初探 create-t3-app

2025-06-08

初探 create-t3-app

大纲

该项目的所有代码都可以在我的 GitHub 上的First Look monorepo中找到。

介绍

create-t3-app是一个全栈 React 框架和 CLI,它是 Theo Browne 网站上推荐的 T3 堆栈的演变版本init.tips。它的创建者将其描述为“一种模板”,旨在强调它“不是模板”

为何创建 t3 堆栈

ct3a的目标是提供最快捷的方式来启动一个全新的全栈、类型安全的 Web 应用。为了实现这一目标,该堆栈围绕三个基础组件构建,这些组件可以捆绑在一起,用于开发单体应用:

根据您的背景和观点,这可能听起来像是一项突破性的创新,对二十多年前使用的技术的完全明显的重新包装,或者绝对的异端邪说,因为您被教导说开发整体是一种罪过。

01 - 端到端类型安全

资料来源:Sabin Adams -端到端类型安全

作为一个一直抵制 TypeScript 的人,这对我来说很可怕。但如果这个技术栈真的能提供流畅、精简的 TypeScript 体验,我就要破例,平生第一次拥抱 TypeScript。

但对于那些已经爱上 TypeScript 和全栈 React 框架的人来说,你现在可能会有一种似曾相识的感觉。这几乎与 Blitz.js 完全相同,并且共享许多相同的架构原则。显著的区别是 CTA 包含 tRPC(tRPC 本身也经常被拿来与 Blitz.js 比较)。

t3 堆栈和 Create Nex 应用的历史

该网站的第一次迭代init.tips建议只需要一个命令就可以为 2021 年的大多数 Web 应用程序初始化一个最优化的样板。这个建议(以其无限的智慧)是:创建一个 Next.js 应用程序, 但使用 TypeScript

02 - 初始提示第一版首页的截图。页面几乎完全空白,只有一条简短的信息:

当人们开始考虑这个建议时,许多开发人员不可避免地会问:

“嗯,但是我需要制作一个边界功能应用程序,而这个堆栈中没有包含的所有其他东西怎么办呢?”

这引出了关于堆栈附加组件的其他建议。这些附加组件针对特定的用例,例如:

  • Prisma通过 ORM 管理数据库迁移和 SQL 查询
  • Next-auth用于客户端身份验证
  • Tailwind用于 CSS 和 UI 样式
  • tRPC用于端到端类型安全 API

如果这些命令经常被推荐,那么创建一个新的、功能更全面的命令就显得理所当然。这不仅会生成一个类型化的 Next.js 项目,还会生成一个具有 ORM、身份验证、样式和 API 协议的项目。

这些功能会自动包含在内,同时,如果您仍然想要精简版,也可以选择退出。我很高兴这项功能正在推广,而且有些人认为这是一个新颖的想法。

过去两年,我一直在不懈地推广各种框架,它们都集成了不同版本的技术栈。RedwoodJS、Blitz.js 和 Bison 的堆栈都非常相似,但也略有不同。为了理解它们之间的关系,我会将其分解如下:

03 - create-t3-app、RedwoodJS、Blitz.js、Bison 框架对比表

这并不是一个详尽的清单,我故意省略了测试、模拟、故事书、部署和其他非架构部分。

随着项目从 演变为init.tipscreate-t3-app它已拥有了自己的生命力。Theo 曾多次声明,他实际上并非 的发起者create-t3-app,他只是在公开场合多次谈论过这个想法。

事实上,他根本没时间去构建或管理这样一个项目。除了全职创作内容之外,他还是一家初创公司的首席执行官,该公司正在开发协作流媒体工具ping.gg。他对这个项目的影响力主要源于他对该技术栈的各种公开讨论。

这些讨论启发了他新创建的Discord服务器的一群成员。这个在线空间旨在聚集他TwitchYouTube频道的粉丝。一个小组开始独立构建一个成熟的项目。这个项目以Shoubhit Dash的作品为中心。

Shoubhit 率先通过开发交互式 CLI 工具(称为nexxelnexxeln online)将该堆栈正式化,该工具能够使用堆栈中使用的各种技术的任意组合来搭建项目。nexxel 是一位 17 岁的自学成才的开发人员,是该项目真正的罗塞塔石碑。

04 - nexxel 的第一条关于 create-nex-app 的 discord 消息

Nexxel 在 5 月份框架发布前就曾撰写过关于 tRPC 的博客。这篇名为“使用 tRPC 构建端到端类型安全 API”的文章标志着该框架于 2022 年 5 月 21 日正式诞生,并于 2022 年 5 月 20 日进行了首次提交。该项目最初名为Create Nex App,其README文件如下:

使用此交互式 CLI使用t3 堆栈搭建一个启动项目。

该项目的早期原型包括 Next.js、Tailwind、TypeScript 和 tRPC。整个六月,该项目开始吸引大约十几位贡献者。Julius Marminge ( juliusmarminge ) 是最早的贡献者之一,至今仍然活跃。

大约一个月后,即 2022 年 6 月 26 日,nexxel 发布了T3 堆栈,这也是我迄今为止最受欢迎的开源项目。这篇博文是在与其他贡献者合作完成 Prisma 和 Next Auth 的全面集成后发布的,标志着该堆栈初始集成阶段的完成。

整个六月,GitHub repo获得了近 2,000 个 GitHub 星标。尽管该项目于五月底才创建,但发展势头却达到了前所未有的水平。2022 年 7 月 17 日,nexxel将他的个人博客迁移到 create-t3-app,到 8 月中旬,该项目已获得超过 5,000 个星标。

05 - create-t3-app 明星历史图表显示 2022 年夏季急剧增长

创建 t3 应用程序

要开始使用ct3a,您可以运行以下三个命令中的任意一个并回答命令提示符问题:

npx create-t3-app@latest
Enter fullscreen mode Exit fullscreen mode
yarn create t3-app
Enter fullscreen mode Exit fullscreen mode
pnpm dlx create-t3-app@latest
Enter fullscreen mode Exit fullscreen mode

当前可用的 CLI 选项如下:

选项 描述
--noGit 明确告诉 CLI 不要在项目中初始化新的 git repo
-y--default 绕过 CLI 并使用所有默认选项来引导新的 t3-app
[dir] 包含一个带有项目名称的目录参数
--noInstall 生成项目而不安装依赖项

我们将为项目命名并选择除 NextAuth 之外的所有可用选项。

pnpm dlx create-t3-app@latest ajcwebdev-t3
Enter fullscreen mode Exit fullscreen mode

我将选择以下选项:

? Will you be using JavaScript or TypeScript?  TypeScript
? Which packages would you like to enable?     prisma, tailwind, trpc
? Initialize a new git repository?             Yes
? Would you like us to run 'pnpm install'?     Yes
Enter fullscreen mode Exit fullscreen mode

包含该-y选项将选择默认配置,将所有四个包捆绑到项目中。交互式 CLI 提示符还会询问您是否要使用 JavaScript 或 TypeScript。但是,如果您尝试选择 JavaScript,您会发现该选项只是一种幻觉。事实上,您必须使用 TypeScript,而且也没有上帝。

Using: pnpm

✔ ajcwebdev-t3 scaffolded successfully!

✔ Successfully setup boilerplate for prisma
✔ Successfully setup boilerplate for tailwind
✔ Successfully setup boilerplate for trpc
✔ Successfully setup boilerplate for envVariables
✔ Successfully installed dependencies!
✔ Successfully initialized git
Enter fullscreen mode Exit fullscreen mode

进入您的项目目录并启动开发服务器。

cd ajcwebdev-t3
pnpm dev
Enter fullscreen mode Exit fullscreen mode

打开localhost:3000就可以看到生成的项目了。

06 - 在本地主机上运行 create-t3-app

项目结构

如果我们忽略项目根目录中的配置文件,那么我们的文件夹和文件结构包括以下内容:

.
├── prisma
│   └── schema.prisma
├── public
│   └── favicon.ico
└── src
    ├── env
    │   ├── client.mjs
    │   ├── schema.mjs
    │   └── server.mjs
    ├── pages
    │   ├── _app.tsx
    │   ├── api
    │   │   ├── examples.ts
    │   │   └── trpc
    │   │       └── [trpc].ts
    │   └── index.tsx
    ├── server
    │   ├── db
    │   │   └── client.ts
    │   └── trpc
    │       ├── context.ts
    │       ├── router
    │       │   ├── _app.ts
    │       │   └── example.ts
    │       └── trpc.ts
    ├── styles
    │   └── globals.css
    └── utils
        └── trpc.ts
Enter fullscreen mode Exit fullscreen mode

随着教程的进行,我们将逐一介绍这些不同的目录和文件。首先,我们主要讨论:

  • 页面和 API 路由
    • src/pages/index.tsx
    • src/pages/api/examples.ts
    • src/pages/api/trpc/[trpc].ts
  • 服务器和数据库
    • src/server/db/client.ts
    • src/server/trpc/context.ts
    • src/server/trpc/router/_app.ts
    • src/server/trpc/router/example.ts
    • src/server/trpc/trpc.ts
  • 造型
    • src/styles/globals.css
  • 实用工具
    • src/utils/trpc.ts

顺风风格

打开src/pages/index.tsx并进行一些更改以自定义主页。您可以随意跟随教程或进行自己的修改,这个项目有很多不同的组织方式。首先,我将创建一个名为 file called 的文件,home-styles.ts用于保存网站主页上将使用的所有样式。

echo > src/styles/home-styles.ts
Enter fullscreen mode Exit fullscreen mode

我将导出一个定义每个 Tailwind 样式的变量,该变量可以在整个项目中重复使用。

// src/styles/home-styles.ts

export const appContainer = "container mx-auto flex flex-col items-center justify-center min-h-screen p-4"
export const title = "text-5xl md:text-[5rem] leading-normal font-extrabold text-gray-700"
export const purple = "text-purple-300"
export const body = "text-2xl text-gray-700"
export const grid = "grid gap-3 pt-3 mt-3 text-center md:grid-cols-2 lg:w-2/3"
export const queryResponse = "pt-6 text-2xl text-blue-500 flex justify-center items-center w-full"
Enter fullscreen mode Exit fullscreen mode
  • appContainermain内容样式
  • title设置页面h1标题的样式
  • purpleT3 采用标志性的紫色
  • body样式化p标签,介绍堆栈中包含的技术列表
  • grid组件的div包装样式TechnologyCard
  • queryResponsediv包装 tRPChello查询的样式

将这些样式变量添加到Home组件。

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import {
  appContainer, title, purple, body, grid, queryResponse
} from "../styles/home-styles"

export default function Home() {
  const hello = trpc.useQuery([
    "example.hello",
    { text: "from tRPC" }
  ])

  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta name="description" content="Example t3 project from A First Look at create-t3-app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={appContainer}>
        <h1 className={title}>
          Create <span className={purple}>T3</span> App
        </h1>

        <p className={body}>This stack uses:</p>

        <div className={grid}>...</div>
        <div className={queryResponse}>...</div>
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode
echo > src/styles/card-styles.ts
Enter fullscreen mode Exit fullscreen mode

给组件添加样式变量TechnologyCard

  • cardSectionsection在元素上设置卡片容器的样式
  • cardTitle在每张卡片上设置技术标题的样式
  • cardDescription对每种技术的描述进行样式化
  • link设置每张卡片上的链接样式
// src/styles/card-styles.ts

export const cardSection = "flex flex-col justify-center p-6 duration-500 border-2 border-gray-500 rounded shadow-xl motion-safe:hover:scale-105"
export const cardTitle = "text-lg text-gray-700"
export const cardDescription = "text-sm text-gray-600"
export const link = "mt-3 text-sm underline text-violet-500 decoration-dotted underline-offset-2"
Enter fullscreen mode Exit fullscreen mode
// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import { appContainer, title, purple, body, grid, queryResponse } from "../styles/home-styles"
import { cardSection, cardTitle, cardDescription, link } from "../styles/card-styles"

type TechnologyCardProps = {...}

const TechnologyCard = ({ name, description, documentation }: TechnologyCardProps) => {
  return (
    <section className={cardSection}>
      <h2 className={cardTitle}>
        {name}
      </h2>

      <p className={cardDescription}>
        {description}
      </p>

      <a className={link} href={documentation} target="_blank" rel="noreferrer">
        Documentation
      </a>
    </section>
  )
}
Enter fullscreen mode Exit fullscreen mode

现在,我将修改这四张卡片,使其包含指向我的博客和社交媒体资料的链接。考虑到这一点,我将使用url而不是 来documentation作为更合适的道具名称。我还将修改链接,使其将整个卡片包含在锚标签中,这样点击卡片上的任意位置都会打开超链接。

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import {
  appContainer, title, purple, body, grid, queryResponse, cardSection, cardTitle, cardDescription, link
} from "../styles/home-styles"

type TechnologyCardProps = {
  name: string
  url: string
}

const TechnologyCard = ({ name, url }: TechnologyCardProps) => {
  return (
    <a href={`https://${url}`} target="_blank" rel="noreferrer">
      <section className={cardSection}>
        <h2 className={cardTitle}>
          {name}
        </h2>

        <span className={link}>
          {url}
        </span>
      </section>
    </a>
  )
}

export default function Home() {
  const hello = trpc.useQuery([
    "example.hello",
    { text: "from tRPC" }
  ])

  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta name="description" content="Example t3 project from A First Look at create-t3-app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={appContainer}>
        <h1 className={title}>
          Hello from <span className={purple}>ajc</span>webdev
        </h1>

        <div className={grid}>
          <TechnologyCard name="Blog" url="ajcwebdev.com/" />
          <TechnologyCard name="Twitter" url="twitter.com/ajcwebdev/" />
          <TechnologyCard name="GitHub" url="github.com/ajcwebdev/" />
          <TechnologyCard name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <div className={queryResponse}>
          {
            hello.data
              ? <p>{hello.data.greeting}</p>
              : <p>Loading..</p>
          }
        </div>
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

返回localhost:3000查看更改。

07 - 包含 ajcwebdev 信息的主页

最后,我将把TechnologyCard组件抽象到它自己的文件中并将其重命名为Card

mkdir src/components
echo > src/components/Card.tsx
Enter fullscreen mode Exit fullscreen mode

重命名TechnologyCardPropsCardProps创建一个Card组件。

// src/components/Card.tsx

import { cardSection, cardTitle, link } from "../styles/home-styles"

type CardProps = {
  name: string
  url: string
}

export default function Card({
  name, url
}: CardProps) {
  return (
    <a href={`https://${url}`} target="_blank" rel="noreferrer">
      <section className={cardSection}>
        <h2 className={cardTitle}>
          {name}
        </h2>

        <span className={link}>
          {url}
        </span>
      </section>
    </a>
  )
}
Enter fullscreen mode Exit fullscreen mode

导入删除Cardsrc/pages/index.tsxCardProps

// src/pages/index.tsx

import Head from "next/head"
import Card from "../components/Card"
import { appContainer, title, purple, grid } from "../styles/home-styles"

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta
          name="description"
          content="Example t3 project from A First Look at create-t3-app"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={appContainer}>
        <h1 className={title}>
          Hello from <span className={purple}>ajc</span>webdev
        </h1>

        <div className={grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

配置 PostgreSQL 数据库

由于这是一个全栈框架,它已经包含了一个名为 Prisma 的工具来处理我们的数据库。我们的模型将prisma/schema.prisma与特定的数据库提供程序一起在文件中定义。

将 Posts 模型添加到 Prisma Schema

初始生成的项目已将数据库datasource设置为 SQLite。由于我们想使用真实的数据库,因此请打开schema.prisma并更新datasource到 PostgreSQL 提供程序。

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
Enter fullscreen mode Exit fullscreen mode

除了模式中的当前模型之外,还添加一个Post带有idtitledescriptionbodycreatedAt时间戳的模型。

// prisma/schema.prisma

model Post {
  id                String    @id
  title             String
  description       String
  body              String
  createdAt         DateTime  @default(now())
}
Enter fullscreen mode Exit fullscreen mode

@db.Text此外,取消对模型上所有外观的注释Account

// prisma/schema.prisma

model Account {
  id                String    @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?   @db.Text
  access_token      String?   @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?   @db.Text
  session_state     String?
  user              User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}
Enter fullscreen mode Exit fullscreen mode

初始化铁路数据库并运行迁移

我们将使用 Railway 来配置 PostgreSQL 数据库。首先,您需要创建一个 Railway 帐户并安装Railway CLI。如果您无法通过浏览器登录,请运行railway login --browserless以下命令。

railway login
Enter fullscreen mode Exit fullscreen mode

运行以下命令,选择“空项目”,并为项目命名。

railway init
Enter fullscreen mode Exit fullscreen mode

要配置数据库,请向您的 Railway 项目添加一个插件并选择 PostgreSQL。

railway add
Enter fullscreen mode Exit fullscreen mode

DATABASE_URL为您的数据库设置环境变量并创建一个.env文件来保存它。

echo DATABASE_URL=`railway variables get DATABASE_URL` > .env
Enter fullscreen mode Exit fullscreen mode

使用 运行迁移,prisma migrate dev生成创建新迁移所需的文件夹和文件。我们将init使用--name参数来命名迁移。

pnpm prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

迁移完成后,使用 生成 Prisma 客户端prisma generate

pnpm prisma generate
Enter fullscreen mode Exit fullscreen mode

播种博客文章

目前,我们将避免通过应用程序实现具有写入、更新或删除功能的端点,因为本节不涉及身份验证。但是,至少有五种不同的方法可以将数据写入数据库。如果您想跳过本节,简而言之,就是通过 Railway Query 仪表板发送原始 SQL 查询。

Railway 仪表板提供了三种单独的方法来访问您的数据库(不包括在项目中使用连接字符串本身作为环境变量,我们稍后会这样做):

  • 在“查询”选项卡下执行原始 SQL 查询
  • 使用“连接”选项卡psql下的命令连接到数据库
  • 使用 Railway 的 UI 在“数据”选项卡下输入数据

对于 Prisma,您可以:

注意:为了完整性起见,我在这里提到了 Prisma Studio,但我建议不要使用它。它是一款看似精美的产品,但却有一个非常奇怪的输入错误:如果你在将记录添加到表格之前没有离开输入框,它就会抛出一个值。这意味着你可能会创建一条记录,然后一个关键字段会被完全清除并替换为空值。

是的,这应该只是一个测试数据库,而且确实只是虚拟数据。但对我来说,尤其是对于一个数据库工具来说,这似乎从根本上就存在问题,老实说,我不建议谨慎使用这个工具。我第一次遇到这个漏洞是在 2021 年底左右,你可以在 2022年 11 月录制的《Teach Jenn Tech》节目中观看该漏洞的录像。

对于缺乏 SQL 经验的开发者来说,GUI 更加直观。但遗憾的是,GUI 也可能存在 bug 或操作繁琐。尤其当您需要一次性输入大量数据时,您肯定不想手动输入每一行。SQL 命令提供了一种更一致、更可扩展的技术,可用于创建数据库种子或持续输入新数据。

列表中的第一个选项(在 Railway 仪表板的“查询”选项卡下执行原始 SQL 查询)为我们提供了两全其美的解决方案。它不需要在任何 GUI 中输入数据,也不需要像psql在本地计算机上安装 Postgres 客户端并通过网络连接到数据库实例。我们可以使用以下命令创建博客文章:

INSERT INTO "Post" (id, title, description, body) VALUES (
  '1',
  'A Blog Post Title',
  'This is the description of a blog post',
  'The body of the blog post is here. It is a very good blog post.'
);
Enter fullscreen mode Exit fullscreen mode

该 SQL 命令可以直接输入到“查询”选项卡下的文本区域中。

08 - 在铁路仪表板的查询选项卡中使用原始 SQL 创建帖子

单击“运行查询”,然后再添加两篇博客文章:

INSERT INTO "Post" (id, title, description, body) VALUES (
  '2',
  'Second Blog Post',
  'This is the description of ANOTHER blog post',
  'Even better than the last!'
);
INSERT INTO "Post" (id, title, description, body) VALUES (
  '3',
  'The Final Blog Post',
  'This is the description for my final blog post',
  'My blogging career is over. This is the end, thank you.'
);
Enter fullscreen mode Exit fullscreen mode

使用 tRPC 查询帖子

tRPC 是一个专为编写类型安全 API 而设计的库。客户端无需导入服务器代码,只需导入一个 TypeScript 类型即可。tRPC 会将此类型转换为完全类型安全的客户端,以便前端调用。

创建帖子路由器

创建一个名为 的文件来初始化路由器实例postRouter。这将查询我们所有的帖子。

echo > src/server/router/post.ts
Enter fullscreen mode Exit fullscreen mode

使用该方法向路由器添加查询端点.query()。它接受两个参数:name端点名称和params查询参数。

// src/server/router/post.ts

import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      // Add Prisma query
    },
  })
Enter fullscreen mode Exit fullscreen mode

params.resolve实现端点,它将是一个具有单个req参数的函数,该函数运行 Prisma ClientfindMany查询,该查询返回记录列表,在本例中all是基于post模型的帖子。

// src/server/router/post.ts

import { prisma } from "../db/client"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    },
  })
Enter fullscreen mode Exit fullscreen mode

params.input提供输入验证,将在创建默认查询单元部分讨论

创建应用路由器

在 中src/server/router/index.ts,有一个appRouter用于服务器入口点的基础。它可以逐渐扩展更多类型,并解析为单个对象。

// src/server/router/index.ts

import superjson from "superjson"
import { createRouter } from "./context"
import { exampleRouter } from "./example"
import { protectedExampleRouter } from "./protected-example-router"

export const appRouter = createRouter()
  .transformer(superjson)
  .merge("example.", exampleRouter)
  .merge("question.", protectedExampleRouter)

export type AppRouter = typeof appRouter
Enter fullscreen mode Exit fullscreen mode

导入postRouter并使用.merge()方法将以下三个路由组合成一个appRouter实例:

  • exampleRouter
  • postRouter
  • protectedExampleRouter
// src/server/router/index.ts

import superjson from "superjson"
import { createRouter } from "./context"
import { exampleRouter } from "./example"
import { postRouter } from "./post"
import { protectedExampleRouter } from "./protected-example-router"

export const appRouter = createRouter()
  .transformer(superjson)
  .merge("example.", exampleRouter)
  .merge("post.", postRouter)
  .merge("question.", protectedExampleRouter)

export type AppRouter = typeof appRouter
Enter fullscreen mode Exit fullscreen mode

post与博客文章相关的查询将以( post.all, )为前缀post.byId。hello查询示例将以example前面所见的 为前缀example.hello

使用 useQuery 查询帖子

打开src/pages/index.tsx查询所有帖子并将其显示在主页上。创建一个组件,并在 return 语句上方Posts初始化一个名为的变量。使用钩子将变量设置为 的输出postsQuerypostsQuerypost.alluseQuery()

// src/pages/index.tsx

import Head from "next/head"
import { trpc } from "../utils/trpc"
import {
  appContainer, title, purple, body, grid, queryResponse, cardSection, cardTitle, cardDescription, link
} from "../styles/home-styles"
import Card from "../components/Card"

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (...)
}

export default function Home() {...}
Enter fullscreen mode Exit fullscreen mode

如上一节所述,该appRouter对象可以在客户端推断。将 JSON 输出字符串化postsQuery.data,并将数据显示在页面标题下方。

// src/pages/index.tsx

const Posts = () => {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  const { data } = postsQuery

  return (
    <div className={queryResponse}>
      {data
        ? <p>{JSON.stringify(data)}</p>
        : <p>Loading..</p>
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Posts在组件中返回Home

// src/pages/index.tsx

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta name="description" content="Example t3 project from A First Look at create-t3-app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={appContainer}>
        <h1 className={title}>
          Hello from <span className={purple}>ajc</span>webdev
        </h1>

        <div className={grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <Posts />
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

09 - 在主页上显示带有博客文章的 json 对象

我们有一些条件逻辑,确保在服务器数据尚未返回时显示加载消息。但是,如果数据库中没有博客文章,或者服务器返回错误怎么办?这种情况非常适合使用单元格。

添加用于声明式数据获取的单元格

我最喜欢的 Redwood 模式之一是 Cell 的概念,我一直希望它也能在其他框架中看到。Cell 提供了一种内置的声明式数据获取约定,它并非完全是状态机,但具有一些共同的优点和特性。

与通用有限状态机不同,单元专注于常见的数据获取结果。它们使开发人员能够避免编写任何条件逻辑,因为单元将管理数据获取过程中以下四种潜在状态的执行情况:

  • 成功-显示响应数据
  • 失败- 处理错误消息并向用户提供说明
  • - 显示一条消息或图形,传达空列表
  • 正在加载- 显示一条消息或图形,表明数据仍在加载

值得庆幸的是,当首席 tRPC 维护者Alex Johansson 打开一个 PR并附带一个 tRPC Cell 示例时,我的希望实现了,他承认该示例受到了 RedwoodJS 的影响

创建默认查询单元

createQueryCell用于引导DefaultQueryCell,可以在应用程序的任何地方使用。

echo > src/utils/DefaultQueryCell.tsx
Enter fullscreen mode Exit fullscreen mode

理想情况下,未来有一天,它会内置于 tRPC 或 tRPC 中,create-t3-app这样你就可以直接编写 cell,无需思考。但目前,我们需要自己创建它。

// src/utils/DefaultQueryCell.tsx

import { TRPCClientErrorLike } from "@trpc/client"
import NextError from "next/error"
import type { AppRouter } from "../server/router/index"
import {
  QueryObserverIdleResult,
  QueryObserverLoadingErrorResult,
  QueryObserverLoadingResult,
  QueryObserverRefetchErrorResult,
  QueryObserverSuccessResult,
  UseQueryResult,
} from "react-query"

type JSXElementOrNull = JSX.Element | null

type ErrorResult<TData, TError> =
  | QueryObserverLoadingErrorResult<TData, TError>
  | QueryObserverRefetchErrorResult<TData, TError>

interface CreateQueryCellOptions<TError> {
  error: (query: ErrorResult<unknown, TError>) => JSXElementOrNull
  loading: (query: QueryObserverLoadingResult<unknown, TError>) => JSXElementOrNull
  idle: (query: QueryObserverIdleResult<unknown, TError>) => JSXElementOrNull
}

interface QueryCellOptions<TData, TError> {
  query: UseQueryResult<TData, TError>
  error?: (query: ErrorResult<TData, TError>) => JSXElementOrNull
  loading?: (query: QueryObserverLoadingResult<TData, TError>) => JSXElementOrNull
  idle?: (query: QueryObserverIdleResult<TData, TError>) => JSXElementOrNull
}

interface QueryCellOptionsWithEmpty<TData, TError>
  extends QueryCellOptions<TData, TError> {
  success: (query: QueryObserverSuccessResult<NonNullable<TData>, TError>) => JSXElementOrNull
  empty: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull
}
interface QueryCellOptionsNoEmpty<TData, TError>
  extends QueryCellOptions<TData, TError> {
  success: (query: QueryObserverSuccessResult<TData, TError>) => JSXElementOrNull
}

function createQueryCell<TError>(
  queryCellOpts: CreateQueryCellOptions<TError>,
) {
  function QueryCell<TData>(opts: QueryCellOptionsWithEmpty<TData, TError>): JSXElementOrNull
  function QueryCell<TData>(opts: QueryCellOptionsNoEmpty<TData, TError>): JSXElementOrNull
  function QueryCell<TData>(opts:
    | QueryCellOptionsNoEmpty<TData, TError>
    | QueryCellOptionsWithEmpty<TData, TError>,
  ) {
    const { query } = opts

    if (query.status === 'success') {
      if ('empty' in opts &&
        (query.data == null ||
          (Array.isArray(query.data) && query.data.length === 0))
      ) {
        return opts.empty(query)
      }
      return opts.success(query as QueryObserverSuccessResult<NonNullable<TData>, TError>)
    }

    if (query.status === 'error') {
      return opts.error?.(query) ?? queryCellOpts.error(query)
    }
    if (query.status === 'loading') {
      return opts.loading?.(query) ?? queryCellOpts.loading(query)
    }
    if (query.status === 'idle') {
      return opts.idle?.(query) ?? queryCellOpts.idle(query)
    }
    return null
  }
  return QueryCell
}

type TError = TRPCClientErrorLike<AppRouter>

export const DefaultQueryCell = createQueryCell<TError>({
  error: (result) => (
    <NextError
      title={result.error.message}
      statusCode={result.error.data?.httpStatus ?? 500}
    />
  ),
  idle: () => <div>Loading...</div>,
  loading: () => <div>Loading...</div>,
})
Enter fullscreen mode Exit fullscreen mode

我们希望能够根据其查询单个博客文章id。创建一个基于动态路由post的页面id

mkdir src/pages/post
echo > src/pages/post/\[id\].tsx
Enter fullscreen mode Exit fullscreen mode

由于我们要将数据发送到数据库,因此需要验证inputzod它是一个具有静态类型推断的 TypeScript 模式验证器。我们还将导入它TRPCError以进行错误处理。

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })
Enter fullscreen mode Exit fullscreen mode

将查询添加byId到 Post 路由器并从中src/server/router/post.ts解构idinput

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
    },
  })
Enter fullscreen mode Exit fullscreen mode

findUniqueid查询允许您根据传递给 Prisma 的选项所提供的信息检索单个数据库记录where

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
      const post = await prisma.post.findUnique({
        where: { id }
      })
    },
  })
Enter fullscreen mode Exit fullscreen mode

TRPCError最后但同样重要的一点是,如果未返回帖子,则会引发错误。

// src/server/router/post.ts

import { prisma } from "../db/client"
import { TRPCError } from "@trpc/server"
import { z } from "zod"
import { createRouter } from "./context"

export const postRouter = createRouter()
  .query('all', {
    async resolve() {
      return prisma.post.findMany()
    }
  })

  .query('byId', {
    input: z.object({ id: z.string() }),
    async resolve({ input }) {
      const { id } = input
      const post = await prisma.post.findUnique({
        where: { id }
      })
      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `No post with id '${id}'`
        })
      }
      return post
    }
  })
Enter fullscreen mode Exit fullscreen mode

创建帖子页面

导入DefaultQueryCellsrc/pages/post/[id].tsx创建一个名为的组件PostPage

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"

export default function PostPage() {
  return (...)
}
Enter fullscreen mode Exit fullscreen mode

返回DefaultQueryCell传递postQueryquerydatasuccess

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"

export default function PostPage() {
  const id = useRouter().query.id as string
  const postQuery = trpc.useQuery([
    'post.byId',
    { id }
  ])

  return (
    <DefaultQueryCell
      query={postQuery}
      success={({ data }) => (
        <>
          <Head>
            <title>{data.title}</title>
            <meta name="description" content={data.description} />
          </Head>

          <main>
            <h1>{data.title}</h1>
            <p>{data.body}</p>
            <em>Created {data.createdAt.toLocaleDateString()}</em>
          </main>
        </>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

最后,添加blogContainerblogTitleblogBody来设置帖子样式。

// src/pages/post/[id].tsx

import { useRouter } from "next/router"
import Head from "next/head"
import { DefaultQueryCell } from "../../utils/DefaultQueryCell"
import { trpc } from "../../utils/trpc"
import { blogContainer, blogTitle, blogBody } from "../../styles/blog-styles"

export default function PostPage() {
  const id = useRouter().query.id as string
  const postQuery = trpc.useQuery([
    'post.byId',
    { id }
  ])

  return (
    <DefaultQueryCell
      query={postQuery}
      success={({ data }) => (
        <>
          <Head>
            <title>{data.title}</title>
            <meta name="description" content={data.description} />
          </Head>

          <main className={blogContainer}>
            <h1 className={blogTitle}>
              {data.title}
            </h1>
            <p className={blogBody}>
              {data.body}
            </p>

            <em>Created {data.createdAt.toLocaleDateString()}</em>
          </main>
        </>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

打开localhost:3000/post/1查看您的第一篇博客文章。

10 - 第一个博客文章页面

创建帖子单元格

echo > src/components/PostsCell.tsx
echo > src/styles/blog-styles.ts
Enter fullscreen mode Exit fullscreen mode
// src/styles/blog-styles.ts

export const blogContainer = "container mx-auto min-h-screen p-4"
export const blogTitle = "text-5xl leading-normal font-extrabold text-gray-700"
export const blogBody = "mb-2 text-lg text-gray-700"
export const blogHeader = "text-5xl leading-normal font-extrabold text-gray-700"
Enter fullscreen mode Exit fullscreen mode

创建一个PostsCell函数并在其上方导入以下内容:

  • Link用于链接到每个博客文章的页面
  • blogHeaderlink用于设置 Cell 输出列表的样式
  • DefaultQueryCell用于创建单元格
  • trpc用于执行查询
// src/components/PostsCell.tsx

import Link from "next/link"
import { blogHeader, link } from "../styles/blog-styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

export default function PostsCell() {
  return (...)
}
Enter fullscreen mode Exit fullscreen mode

创建一个名为 的类型,类型BlogPostProps。删除中的组件,并将钩子移到 组件中idtitlestringPostssrc/pages/index.tsxuseQueryPostsCell

// src/components/PostsCell.tsx

import Link from "next/link"
import { blogHeader, link } from "../styles/blog-styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

type BlogPostProps = {
  id: string
  title: string
}

export default function PostsCell() {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (...)
}
Enter fullscreen mode Exit fullscreen mode

返回设置DefaultQueryCell将映射到对象并显示每个博客文章的链接。querypostsQuerysuccessdata

// src/components/PostsCell.tsx

import Link from "next/link"
import { blogHeader, link } from "../styles/blog-styles"
import { DefaultQueryCell } from "../utils/DefaultQueryCell"
import { trpc } from "../utils/trpc"

type BlogPostProps = {
  id: string
  title: string
}

export default function PostsCell() {
  const postsQuery = trpc.useQuery([
    'post.all'
  ])

  return (
    <>
      <h2 className={blogHeader}>Posts</h2>

      {postsQuery.status === 'loading'}

      <DefaultQueryCell
        query={postsQuery}
        success={({ data }: any) => (
          data.map(({id, title}: BlogPostProps) => (
            <Link key={id} href={`/post/${id}`}>
              <p className={link}>
                {title}
              </p>
            </Link>
          ))
        )}
        empty={() => <p>WE NEED POSTS!!!</p>}
      />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

在函数中导入并返回组件PostsCellsrc/pages/index.tsxHome

// src/pages/index.tsx

import Head from "next/head"
import { appContainer, title, purple, grid } from "../styles/home-styles"
import Card from "../components/Card"
import PostsCell from "../components/PostsCell"

export default function Home() {
  return (
    <>
      <Head>
        <title>A First Look at create-t3-app</title>
        <meta name="description" content="Example t3 project from A First Look at create-t3-app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={appContainer}>
        <h1 className={title}>
          Hello from <span className={purple}>ajc</span>webdev
        </h1>

        <div className={grid}>
          <Card name="Blog" url="ajcwebdev.com/" />
          <Card name="Twitter" url="twitter.com/ajcwebdev/" />
          <Card name="GitHub" url="github.com/ajcwebdev/" />
          <Card name="Polywork" url="poly.work/ajcwebdev/" />
        </div>

        <PostsCell />
      </main>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

11 - 带有博客文章标题的主页

部署

提交当前更改并使用GitHub CLI在 GitHub 上创建一个新的存储库。

git add .
git commit -m "ct3a"
gh repo create ajcwebdev-t3 --public --push \
  --source=. \
  --description="An example T3 application with Next.js, Prisma, tRPC, and Tailwind deployed on Vercel and Fly." \
  --remote=upstream
Enter fullscreen mode Exit fullscreen mode

部署到 Vercel

在您的机器上安装vercelCLI 或将其添加到您的项目中pnpm

pnpm add -D vercel
Enter fullscreen mode Exit fullscreen mode

使用以下命令传递数据库环境变量并部署到 Vercel。用于--confirm为每个问题提供默认答案。

pnpm vercel --env DATABASE_URL=YOUR_URL_HERE
Enter fullscreen mode Exit fullscreen mode

首次部署后,此命令将部署到预览分支。您需要包含此命令--prod才能将更改直接推送到实际站点,以便将来部署。

打开ajcwebdev-t3.vercel.app即可看到您的博客。

12 - 主页部署在 vercel 上

API 端点在 上公开api/trpc/,因此ajcwebdev-t3.vercel.app/api/trpc/post.all将显示所有博客文章。

13 - Vercel API 路由上的所有帖子 trpc 端点

或者你可以使用 curl 来访问端点:

curl "https://ajcwebdev-t3.vercel.app/api/trpc/post.all" | npx json
Enter fullscreen mode Exit fullscreen mode
{
  "id": null,
  "result": {
    "type": "data",
    "data": {
      "json": [
        {
          "id": "1",
          "title": "A Blog Post Title",
          "description": "This is the description of a blog post",
          "body": "The body of the blog post is here. It is a very good blog post.",
          "createdAt": "2022-08-13T08:30:59.344Z"
        },
        {
          "id": "2",
          "title": "Second Blog Post",
          "description": "This is the description of ANOTHER blog post",
          "body": "Even better than the last!",
          "createdAt": "2022-08-13T08:36:59.790Z"
        },
        {
          "id": "3",
          "title": "The Final Blog Post",
          "description": "This is the description for my final blog post",
          "body": "My blogging career is over. This is the end, thank you.",
          "createdAt": "2022-08-13T08:40:32.133Z"
        }
      ],
      "meta": {
        "values": {...}
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

对于单篇博客文章,请尝试以下任一操作:

并将它们复制到末尾:

https://ajcwebdev-t3.vercel.app/api/trpc/post.byId?batch=1&input=

在此处查看桌面版 PageSpeed Insights

14 - Pagespeed insight 桌面

如果我对这些指标的了解是正确的,那么我相信与其他非 100 的分数相比,100 被认为是一个更好的分数。

在此处查看移动版 PageSpeed Insights

15 - Pagespeed insight 移动版

又是100!同样好!

部署飞行

由于create-t3-app最终主要使用 Next.js 和 Prisma,因此它可以非常轻松地部署在 Vercel 等平台上。但是,作为易用性的回报,每次查询数据库时都会受到性能损失。

Prisma 在 Lambda 函数中运行时,会出现明显的冷启动问题。ct3a 文档后续的指南将演示如何使用 Fly、Railway 和 Render 等平台将项目部署到长期运行的服务器。安装flyctlCLI 并运行以下命令来初始化您的项目。

fly launch --remote-only \
  --name ajcwebdev-t3 \
  --region ord \
  --env DATABASE_URL=YOUR_URL_HERE
Enter fullscreen mode Exit fullscreen mode

flyctl platform regions查看可用区域。

资源、文章和视频

鏂囩珷鏉ユ簮锛�https://dev.to/ajcwebdev/a-first-look-at-create-t3-app-1i8f
PREV
初探无服务器云
NEXT
使用 Github Actions 搭建番茄钟!我的工作流程 pomodoro-clock