☰
Schema驱动的全栈代码生成:t3code如何打通类型安全链路
2026/10/9 9:04:14 网站建设 项目流程

t3code 这个名字,最开始只是我本地仓库里的一个随手代号。当时我正在为团队内部一个B端项目发愁:后端把user.status从数字改成了字符串枚举,前端页面毫无征兆地崩了一片;接口文档更新的速度永远赶不上代码提交的速度,联调基本靠语音对喊。后来我决定把这个“发愁”变成一个实际的东西,代号就叫 t3code。

t3code 不是某个开源框架,也不是什么花哨的平台,它是我在维护内部全栈项目时沉淀下来的一套代码生成方案:你只需要维护一份数据模型定义,它会自动产出对应的类型、API 客户端、前端 Hooks 和基础页面,让类型信息从数据库一路长到 UI 层,中间不断开。这篇文章把 t3code 从选型推演、核心实现到完整落地和翻车修复的过程都写出来,适合正在被全栈类型断链、重复 CRUD 和联调效率折磨的开发者参考。如果你只是写个小 Demo,大可不必搞这套;但如果你维护的应用有几十张表、上百个接口,手工写胶水代码的账,算一算就知道不划算。

1. 为什么叫 t3code:它到底想解决什么问题

1.1 一次联调让我决定不再手工写胶水

事情的开端特别朴素。我们有个老项目,后端接口返回status: 1表示“启用”,前端在页面里写死了status === 1的判断。某天后端说要把这个字段升级成可扩展的枚举,改成字符串"active"、"disabled"、"pending",前端完全不知情。上线当天,所有列表页里的状态标签全部失效,用户看到的是一片空白。

这个问题表面上是“接口变更没通知到位”,但根子上是类型断层:后端知道status是什么类型,数据库知道,但前端不知道。中间的 API 层靠一份手写文档维持秩序,文档一旦滞后,整个链路就像一群人在没有图纸的情况下接力施工,后一个人永远只能猜前一个人传过来的到底是个螺丝还是钉子。

我统计了一下当时项目里的状况:一个普通的列表页面,包含类型定义、请求函数、页面状态管理和表单校验,大约 300 到 500 行代码,其中 60% 以上是重复的、可以由机器生成的胶水。真正有业务含量的决策很少,但每一处手写都可能出错。t3code 要解决的核心问题,不是“写代码更快”,而是让跨端类型在编译期就能对上账,把“联调时猜谜”变成“类型不匹配时直接编译失败”。

1.2 t3code 的定位:一份 Schema 驱动的全栈生成方案

t3code 的整体逻辑可以这样概括:你定义一次,它生成四样东西。

  • 类型定义:从zodSchema 推断出的 TypeScript 类型,后端和前端的类型都从同一份 Schema 来。
  • API 层:tRPC Router 的查询、变更过程(procedure),包含输入输出的校验逻辑。
  • 前端数据访问层:封装好的 React Query Hooks,比如usePostList、usePostCreate,组件里直接调用。
  • 基础页面模板:列表页、详情页、表单页的初始版式,后续再手工优化视觉细节。

用装修来类比:Schema 是整套房子唯一权威的施工图纸,t3code 相当于一支按图纸下料的施工队。图纸上画了墙要开多大的窗,施工队不会每面墙都拆开重新量一遍,更不会凭记忆把窗户做成一米九还是两米一。只要图纸更新,施工队重新下料,所有窗户随之更新。放在代码里,就是只要 Schema 变化,后端类型、前端类型、请求函数全部同步变化,不可能出现“后端改了但前端不知道”的中间态。

1.3 t3code 刻意不做什么

任何一个生成方案,最大的风险不是功能不够,而是手伸得太长。我在设计 t3code 时给自己划了几条红线,这些恰恰是它和那些“全自动低代码平台”的本质区别。

  • 不做复杂权限系统。权限往往跟业务组织架构强耦合,自动化生成的权限模块基本没法覆盖真实场景,硬做只会让配置比写代码更复杂。
  • 不做业务状态机。一个订单从待支付到已发货中间有多少分支、多少校验,这些东西必须由业务开发手工编码,模板化的状态机会把人逼疯。
  • 不做视觉设计。t3code 生成的是“能用的骨架”,不是“好看的门面”。视觉相关的打磨留给前端,生成器不该替你做设计决策。

边界划清楚之后,生成器就只是一个“搬运类型”的管道,而不是一个“替你做业务决策”的黑盒。这个定位非常重要,后面我在翻车记录里还会反复回到这一点。

2. 技术选型推演:为什么偏偏是这几个组件

2.1 TypeScript 先行的核心逻辑

t3code 的第一个字母是 T,代表的不是“第三代”什么玄学,而是TypeScript 先行。在我维护过的大大小小的项目里,纯 JavaScript 项目的中后期维护成本几乎必然失控,原因不是程序员不细心,而是人脑无法在每一次修改时同时记住所有相关的调用方。

类型系统在这里扮演的角色,相当于工程施工里的图纸规范。有了类型,改一个字段名,编译器能帮你把全项目所有引用点标红;没有类型,改一个字段名,用户会在你根本想不到的地方遇到线上事故。TypeScript 类型并不能消灭 Bug,但它能把很大一类“低级但致命”的 Bug 从运行时挪到编译期,而编译期的错误修复成本,通常只有运行时的十分之一。

在 t3code 里,TypeScript 不只是一个语言选项,而是整个生成策略的基石。因为要生成类型安全的代码,生成器本身必须有能力读取类型、推断类型、输出类型。zod 和 tRPC 这两个库之所以入选,核心原因就是它们都是类型友好的,能够在运行时校验和静态类型推断之间无缝切换。

2.2 API 层为什么选了 tRPC 而不是 REST 或 GraphQL

API 层的选型是 t3code 里最重要的一次决策。当时我对比了三条技术路线:传统 REST、GraphQL 和 tRPC。它们各有一批拥趸,但用在我这个“Schema 驱动 + 类型全链路”的场景里,差异非常明显。

维度RESTGraphQLtRPC
类型安全需要手工维护 OpenAPI 或独立类型包Schema 层安全,客户端仍需生成代码端到端天然类型安全,无需额外代码生成
客户端调用体验手动拼 URL、处理响应包装查询语言灵活但字符串无类型检查直接调用服务端函数,自动补全
学习成本低高,需要理解 resolver、fragment、缓存策略低,本质是远程函数调用
缓存策略需自己设计Apollo/Urql 缓存策略复杂常搭配 React Query,简单直接
适用规模中大型、多客户端分离大型、数据形态复杂、多端中大型全栈应用、前后端同仓

我最后选了 tRPC,最直接的原因是:我不想再维护一份“接口状态清单”。REST 方式下,即使有 OpenAPI,前后端各自生成类型后仍然可能出现版本不一致;GraphQL 虽然 Schema 强,但为了一个内部后台项目引入整套 GraphQL 的 resolver、fragment 和缓存策略,成本明显偏高。tRPC 的思路是把你后端的函数直接暴露给前端调用,类型是天然共享的,不用额外生成一份接口类型协议。

当然,选 tRPC 不等于它没有缺点。它适合前后端在一个代码仓库、或者至少共享 TypeScript 类型的场景;如果你们是多端分离、或者有第三方外部开发者接入,tRPC 就不太合适了。那不是 t3code 的目标场景。t3code 想优化的,是“开发效率优先、内部系统密集、CRUD 占比高”的标准全栈应用。

2.3 前端层与样式方案:Tailwind + React Query

前端层的选型相对简单,我遵循了一个原则:生成器最容易稳定输出的技术栈,就是最适合模板化的技术栈。

样式方案我选了 Tailwind CSS。原因是它足够“平淡”:类名即是样式,生成器输出一个表单组件时,只需要拼出稳定的 HTML 结构和一组确定的类名即可,不需要去操作 CSS Modules 的哈希名,更不需要去猜 CSS-in-JS 的运行时逻辑。对生成器来说,Tailwind 模板的可预测性非常高,生成的代码不会因为样式方案不同而千奇百怪。

服务端状态管理用了 React Query。它的useQuery和useMutation把请求、缓存、重试、乐观更新这些痛到骨头里的逻辑封装得干净利落。对生成器而言,React Query 的 API 形态高度统一:一个列表查询就是一个useQuery,配一个queryKey;一个写操作就是一个useMutation,配一个onSuccess。这种统一性正是模板代码最需要的。

2.4 monorepo 目录结构:先把边界画清楚

代码生成最忌讳的就是“所有代码搅在一个项目里,分不清哪些是生成的、哪些是手写的”。所以我用 pnpm workspace 把项目拆成了几个小包,每个包职责单一:

t3code/ ├── apps/ │ └── web/ # Next.js 应用,只放页面和组件 ├── packages/ │ ├── config/ # tsconfig、eslint 等共享配置 │ ├── database/ # Prisma Schema、数据库连接 │ ├── api/ # tRPC Router 定义 │ ├── generator/ # t3code 生成器本体 │ └── shared/ # zod Schema、共享类型、工具函数

这个结构的核心思想是:手写代码和生成代码从物理路径上就分开了。generator负责产出,web和api消费产出,shared里面的 Schema 是唯一事实源。后续不管是查看 Diff,还是排查“这个文件是谁改的”,都一目了然。说实话,我见过很多生成工具最后死在“生成的代码被手工改乱,重新生成时又冲突”这上面,目录边界划分就是从结构上杜绝这种问题。

3. 核心实现:把胶水代码逼成模板

3.1 Schema 是唯一事实源:用 zod 建模

t3code 的第一步,是定义 Schema。我选的是 zod,而不是手写 TypeScript interface,原因很实际:zod 既能做运行时校验,又能做静态类型推断。

当后端从客户端拿到一个请求体时,总得校验它是否合法;当客户端拿到后端返回的数据时,总得信任它的结构。用 zod 可以一鱼两吃:运行时用safeParse做校验,类型层面用z.infer自动推导出Post类型。这样,Schema 就成了前后端共享的唯一真相源。下面是一个简化的示例,先用Post和Comment两个模型感受一下:

// packages/shared/src/schemas/post.ts import { z } from "zod"; export const PostSchema = z.object({ id: z.string().uuid(), title: z.string().min(1).max(120), slug: z.string().regex(/^[a-z0-9-]+$/), content: z.string().min(1), published: z.boolean().default(false), authorId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), }); export const PostCreateSchema = PostSchema.pick({ title: true, slug: true, content: true, published: true, }); export const PostUpdateSchema = PostCreateSchema.partial(); export type Post = z.infer<typeof PostSchema>; export type PostCreateInput = z.infer<typeof PostCreateSchema>;

看到PostCreateSchema了吗?它直接复用了PostSchema,只选取允许客户端传入的字段,避免客户端把id、authorId这些服务端字段一起传上来。这一层是安全边界,也是类型边界:前端看到PostCreateInput时,天然就知道哪些字段能传、哪些不能传。

3.2 API 层生成策略:模板化的 tRPC Router

有了 Schema,下一步是生成 tRPC Router。这里我强调一个实现原则:t3code 用的是“代码生成”,不是“运行时反射”。也就是说,生成器读取 Schema 之后,不是去在内存里动态拼一个路由,而是真的把一段像人写的、平淡无奇的代码落盘到文件里。

为什么要这样?因为生成的代码必须能被阅读、被审查、被断点调试。如果靠运行时反射,代码是不存在的,出问题你只能对着一个黑盒猜。而落盘生成的代码,你可以直接打开文件看它每一步在做什么,甚至可以直接复制出来改掉跑。这是一条关于可维护性的底线。

生成出来的 Router 大概是这样的结构:

// packages/api/src/routers/post.ts import { router, publicProcedure, protectedProcedure } from "../trpc"; import { PostCreateSchema, PostUpdateSchema } from "@t3code/shared"; import { prisma } from "@t3code/database"; export const postRouter = router({ list: publicProcedure .input( z.object({ cursor: z.string().optional(), take: z.number().int().min(1).max(50).default(20), onlyPublished: z.boolean().default(true), }) ) .query(async ({ input }) => { const where = { published: input.onlyPublished ?? undefined }; const items = await prisma.post.findMany({ where, take: input.take + 1, ...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}), orderBy: { createdAt: "desc" }, }); let nextCursor: string | undefined; if (items.length > input.take) { nextCursor = items.pop()?.id; } return { items, nextCursor }; }), create: protectedProcedure .input(PostCreateSchema) .mutation(async ({ input, ctx }) => { return prisma.post.create({ data: { ...input, authorId: ctx.session.user.id, }, }); }), update: protectedProcedure .input(z.object({ id: z.string().uuid(), data: PostUpdateSchema })) .mutation(async ({ input, ctx }) => { const existing = await prisma.post.findUnique({ where: { id: input.id }, }); if (!existing) throw new Error("POST_NOT_FOUND"); if (existing.authorId !== ctx.session.user.id) { throw new Error("FORBIDDEN"); } return prisma.post.update({ where: { id: input.id }, data: input.data, }); }), delete: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input, ctx }) => { const existing = await prisma.post.findUnique({ where: { id: input.id }, }); if (!existing || existing.authorId !== ctx.session.user.id) { throw new Error("FORBIDDEN"); } await prisma.post.delete({ where: { id: input.id } }); return { ok: true }; }), });

这段代码本身没有魔法,都是标准套路。但正是因为它“标准”,生成器才能稳定产出,团队里任何人来看都能快速理解。生成策略是:每次运行都全量覆盖post.ts整个文件,而不是在旧文件上做增量补丁。全量覆盖的优势是避免“旧代码残留”跟“新生成代码”混在一起,这是我在翻车记录里学到的深刻教训,后面会提到。

3.3 前端 Hooks 与页面的自动派生

前端数据访问层是生成器的另一个重头戏。它的输入是 Schema 和 Router 的元数据,输出则是 React Query Hooks。核心逻辑是把queryKey、queryFn和类型全部串在一起:

// apps/web/src/features/posts/hooks.ts import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "~/lib/api"; import type { Post, PostCreateInput, PostUpdateInput } from "@t3code/shared"; export const postKeys = { all: ["posts"] as const, list: (params: { cursor?: string; take?: number; onlyPublished?: boolean }) => [...postKeys.all, "list", params] as const, detail: (id: string) => [...postKeys.all, "detail", id] as const, }; export function usePostList(params: { onlyPublished?: boolean; take?: number }) { return useQuery({ queryKey: postKeys.list(params), queryFn: () => api.post.list.query(params), }); } export function usePostDetail(id: string) { return useQuery({ queryKey: postKeys.detail(id), queryFn: () => api.post.byId.query({ id }), enabled: !!id, }); } export function useCreatePost() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input: PostCreateInput) => api.post.create.mutate(input), onSuccess: (newPost: Post) => { queryClient.setQueryData(postKeys.detail(newPost.id), newPost); queryClient.invalidateQueries({ queryKey: postKeys.all }); }, }); }

这里有一个容易被忽视但也极其重要的点:queryKey 必须包含查询参数。usePostList里的queryKey是postKeys.list(params),包含了onlyPublished和take。如果你漏掉了某个参数,React Query 会把两个不同条件的请求当成同一个缓存项,导致 A 条件的界面显示了 B 条件的数据,排查起来非常隐蔽。生成器在这里的优势是:它不会忘记拼参数,因为它就是从 Schema 的字段定义和 Router 的入参结构里读出来的。

至于页面模板,我一开始其实没打算让 t3code 生成页面,但后来发现,一个标准列表页来回就是那几样:加载态、空态、列表渲染、分页/加载更多、删除/编辑按钮。把这些套路生成出来,至少能为每个模块省掉 40 分钟的手工开局时间。生成出来的页面只是一个起点,你最终会手改它,但起码不用从空白开始。

3.4 生成出的代码必须“普通”且可审计

这是 t3code 里我最坚持的一条经验:生成器输出的代码应该尽量普通、尽量平淡、尽量没有魔法。

所谓“普通”,是指语法要保守,用最常见的 if/else、for 循环、Promise,不要炫技搞什么函数柯里化、复杂泛型推导。为什么?因为这些代码是要被团队成员读懂并可能手改的,如果你生成的东西比人写的还“高级”,那它就失去了可维护性。可审计性还体现在文件头部注释上,我让生成器在每个生成文件头部都加一行注释:

// GENERATED FILE - DO NOT EDIT MANUALLY // Source: packages/shared/src/schemas/post.ts // Run `pnpm generate` to regenerate.

这行注释看起来不起眼,但它的作用非常大。它告诉所有后来者:“这个文件是自动生成的,别手改,改完也会被覆盖。”有了这行注释,团队协作时就不会有人满怀好心去改一个生成文件,然后下次重新生成时又一头雾水地发现改动全部消失了。

4. 落地完整功能:文章与评论模块从零到可用

4.1 编写 Schema 并跑通数据库迁移

有了前面的架子,我用 t3code 重做了一次团队内部的内容管理模块,包含文章和评论。这一步最关键的是把 Schema 和数据库表对齐。我采用的方式是 Prisma 作为 ORM,所以在写 zod Schema 之前,先写 Prisma Schema,然后让 t3code 读取 Prisma 的模型定义反向生成 zod Schema。

model Post { id String @id @default(uuid()) @db.Uuid title String @db.VarChar(120) slug String @unique @db.VarChar(120) content String @db.Text published Boolean @default(false) authorId String @db.Uuid author User @relation(fields: [authorId], references: [id]) comments Comment[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("posts") } model Comment { id String @id @default(uuid()) @db.Uuid content String @db.VarChar(2000) postId String @db.Uuid post Post @relation(fields: [postId], references: [id], onDelete: Cascade) authorId String @db.Uuid createdAt DateTime @default(now()) @map("created_at") @@index([postId, createdAt]) @@map("comments") }

然后依次执行三条命令:prisma migrate dev生成迁移文件并同步数据库,pnpm generate让 t3code 根据 Prisma 模型生成 zod Schema、Router 和前端 Hooks,最后pnpm typecheck验证全链路类型是否闭合。这一步跑通后,一个模块的“基础设施”就算齐了。

值得一说的是onDelete: Cascade这个设计。评论是文章的从属数据,文章删了评论留着没有意义,所以设置级联删除,避免后面在业务代码里手动补一条“删除所有评论”的逻辑。这种决策不适合由生成器来做,需要人在 Schema 层面显式声明,这也再次印证了“生成器只处理重复,决策必须留给人”的理念。

4.2 注册 Router 并验证类型链路

生成器产出的 Router 不会自己挂到根路由上,还需要手动做一次组装。这一步是“人机协作”的关键节点,我在根文件里引入并注册:

// packages/api/src/root.ts import { postRouter } from "./routers/post"; import { commentRouter } from "./routers/comment"; import { router } from "./trpc"; export const appRouter = router({ post: postRouter, comment: commentRouter, }); export type AppRouter = typeof appRouter;

挂载完成后,我故意做了一个实验来验证类型链路是不是真的通了:把PostCreateSchema里加一个本来不存在的字段unknownField: z.string(),保存后立刻去看前端api.post.create.mutate的调用处。结果没有任何意外,TypeScript 编译器在毫秒级就标红了,提示参数类型不匹配。这种感觉和“写完接口后手动翻文档核对字段”完全不是一个量级。

如果在你的项目里,前端这个报错没有出现,先检查tsconfig的strict是否为true。类型安全这套玩法,strict关了基本等于白搭。另外,确认前端引用的api对象的类型确实来自AppRouter,有些项目会因为路径别名配置问题,意外引用了旧的类型副本,导致类型链路没接上。

4.3 页面组件与乐观更新

t3code 生成的 Hooks 层只是数据访问的封装,真正落在页面上还需要一点手工加工。我在文章列表页做了一个乐观更新的效果:用户点击“发布”按钮时,界面先立刻把文章状态切换为已发布,如果后端请求失败再回滚。这个交互用 React Query 的onMutate实现非常顺手:

// apps/web/src/features/posts/PostListPage.tsx import { usePostList, useUpdatePost, postKeys } from "./hooks"; import { useQueryClient } from "@tanstack/react-query"; export function PostListPage() { const queryClient = useQueryClient(); const { data, isLoading } = usePostList({ onlyPublished: false, take: 20 }); const updatePost = useUpdatePost(); const handleTogglePublish = async (postId: string, current: boolean) => { await updatePost.mutateAsync( { id: postId, data: { published: !current } }, { onMutate: async ({ id, data }) => { await queryClient.cancelQueries({ queryKey: postKeys.all }); const previous = queryClient.getQueryData(postKeys.list({ onlyPublished: false, take: 20 })); queryClient.setQueriesData({ queryKey: postKeys.all }, (old) => { if (!old) return old; return { ...old, pages: old.pages.map((page) => ({ ...page, items: page.items.map((item: any) => item.id === id ? { ...item, published: data.published ?? item.published } : item ), })), }; }); return { previous }; }, onError: (_err, _input, context) => { if (context?.previous) { queryClient.setQueriesData({ queryKey: postKeys.all }, context.previous); } }, } ); }; if (isLoading) return <div>加载中...</div>; return ( <div> {data?.items.map((post) => ( <div key={post.id}> <span>{post.title}</span> <input type="checkbox" checked={post.published} onChange={() => handleTogglePublish(post.id, post.published)} /> </div> ))} </div> ); }

注意onMutate里setQueriesData使用了postKeys.all,这样无论当前有几个列表页签,都能把对应缓存里的文章状态一次性更新,不会出现“详情页改了,列表页还是旧状态”的尴尬。这个细节是我在实现评论回复功能时踩出来的,优化体验成本很低,但效果非常明显。

4.4 权限与错误处理:中间件和统一报错

CRUD 功能做出来后,紧接着就是权限。t3code 生成的 Router 里默认区分了publicProcedure和protectedProcedure,前者任何人都能访问,后者要求当前请求已登录。在 tRPC 里,这个区分靠中间件实现:

// packages/api/src/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import type { Context } from "./context"; export const t = initTRPC.context<Context>().create(); export const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(isAuthed);

错误处理我也做了统一约定:业务错误只用TRPCError抛出,并约定错误码语义。比如NOT_FOUND、FORBIDDEN、UNAUTHORIZED这些标准码,前端在写错误提示时可以按错误码映射,不用去 parse 后端返回的字符串。这种约定不属于生成器的范围,但它是 t3code 项目里所有 Router 共同遵守的约定,生成器只是把这个约定写进了模板里。

5. 实测翻车记录:类型错位、Context 泄漏与 stale closure

这一节写的都是我在 t3code 实际推进过程中真实遇到、并且花了不少时间才定位的问题。如果你也打算做类似的代码生成工具,这些问题大概率也会撞上。

5.1 生成缓存导致类型错位:现象、定位过程与解法

第一次大规模生成后,我跑pnpm typecheck,报错指向packages/shared/src/generated/types.ts,说里面引用了一个早已删除的字段authorName。当时我很疑惑:数据库和 Schema 里都没有这个字段,它怎么会出现在生成文件里?

排查链路是这样的:

  1. 先看报错文件,发现types.ts里Post类型还包含authorName。
  2. 打开generator/src/main.ts,发现生成器输出文件之前,会先检查目标目录的修改时间,如果“没变化”就跳过写入。
  3. 真正的问题出来了:生成器的输入文件prisma/schema.prisma没变,但 zod Schema 被人手工改过,生成器认为“Schema 没变,不用重新生成”,于是旧的生成文件被保留了下来。

这个坑的本质是:生成器的增量缓存判断依赖了错误的信号。我原本想通过跳过无变化的写入来节省时间,结果引入了“旧生成物残留”的风险。解法也很粗暴:每次运行都先强制清空generated目录再全量创建,不做任何增量判断。生成全量代码的时间开销在毫秒到秒级,完全可以接受,而“永远从干净状态出发”这一条规则帮我消灭了整个类别的幽灵类型问题。

后来我还把生成产物纳入 Git 提交,而不是放进.gitignore。原因很简单:生成的代码要参与 Code Review,团队里任何人改动 Schema 后,Diff 里能直接看到生成文件跟着变了,这就让 Schema 变更的审计变得透明。如果你把生成文件 ignore 掉,那“类型错位”这类问题只能等 CI 报错,反馈链路长太多了。

5.2 tRPC Context 泄漏:一次隐蔽的串数据 Bug

第二个坑说起来有点囧。某个页面上线测试时,用户 A 偶尔能看到用户 B 的草稿数据。第一反应是权限没写好,但查来查去权限逻辑没问题。后来我把问题缩小到 tRPC Context:测试环境里,两个浏览器同时请求一个接口,拿到 Session 竟然互相串了。

定位过程:

  • 给中间件加日志,打印ctx.session.user.id,发现同一台机器上不同请求打印出了不同用户的 ID。
  • 继续查createContext的实现,发现我把 Session 对象缓存到了一个模块级变量里:
let cachedSession: Session | null = null; export async function createContext(opts: CreateContextOptions) { if (!cachedSession) { cachedSession = await loadSession(opts.req); } return { session: cachedSession }; }

这段代码的问题非常典型:模块级变量在整个进程生命周期内是共享的,但 HTTP 请求是并发的。第一个请求设置好 Session 后,第二个请求发现cachedSession已经存在,就不再重新加载,直接复用,结果就是用户数据串台。

修复方式是把 Context 生成完全改为“每次请求独立构造”,不在模块级保存任何请求级状态:

export async function createContext(opts: CreateContextOptions) { const session = await loadSession(opts.req); return { session }; }

这个教训让我在 t3code 的代码模板里加了一条铁律:凡是为一个请求服务的状态,都必须放在createContext返回的对象里,绝不允许存在模块作用域。这一条现在已经作为评审 Checklist 写进团队的编码规范里了。

5.3 生成的 Hook 出现 stale closure

第三个问题出现在前端 Hooks 上。当时用生成器产出的usePostList写了一个带关键词搜索的页面,搜索框每次输入都会触发一次新请求,但页面显示的总是一秒前的旧关键词结果。看代码逻辑完全没问题,queryKey里也包含了关键词,最后发现是useCallback的依赖数组写漏了:

const fetchPosts = useCallback(() => { return api.post.list.query({ keyword, page: 1 }); }, []); // 依赖数组里漏了 keyword

因为keyword没有被放进依赖数组,fetchPosts永远闭包住了第一次渲染时的keyword,后续输入的新值根本进不到回调里。这个 bug 在人手写的代码里也很常见,但生成器有义务杜绝,因为它可以控制输出格式。

我的修复方式是:生成器在生成 Hooks 时,把依赖数组显式写在模板里,而且把依赖项列得越直白越好,不依赖 eslint 的自动修复,也不搞“放开 lint 规则所以不用写依赖”这套。类型安全同样适用于依赖问题:只要生成的函数引用了某个变量,就把它放进依赖数组,宁可多写一个,不可少写一个。经过这个修复,这类 stale closure 问题几乎从我的项目里绝迹了。

5.4 老项目迁移时的兼容策略:别把现有接口推倒重来

不是每个项目都有机会从零开始用 t3code。我们有一个老模块已经有稳定的 REST 接口和一堆调用方,直接推倒重来风险太大。我采取的策略是在 tRPC Router 外面包一层 adapter:

// packages/api/src/routers/legacyPost.ts export const legacyPostRouter = router({ list: publicProcedure.query(async () => { const result = await fetchLegacyApi("/posts"); return normalizeLegacyPostList(result); }), });

也就是说,老接口还是那个老接口,但前端调用方从原来的fetch("/posts")改成了api.legacyPost.list.query()。这样一来,业务代码的调用方式被统一到了 tRPC 的体系里,但后端代码没有大爆炸。等后续迭代到相关模块时,再逐步把 adapter 内部换成直接走 Prisma。

迁移顺序我也踩出一个比较顺手的节奏:先迁移最常用的列表页和详情页,这两个页面带来的体感提升最明显;再迁移写操作(增删改);最后才处理那些批量的、复杂的报表类接口。此处的原则是“渐进式替换,保持系统随时可用”,而不是追求一次切换完成。

6. 最后:模板帮你省力,别让它替你做决定

t3code 做下来,我最大的体会是:代码生成工具的价值不在于生成多少代码,而在于替你消灭多少不必要的决策。CRUD 的字段映射、请求参数校验、查询键管理,这些是低信息量决策,交给生成器是对的。但权限模型、业务状态机、页面视觉,这些是高信息量决策,必须留在人手里。

如果你也打算在自己的项目里动手做类似的生成方案,我建议你先别贪大。从一个只有两三个字段的小模块开始,让生成器先把类型链路跑通,再逐步加 Router、Hooks、页面模板。我最初犯过的错误就是在设计文档里把生成器的功能画得太大,结果实现到一半发现“生成器想替人做业务决策”的部分全都要推翻重来。

最后分享一个小技巧:每当你犹豫“这个功能要不要塞进生成器”时,就问自己一个问题——如果这次生成的代码让一个不熟悉这个项目的新人来看,他能在一分钟内看懂它在干嘛吗?如果答案是不能,那说明它不适合被生成,它应该被手写,并且应该被好好地注释,而不是被埋进黑盒里。t3code 这个名字以后我可能会改掉,但“生成代码必须普通、必须可审计、必须把决策留给人”这三条原则,我大概会一直带在身边。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询