多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Schema驱动的全栈代码生成:t3code如何打通类型安全链路

Schema驱动的全栈代码生成:t3code如何打通类型安全链路 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 或 GraphQLAPI 层的选型是 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.infertypeof PostSchema; export type PostCreateInput z.infertypeof 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 typecheckbox 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.contextContext().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 里都没有这个字段它怎么会出现在生成文件里排查链路是这样的先看报错文件发现types.ts里Post类型还包含authorName。打开generator/src/main.ts发现生成器输出文件之前会先检查目标目录的修改时间如果“没变化”就跳过写入。真正的问题出来了生成器的输入文件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 这个名字以后我可能会改掉但“生成代码必须普通、必须可审计、必须把决策留给人”这三条原则我大概会一直带在身边。
返回列表