初探 create-t3-app
大纲
该项目的所有代码都可以在我的 GitHub 上的First Look monorepo中找到。
介绍
create-t3-app是一个全栈 React 框架和 CLI,它是 Theo Browne 网站上推荐的 T3 堆栈的演变版本init.tips。它的创建者将其描述为“一种模板”,旨在强调它“不是模板”。
为何创建 t3 堆栈
ct3a的目标是提供最快捷的方式来启动一个全新的全栈、类型安全的 Web 应用。为了实现这一目标,该堆栈围绕三个基础组件构建,这些组件可以捆绑在一起,用于开发单体应用:
- 类型化 React 前端(TypeScript和Next.js)
- 类型数据库客户端(Prisma)
- 类型化远程过程调用(tRPC)
根据您的背景和观点,这可能听起来像是一项突破性的创新,对二十多年前使用的技术的完全明显的重新包装,或者绝对的异端邪说,因为您被教导说开发整体是一种罪过。
资料来源:Sabin Adams -端到端类型安全
作为一个一直抵制 TypeScript 的人,这对我来说很可怕。但如果这个技术栈真的能提供流畅、精简的 TypeScript 体验,我就要破例,平生第一次拥抱 TypeScript。
但对于那些已经爱上 TypeScript 和全栈 React 框架的人来说,你现在可能会有一种似曾相识的感觉。这几乎与 Blitz.js 完全相同,并且共享许多相同的架构原则。显著的区别是 CTA 包含 tRPC(tRPC 本身也经常被拿来与 Blitz.js 比较)。
t3 堆栈和 Create Nex 应用的历史
该网站的第一次迭代init.tips建议只需要一个命令就可以为 2021 年的大多数 Web 应用程序初始化一个最优化的样板。这个建议(以其无限的智慧)是:创建一个 Next.js 应用程序, 但使用 TypeScript。
当人们开始考虑这个建议时,许多开发人员不可避免地会问:
“嗯,但是我需要制作一个边界功能应用程序,而这个堆栈中没有包含的所有其他东西怎么办呢?”
这引出了关于堆栈附加组件的其他建议。这些附加组件针对特定的用例,例如:
- Prisma通过 ORM 管理数据库迁移和 SQL 查询
- Next-auth用于客户端身份验证
- Tailwind用于 CSS 和 UI 样式
- tRPC用于端到端类型安全 API
如果这些命令经常被推荐,那么创建一个新的、功能更全面的命令就显得理所当然。这不仅会生成一个类型化的 Next.js 项目,还会生成一个具有 ORM、身份验证、样式和 API 协议的项目。
这些功能会自动包含在内,同时,如果您仍然想要精简版,也可以选择退出。我很高兴这项功能正在推广,而且有些人认为这是一个新颖的想法。
过去两年,我一直在不懈地推广各种框架,它们都集成了不同版本的技术栈。RedwoodJS、Blitz.js 和 Bison 的堆栈都非常相似,但也略有不同。为了理解它们之间的关系,我会将其分解如下:
这并不是一个详尽的清单,我故意省略了测试、模拟、故事书、部署和其他非架构部分。
随着项目从 演变为init.tips,create-t3-app它已拥有了自己的生命力。Theo 曾多次声明,他实际上并非 的发起者create-t3-app,他只是在公开场合多次谈论过这个想法。
事实上,他根本没时间去构建或管理这样一个项目。除了全职创作内容之外,他还是一家初创公司的首席执行官,该公司正在开发协作流媒体工具ping.gg。他对这个项目的影响力主要源于他对该技术栈的各种公开讨论。
这些讨论启发了他新创建的Discord服务器的一群成员。这个在线空间旨在聚集他Twitch和YouTube频道的粉丝。一个小组开始独立构建一个成熟的项目。这个项目以Shoubhit Dash的作品为中心。
Shoubhit 率先通过开发交互式 CLI 工具(称为nexxel或nexxeln online)将该堆栈正式化,该工具能够使用堆栈中使用的各种技术的任意组合来搭建项目。nexxel 是一位 17 岁的自学成才的开发人员,是该项目真正的罗塞塔石碑。
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 个星标。
创建 t3 应用程序
要开始使用ct3a,您可以运行以下三个命令中的任意一个并回答命令提示符问题:
npx create-t3-app@latest
yarn create t3-app
pnpm dlx create-t3-app@latest
当前可用的 CLI 选项如下:
| 选项 | 描述 |
|---|---|
--noGit |
明确告诉 CLI 不要在项目中初始化新的 git repo |
-y,--default |
绕过 CLI 并使用所有默认选项来引导新的 t3-app |
[dir] |
包含一个带有项目名称的目录参数 |
--noInstall |
生成项目而不安装依赖项 |
我们将为项目命名并选择除 NextAuth 之外的所有可用选项。
pnpm dlx create-t3-app@latest ajcwebdev-t3
我将选择以下选项:
? 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
包含该-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
进入您的项目目录并启动开发服务器。
cd ajcwebdev-t3
pnpm dev
打开localhost:3000就可以看到生成的项目了。
项目结构
如果我们忽略项目根目录中的配置文件,那么我们的文件夹和文件结构包括以下内容:
.
├── 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
随着教程的进行,我们将逐一介绍这些不同的目录和文件。首先,我们主要讨论:
- 页面和 API 路由
src/pages/index.tsxsrc/pages/api/examples.tssrc/pages/api/trpc/[trpc].ts
- 服务器和数据库
src/server/db/client.tssrc/server/trpc/context.tssrc/server/trpc/router/_app.tssrc/server/trpc/router/example.tssrc/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
我将导出一个定义每个 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"
appContainermain内容样式title设置页面h1标题的样式purpleT3 采用标志性的紫色body样式化p标签,介绍堆栈中包含的技术列表grid组件的div包装样式TechnologyCardqueryResponsediv包装 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>
</>
)
}
echo > src/styles/card-styles.ts
给组件添加样式变量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"
// 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>
)
}
现在,我将修改这四张卡片,使其包含指向我的博客和社交媒体资料的链接。考虑到这一点,我将使用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>
</>
)
}
返回localhost:3000查看更改。
最后,我将把TechnologyCard组件抽象到它自己的文件中并将其重命名为Card。
mkdir src/components
echo > src/components/Card.tsx
重命名TechnologyCardProps并CardProps创建一个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>
)
}
导入和删除Card。src/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>
</>
)
}
配置 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")
}
除了模式中的当前模型之外,还添加一个Post带有id、title、description、body和createdAt时间戳的模型。
// prisma/schema.prisma
model Post {
id String @id
title String
description String
body String
createdAt DateTime @default(now())
}
@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])
}
初始化铁路数据库并运行迁移
我们将使用 Railway 来配置 PostgreSQL 数据库。首先,您需要创建一个 Railway 帐户并安装Railway CLI。如果您无法通过浏览器登录,请运行railway login --browserless以下命令。
railway login
运行以下命令,选择“空项目”,并为项目命名。
railway init
要配置数据库,请向您的 Railway 项目添加一个插件并选择 PostgreSQL。
railway add
DATABASE_URL为您的数据库设置环境变量并创建一个.env文件来保存它。
echo DATABASE_URL=`railway variables get DATABASE_URL` > .env
使用 运行迁移,prisma migrate dev生成创建新迁移所需的文件夹和文件。我们将init使用--name参数来命名迁移。
pnpm prisma migrate dev --name init
迁移完成后,使用 生成 Prisma 客户端prisma generate。
pnpm prisma generate
播种博客文章
目前,我们将避免通过应用程序实现具有写入、更新或删除功能的端点,因为本节不涉及身份验证。但是,至少有五种不同的方法可以将数据写入数据库。如果您想跳过本节,简而言之,就是通过 Railway Query 仪表板发送原始 SQL 查询。
Railway 仪表板提供了三种单独的方法来访问您的数据库(不包括在项目中使用连接字符串本身作为环境变量,我们稍后会这样做):
- 在“查询”选项卡下执行原始 SQL 查询
- 使用“连接”选项卡
psql下的命令连接到数据库 - 使用 Railway 的 UI 在“数据”选项卡下输入数据
对于 Prisma,您可以:
- 登录Prisma数据平台cloud.prisma.io
- 在 localhost 5555 上运行Prisma Studio
pnpm prisma studio
注意:为了完整性起见,我在这里提到了 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.'
);
该 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.'
);
使用 tRPC 查询帖子
tRPC 是一个专为编写类型安全 API 而设计的库。客户端无需导入服务器代码,只需导入一个 TypeScript 类型即可。tRPC 会将此类型转换为完全类型安全的客户端,以便前端调用。
创建帖子路由器
创建一个名为 的文件来初始化路由器实例postRouter。这将查询我们所有的帖子。
echo > src/server/router/post.ts
使用该方法向路由器添加查询端点.query()。它接受两个参数:name端点名称和params查询参数。
// src/server/router/post.ts
import { createRouter } from "./context"
export const postRouter = createRouter()
.query('all', {
async resolve() {
// Add Prisma query
},
})
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()
},
})
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
导入postRouter并使用.merge()方法将以下三个路由组合成一个appRouter实例:
exampleRouterpostRouterprotectedExampleRouter
// 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
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() {...}
如上一节所述,该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>
)
}
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>
</>
)
}
我们有一些条件逻辑,确保在服务器数据尚未返回时显示加载消息。但是,如果数据库中没有博客文章,或者服务器返回错误怎么办?这种情况非常适合使用单元格。
添加用于声明式数据获取的单元格
我最喜欢的 Redwood 模式之一是 Cell 的概念,我一直希望它也能在其他框架中看到。Cell 提供了一种内置的声明式数据获取约定,它并非完全是状态机,但具有一些共同的优点和特性。
与通用有限状态机不同,单元专注于常见的数据获取结果。它们使开发人员能够避免编写任何条件逻辑,因为单元将管理数据获取过程中以下四种潜在状态的执行情况:
- 成功-显示响应数据
- 失败- 处理错误消息并向用户提供说明
- 空- 显示一条消息或图形,传达空列表
- 正在加载- 显示一条消息或图形,表明数据仍在加载
值得庆幸的是,当首席 tRPC 维护者Alex Johansson 打开一个 PR并附带一个 tRPC Cell 示例时,我的希望实现了,他承认该示例受到了 RedwoodJS 的影响。
创建默认查询单元
createQueryCell用于引导DefaultQueryCell,可以在应用程序的任何地方使用。
echo > src/utils/DefaultQueryCell.tsx
理想情况下,未来有一天,它会内置于 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>,
})
我们希望能够根据其查询单个博客文章id。创建一个基于动态路由post的页面。id
mkdir src/pages/post
echo > src/pages/post/\[id\].tsx
由于我们要将数据发送到数据库,因此需要验证input。zod它是一个具有静态类型推断的 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()
}
})
将查询添加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
},
})
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 }
})
},
})
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
}
})
创建帖子页面
导入DefaultQueryCell并src/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 (...)
}
返回DefaultQueryCell并传递postQuery给query和。datasuccess
// 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>
</>
)}
/>
)
}
最后,添加blogContainer、blogTitle和blogBody来设置帖子样式。
// 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>
</>
)}
/>
)
}
打开localhost:3000/post/1查看您的第一篇博客文章。
创建帖子单元格
echo > src/components/PostsCell.tsx
echo > src/styles/blog-styles.ts
// 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"
创建一个PostsCell函数并在其上方导入以下内容:
Link用于链接到每个博客文章的页面blogHeader并link用于设置 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 (...)
}
创建一个名为 的类型,其类型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 (...)
}
返回设置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>}
/>
</>
)
}
在函数中导入并返回组件PostsCell。src/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>
</>
)
}
部署
提交当前更改并使用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
部署到 Vercel
在您的机器上安装vercelCLI 或将其添加到您的项目中pnpm。
pnpm add -D vercel
使用以下命令传递数据库环境变量并部署到 Vercel。用于--confirm为每个问题提供默认答案。
pnpm vercel --env DATABASE_URL=YOUR_URL_HERE
首次部署后,此命令将部署到预览分支。您需要包含此命令
--prod才能将更改直接推送到实际站点,以便将来部署。
打开ajcwebdev-t3.vercel.app即可看到您的博客。
API 端点在 上公开api/trpc/,因此ajcwebdev-t3.vercel.app/api/trpc/post.all将显示所有博客文章。
或者你可以使用 curl 来访问端点:
curl "https://ajcwebdev-t3.vercel.app/api/trpc/post.all" | npx json
{
"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": {...}
}
}
}
}
对于单篇博客文章,请尝试以下任一操作:
%7B%220%22%3A%7B%22json%22%3A%7B%22id%22%3A%221%22%7D%7D%7D%7B%220%22%3A%7B%22json%22%3A%7B%22id%22%3A%222%22%7D%7D%7D%7B%220%22%3A%7B%22json%22%3A%7B%22id%22%3A%223%22%7D%7D%7D
并将它们复制到末尾:
https://ajcwebdev-t3.vercel.app/api/trpc/post.byId?batch=1&input=
在此处查看桌面版 PageSpeed Insights 。
如果我对这些指标的了解是正确的,那么我相信与其他非 100 的分数相比,100 被认为是一个更好的分数。
在此处查看移动版 PageSpeed Insights 。
又是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
flyctl platform regions查看可用区域。
资源、文章和视频
| 日期 | 标题 |
|---|---|
| 2022年8月10日 | 使用 create-t3-app 构建全栈应用程序 |
| 2022年7月10日 | ct3a 端到端教程提案 |
| 2022年6月26日 | T3 堆栈和我最受欢迎的开源项目 |
| 2022年5月21日 | 使用 tRPC 构建端到端类型安全 API |
| 日期 | 标题 |
|---|---|
| 2022年7月17日 | 使用 T3 堆栈构建实时聊天应用程序 |
| 2022年7月12日 | T3 堆栈 - 我们如何构建它 |
| 2022年7月10日 | 创建 T3 应用程序概述 |
| 2022年7月3日 | 适合您的下一个项目的最佳堆栈 |
| 2022年6月28日 | 使用 T3 Stack 构建博客 |
后端开发教程 - Java、Spring Boot 实战 - msg200.com














