初探 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.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
我将导出一个定义每个 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"
appContainer
main
内容样式title
设置页面h1
标题的样式purple
T3 采用标志性的紫色body
样式化p
标签,介绍堆栈中包含的技术列表grid
组件的div
包装样式TechnologyCard
queryResponse
div
包装 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
:
cardSection
section
在元素上设置卡片容器的样式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.tsx
CardProps
// 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
实例:
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
post
与博客文章相关的查询将以( post.all
, )为前缀post.byId
。hello查询示例将以example
前面所见的 为前缀example.hello
。
使用 useQuery 查询帖子
打开src/pages/index.tsx
查询所有帖子并将其显示在主页上。创建一个组件,并在 return 语句上方Posts
初始化一个名为的变量。使用钩子将变量设置为 的输出。postsQuery
postsQuery
post.all
useQuery()
// 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
解构。id
input
// 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
},
})
findUnique
id
查询允许您根据传递给 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
和。data
success
// 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
为。删除中的组件,并将钩子移到 组件中。id
title
string
Posts
src/pages/index.tsx
useQuery
PostsCell
// 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
为。将映射到对象并显示每个博客文章的链接。query
postsQuery
success
data
// 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.tsx
Home
// 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
在您的机器上安装vercel
CLI 或将其添加到您的项目中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 等平台将项目部署到长期运行的服务器。安装flyctl
CLI 并运行以下命令来初始化您的项目。
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 构建博客 |