動(dòng)的T3 Stack代碼生成器,解決CRUD重復(fù)勞動(dòng))
一直用 T3 Stack 做全棧項(xiàng)目的人應(yīng)該都有這種體驗(yàn)Next.js 加 TypeScript 加 Tailwind CSS 加 tRPC 這套組合的上手體驗(yàn)確實(shí)順滑但一旦業(yè)務(wù)開始變多路由、輸入校驗(yàn)、RPC handler、前端調(diào)用 hook每一層都是大量重復(fù)勞動(dòng)。t3code 就是為解決這個(gè)痛點(diǎn)出現(xiàn)的——它不是傳統(tǒng)意義上跑一次就扔的腳手架而是一個(gè)能跟著項(xiàng)目長期迭代的代碼生成器。你只要維護(hù)一份描述業(yè)務(wù)模型的 schema它就能把 tRPC 路由、Zod 校驗(yàn)、Prisma 查詢、前端類型的整套鏈路補(bǔ)齊。這篇文章我打算把 t3code 的核心機(jī)制、完整使用流程、以及我在這幾個(gè)項(xiàng)目中踩過的坑一起聊透給正在用 T3 Stack、或者打算嘗試這類模型驅(qū)動(dòng)開發(fā)思路的讀者一個(gè)真實(shí)的參考。1. 從手搓CRUD到一鍵生成t3code想解決的真實(shí)痛點(diǎn)1.1 T3 Stack看著很美寫起來卻全是重復(fù)勞動(dòng)T3 Stack 最吸引人的地方在于端到端類型安全。tRPC 把服務(wù)端 router 的類型直接暴露給前端調(diào)用一個(gè) API 就像調(diào)用本地函數(shù)一樣參數(shù)和返回值都有完整類型提示。但這里有個(gè)微妙的問題類型安全解決的是前后端類型不一致的隱患它并沒有解決每次新增一個(gè)業(yè)務(wù)模塊你要在不同文件里寫四遍相似代碼的體力活。拿一個(gè)最普通的后臺(tái)菜單模塊舉例。我得先打開schema.prisma定義 MenuModel加上關(guān)聯(lián)關(guān)系然后去api/routers/menu.ts寫 CRUD 的 tRPC router再在api/schemas/menu.ts里用 Zod 定義輸入校驗(yàn)接著回組件里寫 useQuery、useMutation 的調(diào)用最后還要手動(dòng)維護(hù) router 的根注冊(cè)表。這還只是單個(gè)模塊如果涉及分頁、篩選、排序、軟刪除代碼量直接翻倍。最難受的不是寫這些代碼本身而是它們之間的隱式關(guān)聯(lián)。Prisma 字段改名了Zod schema 層可能漏改tRPC procedure 增加了一個(gè) query 參數(shù)前端調(diào)用地方的類型也會(huì)跟著報(bào)錯(cuò)——雖然報(bào)錯(cuò)也算提醒但一個(gè)幾百個(gè)文件的項(xiàng)目里這種聯(lián)動(dòng)錯(cuò)誤往往是分散的、延遲出現(xiàn)的修起來特別考驗(yàn)?zāi)托摹?.2 t3code的定位不是腳手架是維護(hù)型代碼生成器我第一次用 t3code 時(shí)本來以為它和市面上那些create-t3-app或代碼片段插件差不多生成完 CRUD 模板就算完事。實(shí)際跑完一個(gè)模塊后我才發(fā)現(xiàn)它真正下功夫的地方在增量維護(hù)。普通的腳手架工具是一次性生成生成完之后你修改文件、維護(hù)代碼它就不管你了。t3code 的做法不太一樣它在生成的文件里保留特定標(biāo)記區(qū)域marker當(dāng)你修改了 schema 描述再次執(zhí)行生成命令時(shí)它只更新標(biāo)記區(qū)域內(nèi)的代碼頁面里手寫的業(yè)務(wù)邏輯會(huì)原樣保留。這就意味著你可以把 t3code 當(dāng)作一個(gè)常態(tài)化的開發(fā)輔助工具而不是只在項(xiàng)目初始化時(shí)用一次。所以它適合的人群也比較明確一是業(yè)務(wù)有大量標(biāo)準(zhǔn)化 CRUD 場景的團(tuán)隊(duì)二是想把類型安全做到極致、希望所有前后端契約都從一個(gè) schema 自動(dòng)推導(dǎo)出來的項(xiàng)目三是團(tuán)隊(duì)里有規(guī)范強(qiáng)迫癥、希望路由命名和參數(shù)結(jié)構(gòu)保持一致的同學(xué)。反過來如果你的項(xiàng)目以復(fù)雜定制查詢?yōu)橹?、表單結(jié)構(gòu)千奇百怪那 t3code 能幫你省的時(shí)間會(huì)打折扣但仍可以作為基礎(chǔ)鏈路的加速器。2. 核心機(jī)制拆解模板引擎、類型推演與RPC代碼生成2.1 模板引擎選型與渲染流程t3code 的底層結(jié)構(gòu)并不神秘核心就三件事讀入 schema、渲染模板、寫出文件。但真正決定它好用與否的是每一步的細(xì)節(jié)設(shè)計(jì)。模板引擎方面t3code 沒有自研語法而是直接復(fù)用 Nunjucks 作為渲染引擎。這個(gè)選型我覺得很務(wù)實(shí)Nunjucks 的模板語法對(duì)后端轉(zhuǎn)前端的開發(fā)者很友好支持宏定義、import 復(fù)用、過濾器和異步環(huán)境而且容錯(cuò)率不錯(cuò)——某個(gè)變量沒傳時(shí)默認(rèn)輸出空字符串不至于直接把整個(gè)生成流程打崩。渲染流程上t3code 分成了兩個(gè)階段。第一階段是模型編譯把用戶寫的 schema 文件解析成統(tǒng)一的內(nèi)部模型ModelDefinition里面包含字段列表、類型映射、校驗(yàn)規(guī)則、關(guān)聯(lián)關(guān)系、是否軟刪除、是否包含分頁等元信息。第二階段是模板渲染把內(nèi)部模型塞進(jìn) Nunjucks 模板按需輸出不同的文件類型。有一點(diǎn)值得注意t3code 里的模板不只是把 schema 翻譯成代碼它還會(huì)根據(jù)模型屬性做條件渲染。比如某個(gè)模型標(biāo)記了softDelete生成的 Prisma 查詢和 tRPC router 就會(huì)自動(dòng)帶上 deletedAt 過濾邏輯某個(gè)字段標(biāo)記了unique生成的輸入校驗(yàn)里會(huì)多一個(gè)查重函數(shù)。這種描述式生成比傳統(tǒng)腳手架里死板的模板要靈活得多。2.2 類型安全的雙向推演從Zod Schema到前端調(diào)用tRPC 的類型安全鏈路是 t3code 的核心賣點(diǎn)這里我得展開多說幾句。用 t3code 生成 RPC 模塊時(shí)你在 schema 描述里定義的字段類型會(huì)同時(shí)影響三條代碼線Prisma model 的數(shù)據(jù)庫類型、Zod 輸入校驗(yàn)的類型、前端引用的返回類型。t3code 內(nèi)部維護(hù)了一張類型映射表比如 schema 里的string字段如果再聲明了email規(guī)則就會(huì)映射為 Zod 的z.string().email()如果是int加min:0就映射成z.number().int().min(0)。關(guān)鍵在輸入反向推演這一步。很多代碼生成器只做正向推導(dǎo)從 schema 生成 Prisma model然后生成 tRPC router。但 t3code 會(huì)反過來從上一步生成的 Prisma model 再推導(dǎo)一份對(duì)應(yīng)的 Zod input schema并且把 create、update、list 三種場景的輸入類分開。比如update場景的輸入會(huì)允許字段可選list場景會(huì)單獨(dú)生成 page、pageSize、sortBy、sortOrder 這些查詢參數(shù)。這樣前端引用的類型和后端校驗(yàn)的類型嚴(yán)格來自同一份元數(shù)據(jù)不會(huì)出現(xiàn)Postgres 里能存、Zod 校驗(yàn)卻攔住了的錯(cuò)位。2.3 增量生成的算法邏輯如何不覆蓋手寫代碼增量生成是 t3code 和其他腳手架拉開差距的地方。簡單說每個(gè)生成目標(biāo)文件里都有類似這樣的標(biāo)記結(jié)構(gòu)// t3code:component:begin export const menuRouter createRouter() .query(list, { ... }) .mutation(create, { ... }) // t3code:component:end當(dāng)你再次執(zhí)行t3code generate時(shí)工具會(huì)掃描文件、定位標(biāo)記區(qū)域只重寫標(biāo)記區(qū)域內(nèi)的內(nèi)容標(biāo)記區(qū)域之外的代碼一律不動(dòng)。實(shí)現(xiàn)上它其實(shí)就是先用正則把每個(gè)標(biāo)記區(qū)域的位置記下來再用模板結(jié)果替換對(duì)應(yīng)區(qū)間。但這個(gè)做法有個(gè)前提你要在第一次生成后保持標(biāo)記不被刪除。我在實(shí)際項(xiàng)目中見過有同學(xué)覺得標(biāo)記注釋礙眼手動(dòng)清掉的之后每次跑 t3code 都直接在文件末尾追加重復(fù)的 router 定義導(dǎo)致類型沖突。后來我們團(tuán)隊(duì)在 Code Review 規(guī)范里加了一條t3code生成的注釋區(qū)域等同鎖定區(qū)域不允許手改需要自定義邏輯就在標(biāo)記外擴(kuò)展。這算是用工具的人必須接受的紀(jì)律。3. 從零跑通一個(gè)完整菜單模塊的生成過程實(shí)錄3.1 初始化項(xiàng)目與t3code配置說再多機(jī)制不如跑一遍實(shí)例。假設(shè)我們現(xiàn)在要在已有的 T3 Stack 項(xiàng)目里加一個(gè)菜單管理模塊項(xiàng)目結(jié)構(gòu)是標(biāo)準(zhǔn)的 Next.js App Router tRPC Prisma。先安裝并初始化 t3codepnpm add -D t3code npx t3code init執(zhí)行 init 之后t3code 會(huì)在項(xiàng)目根目錄生成t3code.config.ts和schema目錄。配置文件負(fù)責(zé)告訴工具Prisma schema 往哪里輸出、tRPC router 目錄在哪、前端 hooks 的輸出路徑是什么、類型文件名怎么命名。我的建議是配置里路徑相關(guān)選項(xiàng)不要寫絕對(duì)路徑全部用相對(duì)項(xiàng)目根目錄的路徑。原因后面會(huì)講t3code 在解析路徑別名時(shí)對(duì)絕對(duì)路徑的處理容易遇到坑相對(duì)路徑要穩(wěn)得多。初始化完成后在schema/menu.ts里描述菜單模型export default defineModel({ name: Menu, tableName: menu, fields: { name: { type: string, required: true, max: 50 }, code: { type: string, required: true, unique: true }, path: { type: string, required: true }, order: { type: int, default: 0 }, parentId: { type: string, optional: true }, status: { type: enum, values: [enabled, disabled], default: enabled } }, softDelete: true, pagination: true, includes: [children] });這份描述的語義很直白一個(gè)菜單有名稱、唯一編碼、路由路徑、排序、父級(jí) ID、狀態(tài)支持軟刪除、分頁和子節(jié)點(diǎn)關(guān)聯(lián)查詢。3.2 編寫Schema描述并生成API鏈路schema 寫好后執(zhí)行npx t3code generatet3code 會(huì)做幾件事。首先把模型同步到prisma/schema.prisma生成對(duì)應(yīng)的 Menu model隨后在src/server/api/routers/下生成menu.ts與根注冊(cè)表更新接著在src/server/api/schemas/下生成 Zod 輸入最后在前端src/hooks/下生成useMenu.ts和類型聲明文件。生成完的 tRPC router 大致長這樣// t3code:component:begin export const menuRouter createRouter() .query(list, { input: menuListInput, resolve: async ({ ctx, input }) { const where buildWhereFromInput(input); const [list, total] await ctx.prisma.$transaction([ ctx.prisma.menu.findMany({ ... }), ctx.prisma.menu.count({ where }) ]); return { list, total }; } }) .mutation(create, { input: menuCreateInput, resolve: async ({ ctx, input }) { return ctx.prisma.menu.create({ data: input }); } }) // ... update / remove / softDelete // t3code:component:end這里能清楚看到查詢的排序、分頁、軟刪除過濾條件都是按 schema 描述自動(dòng)展開的手寫的話至少得十幾行樣板。3.3 前端調(diào)用與類型提示驗(yàn)證生成的前端 hook 是針對(duì) React 封裝的。以u(píng)seMenu.ts為例它會(huì)導(dǎo)出useMenuList、useMenuCreate、useMenuUpdate等自定義 hook內(nèi)部直接封裝 tRPC 的 query/mutationexport function useMenuList(params: MenuListParams) { return trpc.menu.list.useQuery(params, { keepPreviousData: true }); } export function useMenuCreate() { const utils trpc.useUtils(); return trpc.menu.create.useMutation({ onSuccess: () utils.menu.list.invalidate(), }); }最直觀的驗(yàn)證方式是打開頁面組件輸入useMenuList如果代碼提示里能自動(dòng)帶出{ page, pageSize, sortBy, sortOrder, keyword }這些參數(shù)類型并且返回值能感知到list和total說明這條類型鏈路是通的。我一般還會(huì)順手在prisma studio里確認(rèn)表結(jié)構(gòu)字段名是否與預(yù)期一致避免默認(rèn)走 Prisma 屬性名和數(shù)據(jù)庫列名的駝峰映射問題。到這里一個(gè)完整的 CRUD 后端鏈路和基礎(chǔ)前端調(diào)用就結(jié)束了。從寫 schema 到跑通接口走的都是聲明式描述 自動(dòng)生成的路子不用手動(dòng)在四五個(gè)文件之間來回跳。4. 使用中踩過的坑三類最容易翻車的場景4.1 覆蓋策略導(dǎo)致的開發(fā)分支丟失t3code 最需要小心的就是舊項(xiàng)目接入時(shí)的手工改造。前面提到增量生成依賴標(biāo)記區(qū)域但如果你的項(xiàng)目不是從零開始用 t3code而是中途接入第一次 generate 時(shí)會(huì)發(fā)現(xiàn) t3code 默認(rèn)不覆蓋已有文件而是生成.t3gen后綴的新文件。這樣雖然安全但也帶來麻煩你需要手動(dòng)把生成內(nèi)容合并進(jìn)舊文件還要補(bǔ)上標(biāo)記注釋否則下次生成又會(huì)變成重復(fù)的新文件。我的處理方式是接入老項(xiàng)目時(shí)先跑一次生成然后逐個(gè)文件手工合并合并完成后確認(rèn)標(biāo)記注釋已經(jīng)寫入再跑第二次 generate 驗(yàn)證沒有重復(fù)代碼。這個(gè)流程比較繁瑣但對(duì)已有線上代碼庫來說安全第一值得花半小時(shí)換后面幾個(gè)月的穩(wěn)定。另外要留意.prisma 模型的同步?jīng)_突。如果團(tuán)隊(duì)里有人同時(shí)手工改了schema.prisma里的 Menu 模型又有人跑了 t3code generate工具默認(rèn)會(huì)用生成的描述覆蓋同名模型導(dǎo)致手工修改丟失。后來我給團(tuán)隊(duì)定了個(gè)規(guī)矩一切與模型結(jié)構(gòu)相關(guān)的改動(dòng)都要回到schema/menu.tsPrisma schema 只當(dāng)產(chǎn)物看不直接改。剛開始大家覺得多一層間接操作很反直覺但運(yùn)行半年之后幾乎所有成員都認(rèn)為這個(gè)約束反而讓結(jié)構(gòu)變更變得可審計(jì)。4.2 路徑別名映射的差異與路由失效第二個(gè)坑和 Next.js 的路徑別名有關(guān)。T3 Stack 項(xiàng)目里tsconfig.json一般定義了/*指向src/*t3code 生成的代碼默認(rèn)也會(huì)用/server/api/...這類別名導(dǎo)入。聽起來沒問題但 t3code 解析別名時(shí)用的是自己的配置項(xiàng)pathAlias它和tsconfig.json的 paths 不是一回事。我遇到過這么個(gè)情況配置文件里寫了別名但忘了在t3code.config.ts里聲明 tRPC router 根路徑的別名前綴。結(jié)果生成的路由文件內(nèi)部 import router 時(shí)用了/server/api/trpc而實(shí)際生成的文件里對(duì)外導(dǎo)出的路徑變成相對(duì)路徑導(dǎo)致編譯期不報(bào)錯(cuò)、運(yùn)行時(shí) 404。折騰了兩個(gè)小時(shí)最后發(fā)現(xiàn)是別名設(shè)置不一致。建議把 t3code 的路徑相關(guān)配置和tsconfig.json做成一份共享源或者至少在 README 里寫清楚項(xiàng)目里改了路徑別名必須同步改 t3code 的 pathAlias。用工具的人最忌諱的就是配置默認(rèn)值存活在潛意識(shí)里。4.3 生成代碼與Lint規(guī)則的沖突第三類坑是生成代碼過不了 ESLint。t3code 生成的代碼特點(diǎn)是模板感強(qiáng)變量命名規(guī)整但偶爾會(huì)出現(xiàn)空行過多、單引號(hào)雙引號(hào)混用、縮進(jìn)不一致的情況。這不是 bug而是模板文件沒有跟著項(xiàng)目的 Lint 配置走。我在新項(xiàng)目里接 t3code 后第一件事就是把生成流程后面掛了一步自動(dòng)格式化npx t3code generate eslint --fix src/server/api/routers/menu.ts src/hooks/useMenu.ts更省事的做法是在t3code.config.ts里開啟formatOnGenerate讓工具在寫出文件后自動(dòng)調(diào)用 prettier。有幾個(gè)版本這個(gè)開關(guān)還不太穩(wěn)定所以我仍然習(xí)慣在 package.json 里寫一條組合命令。如果生成完的代碼被 ESLint 標(biāo)紅不要急著關(guān)規(guī)則先看看是不是模板變量里帶了不該有的空白字符。還有一個(gè)容易被忽略的是 unused import 問題。當(dāng)某個(gè)模型沒有配置關(guān)聯(lián)查詢時(shí)生成的 router 里不會(huì)用include但模板默認(rèn)引用了 Prisma 的Prisma類型結(jié)果一編譯就報(bào) unused。t3code 后來加了 lint 清理步驟但升級(jí)版本后仍建議跑一次全量tsc --noEmit驗(yàn)證。5. 二次定制與團(tuán)隊(duì)協(xié)作把t3code變成團(tuán)隊(duì)的基礎(chǔ)設(shè)施5.1 自定義模板與變量注入內(nèi)置模板能滿足 80% 的 CRUD 場景但每個(gè)團(tuán)隊(duì)多少有點(diǎn)自己的范式。比如我們團(tuán)隊(duì)約定所有 list 接口都必須返回summary匯總字段t3code 默認(rèn)模板里沒有這個(gè)邏輯就得自己改模板。t3code 的自定義入口是把模板目錄指向項(xiàng)目內(nèi)某個(gè)目錄比如templates/然后復(fù)制一份內(nèi)置的router.njk和hook.njk進(jìn)去改。模板變量名可以通過在 schema 描述里補(bǔ)充自定義字段實(shí)現(xiàn)比如export default defineModel({ name: Menu, // ... t3: { customSummary: true, }, });只要內(nèi)部模型里有t3對(duì)象Nunjucks 模板里就能通過model.t3.customSummary做條件判斷。這種自定義方式比修改生成后的文件要干凈得多因?yàn)樯珊蟮奈募看味紩?huì)被覆蓋。5.2 與CI/CD、ESLint、prettier的工作流整合團(tuán)隊(duì)協(xié)作時(shí)t3code 最好加入 CI 檢查防止有人手工改了生成產(chǎn)物卻沒有同步 schema。我們做法是加一個(gè) CI 任務(wù)npx t3code generate --check--check模式不會(huì)真正寫文件只比對(duì)如果執(zhí)行生成是否有文件發(fā)生變化有變化就返回非零退出碼。這樣哪個(gè) MR 里出現(xiàn)了schema 改了但生成產(chǎn)物沒提交的情況流水線會(huì)直接攔截審查者就不用肉眼 diff 那么痛苦了。prettier 和 ESLint 的整合也建議做成一條命令鏈{ scripts: { gen: t3code generate, gen:fmt: npm run gen prettier --write . eslint --fix src/ } }不要只寫在 README 里直接把命令放進(jìn)package.json更實(shí)際新成員跑npm run gen:fmt就守住了規(guī)范。5.3 團(tuán)隊(duì)規(guī)范化的實(shí)際收益與注意事項(xiàng)我們團(tuán)隊(duì)用了大半年之后最大的變化不是代碼寫得少了而是代碼審查的時(shí)間明顯下降。因?yàn)樗?CRUD 模塊的路由命名、校驗(yàn)規(guī)則、錯(cuò)誤處理模式都高度一致審查者不需要每次重新理解一套新的寫法。新人上手項(xiàng)目時(shí)也不用從零學(xué)業(yè)務(wù)模塊的代碼結(jié)構(gòu)直接看一份 schema 描述就大概明白模塊有哪些字段、哪些規(guī)則。但注意t3code 解決的是重復(fù)不是復(fù)雜。如果一個(gè)模塊的邏輯集中在自定義查詢和業(yè)務(wù)規(guī)則上那生成產(chǎn)物的占比會(huì)下降最終還是要靠人寫。所以團(tuán)隊(duì)約定俗成的用法是先用 t3code 生成骨架再在標(biāo)記區(qū)域外補(bǔ)充自定義邏輯盡量不動(dòng)生成區(qū)。這樣既保住了規(guī)范又留了擴(kuò)展空間。6. 幾個(gè)使用心得性能、版本升級(jí)與長期維護(hù)最后分享幾個(gè)我在長期使用中的觀察這些屬于文檔里不會(huì)細(xì)寫的部分。第一性能方面。t3code 在大型項(xiàng)目里生成速度主要受模板渲染和 Prisma schema 同步影響。模型數(shù)量幾十個(gè)時(shí)基本是毫秒級(jí)但一旦 Prisma schema 文件很大每次讀寫整個(gè)文件再做字段級(jí) diff 會(huì)明顯變慢。如果項(xiàng)目模型超過一百個(gè)可以考慮拆成多個(gè) schema 描述文件t3code 支持多文件輸入內(nèi)部會(huì)做合并執(zhí)行體驗(yàn)會(huì)好不少。第二版本升級(jí)不要沖動(dòng)。我經(jīng)歷過一次 t3code 大版本升級(jí)它調(diào)整了內(nèi)部模型的字段命名規(guī)范導(dǎo)致舊模板里的變量名全部失效生成的代碼結(jié)構(gòu)變了。升級(jí)前務(wù)必看 release notes并且先在 feature 分支跑一次--check確認(rèn)影響面再合并。第三如果要跨項(xiàng)目復(fù)用一套模板可以把模板和 schema 描述單獨(dú)放到一個(gè)私有 npm 包這樣每個(gè)項(xiàng)目只需要依賴這個(gè)包加 t3code就能保持全團(tuán)隊(duì)的生成產(chǎn)物風(fēng)格統(tǒng)一。根據(jù)我個(gè)人的使用體會(huì)t3code 這類模型驅(qū)動(dòng)的代碼生成工具最大的價(jià)值不是幫你省那幾分鐘的敲代碼時(shí)間而是讓團(tuán)隊(duì)在類型安全這件事上建立了一個(gè)統(tǒng)一的事實(shí)來源。你只要維護(hù)好 schema 描述前后端的契約就不會(huì)悄悄漂移。日常開發(fā)里我也習(xí)慣在每次生成后順手打開一個(gè)生成文件快速掃一眼模板輸出的實(shí)際邏輯確認(rèn)它和我預(yù)期的行為一致——相信我工具再自動(dòng)化也要保持對(duì)生成物的一雙人眼。