戰(zhàn):基于 T3 Stack 的類型安全全棧開發(fā)范式)
1. 項(xiàng)目緣起與核心定位第一次看到“t3code”這個(gè)名字我下意識(shí)以為是某個(gè)新出的低代碼平臺(tái)或者代碼生成工具。翻了一圈社區(qū)討論和零散的項(xiàng)目記錄之后才慢慢拼出它的真實(shí)面貌——這是一個(gè)圍繞T3 StackNext.js tRPC Tailwind CSS TypeScript構(gòu)建的代碼實(shí)踐項(xiàng)目核心目標(biāo)是把一套“類型安全、端到端可推導(dǎo)”的全棧開發(fā)范式真正落地成可復(fù)用的工程模板。說(shuō)白了它不是某個(gè)具體產(chǎn)品而是一套開發(fā)方法論加腳手架的組合體。為什么這件事值得單獨(dú)拿出來(lái)講因?yàn)楝F(xiàn)在前端圈子里“全?!边@個(gè)詞被用得太泛了。很多人所謂的全棧不過(guò)是前端寫完頁(yè)面再隨手寫兩個(gè)接口類型對(duì)不上就any一把梭。而 t3code 想解決的恰恰是這個(gè)痛點(diǎn)讓前后端之間的類型契約從編譯期就鎖死接口改一個(gè)字段前端立刻飄紅根本不給運(yùn)行時(shí)錯(cuò)誤留機(jī)會(huì)。這套思路對(duì)于中小型團(tuán)隊(duì)、獨(dú)立開發(fā)者、以及想從純前端往全棧方向轉(zhuǎn)的同學(xué)來(lái)說(shuō)價(jià)值非常大。我接觸這套東西大概是在兩年前當(dāng)時(shí)手上有個(gè)后臺(tái)管理系統(tǒng)的項(xiàng)目接口文檔和實(shí)際返回經(jīng)常對(duì)不上聯(lián)調(diào)階段天天扯皮。后來(lái)把 tRPC 引入進(jìn)來(lái)配合 Next.js 的 API 路由那種“改完接口前端自動(dòng)感知”的體驗(yàn)確實(shí)回不去了。t3code 這個(gè)項(xiàng)目標(biāo)題背后其實(shí)承載的就是這一類工程實(shí)踐的沉淀。它適合誰(shuí)適合已經(jīng)會(huì) React 和 TypeScript、想進(jìn)一步提升工程化水平的開發(fā)者也適合團(tuán)隊(duì)技術(shù)負(fù)責(zé)人用來(lái)評(píng)估要不要在下一個(gè)項(xiàng)目里采用這套技術(shù)組合。2. 技術(shù)選型背后的取舍邏輯2.1 為什么是 T3 這套組合而不是別的先拆一下 T3 Stack 的四個(gè)核心件Next.js 負(fù)責(zé)全??蚣芎吐酚蓆RPC 負(fù)責(zé)類型安全的接口層Tailwind CSS 負(fù)責(zé)樣式TypeScript 負(fù)責(zé)整個(gè)類型系統(tǒng)。這四個(gè)東西單獨(dú)拿出來(lái)都不新鮮但組合在一起產(chǎn)生的化學(xué)反應(yīng)才是關(guān)鍵。Next.js 的 App Router 支持服務(wù)端組件和 Route Handler這意味著你可以在同一個(gè)項(xiàng)目里寫前端頁(yè)面和后端邏輯不用維護(hù)兩個(gè)倉(cāng)庫(kù)。tRPC 則把接口定義變成了普通的 TypeScript 函數(shù)服務(wù)端寫一個(gè)procedure客戶端直接調(diào)用類型自動(dòng)推導(dǎo)。Tailwind 的價(jià)值在于樣式和組件邏輯放在一起減少上下文切換。TypeScript 是粘合劑把上面三個(gè)串成一條類型鏈。我試過(guò)用 REST OpenAPI 生成客戶端代碼的方案也試過(guò) GraphQL 那套。REST 的問(wèn)題是類型定義和實(shí)際實(shí)現(xiàn)容易脫節(jié)代碼生成要額外跑一步CI 里經(jīng)常忘記更新。GraphQL 類型是強(qiáng)了但 schema 和 resolver 之間還是有一層手寫映射而且學(xué)習(xí)曲線陡。tRPC 的好處是沒(méi)有中間層服務(wù)端函數(shù)的輸入輸出類型直接就是客戶端的類型零代碼生成零手動(dòng)同步。注意tRPC 并不是銀彈。如果你的接口需要被非 TypeScript 客戶端消費(fèi)比如移動(dòng)端原生、第三方合作方那 tRPC 就不合適還是得走 REST 或 GraphQL。t3code 這套方案更適合前后端都是自己人、且都用 TypeScript 的場(chǎng)景。2.2 類型安全到底省了多少事舉個(gè)我實(shí)際踩過(guò)的例子。之前有個(gè)用戶列表接口服務(wù)端返回的字段叫createdAt前端某處寫成了createAt因?yàn)槎际亲址愋蚑ypeScript 沒(méi)報(bào)錯(cuò)測(cè)試也沒(méi)覆蓋到上線后那個(gè)時(shí)間一直顯示空白。這種問(wèn)題在 tRPC 體系下根本不會(huì)發(fā)生——字段名寫錯(cuò)編輯器立刻標(biāo)紅編譯直接失敗。再比如接口參數(shù)變更。以前改一個(gè)查詢參數(shù)得全局搜索哪里調(diào)用了這個(gè)接口一個(gè)個(gè)改。現(xiàn)在服務(wù)端改了input的 schema所有調(diào)用點(diǎn)自動(dòng)報(bào)錯(cuò)改完才能編譯通過(guò)。這種“編譯器幫你做回歸測(cè)試”的感覺(jué)用過(guò)就回不去了。從量化角度看我在一個(gè)中等規(guī)模項(xiàng)目里做過(guò)粗略統(tǒng)計(jì)引入 tRPC 之后聯(lián)調(diào)階段因?yàn)樽侄蚊?、類型不匹配?dǎo)致的問(wèn)題減少了大概七成。剩下的三成主要是業(yè)務(wù)邏輯錯(cuò)誤和邊界條件那些本來(lái)就不是類型系統(tǒng)能解決的。2.3 項(xiàng)目結(jié)構(gòu)怎么劃分才不亂t3code 這類項(xiàng)目最容易犯的錯(cuò)就是目錄結(jié)構(gòu)混亂。我見過(guò)有人把所有 tRPC 路由塞在一個(gè)文件里寫到兩千行改一個(gè)接口要滾半天。合理的做法是按業(yè)務(wù)域拆分。一個(gè)我常用的結(jié)構(gòu)是這樣的server/api/routers/下面按模塊建文件比如user.ts、post.ts、order.ts每個(gè)文件導(dǎo)出一個(gè) router。然后在server/api/root.ts里合并成appRouter??蛻舳送ㄟ^(guò)api.user.getById.useQuery()這樣調(diào)用命名空間清晰。共享類型放在server/api/types/或者直接用 tRPC 推導(dǎo)出來(lái)的類型。千萬(wàn)不要手動(dòng)寫一份接口類型再手動(dòng)同步那就失去 tRPC 的意義了。校驗(yàn)邏輯用 Zod 寫在input里這樣運(yùn)行時(shí)校驗(yàn)和編譯時(shí)類型是同一份定義不會(huì)出現(xiàn)“類型說(shuō)沒(méi)問(wèn)題但運(yùn)行時(shí)炸了”的情況。3. 核心環(huán)節(jié)的實(shí)操拆解3.1 環(huán)境搭建與依賴安裝假設(shè)你從零開始第一步是初始化 Next.js 項(xiàng)目。我習(xí)慣用create-next-app選 TypeScript、ESLint、Tailwind、App Router 這幾個(gè)選項(xiàng)。命令大概是這樣npx create-next-applatest my-t3code-app --typescript --tailwind --eslint --app --src-dir裝完之后進(jìn)目錄接著裝 tRPC 相關(guān)依賴npm install trpc/server trpc/client trpc/react-query trpc/next tanstack/react-query zod superjson這里解釋一下每個(gè)包的作用。trpc/server是服務(wù)端核心trpc/client是客戶端核心trpc/react-query提供 React hooks 封裝trpc/next是 Next.js 適配層。tanstack/react-query是 tRPC 底層依賴的數(shù)據(jù)請(qǐng)求庫(kù)zod做運(yùn)行時(shí)校驗(yàn)superjson解決 Date、Map 這類特殊類型的序列化問(wèn)題。提示superjson這個(gè)包很多人會(huì)漏掉結(jié)果傳 Date 對(duì)象的時(shí)候變成字符串前端拿到手還得手動(dòng)轉(zhuǎn)。加上它之后Date、BigInt、Map、Set 都能原樣傳輸省心很多。3.2 服務(wù)端 router 的編寫要點(diǎn)先建src/server/api/trpc.ts初始化 tRPC 實(shí)例配置 context 和 transformer。context 里通常放數(shù)據(jù)庫(kù)連接、當(dāng)前用戶 session 這些東西。transformer 就填superjson。然后建src/server/api/routers/user.ts寫第一個(gè) routerimport { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const userRouter createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) { const user await ctx.db.user.findUnique({ where: { id: input.id }, }); return user; }), });注意input用的是 Zod schema不是 TypeScript interface。這是關(guān)鍵區(qū)別——Zod 既提供編譯時(shí)類型又提供運(yùn)行時(shí)校驗(yàn)。如果客戶端傳了個(gè)數(shù)字 id服務(wù)端會(huì)直接拋校驗(yàn)錯(cuò)誤不會(huì)帶著臟數(shù)據(jù)往下走。在root.ts里合并import { createTRPCRouter } from ./trpc; import { userRouter } from ./routers/user; export const appRouter createTRPCRouter({ user: userRouter, }); export type AppRouter typeof appRouter;最后一行導(dǎo)出AppRouter類型客戶端就靠它做類型推導(dǎo)。3.3 客戶端調(diào)用的完整鏈路客戶端這邊先在src/trpc/react.tsx里創(chuàng)建 tRPC hooksimport { createTRPCReact } from trpc/react-query; import type { AppRouter } from /server/api/root; export const api createTRPCReactAppRouter();然后在組件里直接用const { data: user, isLoading } api.user.getById.useQuery({ id: 123 });這里id的類型會(huì)自動(dòng)約束成 string傳數(shù)字會(huì)報(bào)錯(cuò)。user的類型也會(huì)自動(dòng)推導(dǎo)成服務(wù)端返回的類型編輯器里點(diǎn)進(jìn)去能看到完整字段。整個(gè)過(guò)程沒(méi)有任何手動(dòng)類型聲明全靠推導(dǎo)。我實(shí)測(cè)下來(lái)這套鏈路在 VSCode 里的響應(yīng)速度很快改服務(wù)端代碼后客戶端類型提示幾乎秒級(jí)更新。唯一需要注意的是 tsconfig 的strict要打開不然推導(dǎo)會(huì)打折扣。3.4 參數(shù)校驗(yàn)與錯(cuò)誤處理Zod 的校驗(yàn)?zāi)芰Ρ群芏嗳讼氲膹?qiáng)。除了基礎(chǔ)類型還能做字符串長(zhǎng)度、數(shù)字范圍、正則匹配、自定義 refine。比如.input(z.object({ email: z.string().email(), age: z.number().min(18).max(120), tags: z.array(z.string()).max(5), }))錯(cuò)誤處理方面tRPC 提供了TRPCError可以指定錯(cuò)誤碼throw new TRPCError({ code: NOT_FOUND, message: 用戶不存在, });客戶端拿到錯(cuò)誤后error.data.code就是NOT_FOUND可以根據(jù)錯(cuò)誤碼做不同 UI 處理。這比 REST 里靠 HTTP 狀態(tài)碼猜要清晰得多。注意不要在服務(wù)端把原始數(shù)據(jù)庫(kù)錯(cuò)誤直接拋給客戶端那會(huì)泄露表結(jié)構(gòu)信息。用 TRPCError 包一層對(duì)外只暴露必要的錯(cuò)誤信息。4. 常見問(wèn)題與排查實(shí)錄4.1 類型推導(dǎo)失效怎么辦最常見的情況是客戶端api對(duì)象沒(méi)有正確關(guān)聯(lián)AppRouter類型。檢查createTRPCReactAppRouter()里的泛型是不是導(dǎo)入了正確的類型。另一個(gè)坑是循環(huán)引用——如果服務(wù)端 router 里引用了客戶端的類型會(huì)導(dǎo)致推導(dǎo)鏈斷裂。保持服務(wù)端只依賴服務(wù)端代碼客戶端只依賴AppRouter類型不要交叉。還有一種情況是 tsconfig 的paths配置不對(duì)/server/api/root解析不到。檢查baseUrl和paths是否匹配實(shí)際目錄結(jié)構(gòu)。4.2 請(qǐng)求報(bào) 500 但看不到具體錯(cuò)誤開發(fā)環(huán)境下tRPC 默認(rèn)會(huì)把錯(cuò)誤信息返回給客戶端。如果只看到籠統(tǒng)的 500先看服務(wù)端終端有沒(méi)有打印堆棧。常見原因是數(shù)據(jù)庫(kù)連接失敗、Zod 校驗(yàn)拋錯(cuò)沒(méi)被捕獲、或者 context 初始化時(shí)出錯(cuò)。我遇到過(guò)一次是superjson沒(méi)配置傳 Date 的時(shí)候序列化失敗報(bào)錯(cuò)信息很隱晦。后來(lái)在trpc.ts里加上transformer: superjson就好了。4.3 性能相關(guān)的幾個(gè)坑tRPC 默認(rèn)走 HTTP 批量請(qǐng)求多個(gè) query 會(huì)合并成一個(gè)請(qǐng)求。這個(gè)特性在頁(yè)面初始化時(shí)很有用但如果某個(gè) query 依賴另一個(gè) query 的結(jié)果就不能批量得用enabled選項(xiàng)控制執(zhí)行時(shí)機(jī)。另一個(gè)坑是服務(wù)端組件里直接調(diào)用 tRPC 會(huì)繞過(guò) React Query 緩存。如果需要在服務(wù)端預(yù)取數(shù)據(jù)用createServerSideHelpers或者直接在服務(wù)端調(diào)用 router 的函數(shù)不要走 HTTP。問(wèn)題現(xiàn)象可能原因排查方向類型推導(dǎo)為 anyAppRouter 類型未正確導(dǎo)出檢查 root.ts 的 export type請(qǐng)求 404路由路徑不匹配確認(rèn) Next.js Route Handler 路徑Date 變字符串未配置 superjson檢查 transformer 配置批量請(qǐng)求失效query 之間有依賴用 enabled 控制執(zhí)行順序校驗(yàn)錯(cuò)誤無(wú)提示Zod schema 未定義 message給字段加自定義錯(cuò)誤信息4.4 獨(dú)家避坑經(jīng)驗(yàn)第一個(gè)經(jīng)驗(yàn)不要把業(yè)務(wù)邏輯寫在 router 里。router 只負(fù)責(zé)參數(shù)校驗(yàn)和調(diào)用 service 層真正的業(yè)務(wù)邏輯放到server/services/下面。這樣單元測(cè)試好寫router 也保持輕薄。第二個(gè)經(jīng)驗(yàn)Zod schema 可以復(fù)用。表單校驗(yàn)和接口校驗(yàn)用同一份 schema前端表單提交前先校驗(yàn)一遍減少無(wú)效請(qǐng)求。我通常把 schema 放在shared/schemas/目錄前后端都能 import。第三個(gè)經(jīng)驗(yàn)開發(fā)階段打開 tRPC 的 logger。在trpc.ts里配置loggerLink能看到每個(gè)請(qǐng)求的耗時(shí)和參數(shù)排查性能問(wèn)題很方便。生產(chǎn)環(huán)境記得關(guān)掉不然日志量太大。第四個(gè)經(jīng)驗(yàn)數(shù)據(jù)庫(kù)查詢用 Prisma 的話注意 N1 問(wèn)題。tRPC 不會(huì)自動(dòng)幫你優(yōu)化查詢?cè)?include 的還是要 include。我見過(guò)有人在循環(huán)里調(diào)findUnique一百條數(shù)據(jù)打了一百個(gè)查詢接口慢得離譜。5. 從 t3code 延伸出的工程思考5.1 類型安全只是起點(diǎn)t3code 這套東西解決的是類型層面的問(wèn)題但工程化遠(yuǎn)不止類型。我后來(lái)在這個(gè)基礎(chǔ)上加了幾個(gè)東西用eslint-plugin-boundaries約束模塊依賴方向防止 service 層反向依賴 router用vitest寫 service 層單測(cè)router 層用集成測(cè)試用 GitHub Actions 做 CI每次 PR 跑類型檢查和測(cè)試。這些加進(jìn)來(lái)之后整個(gè)項(xiàng)目的穩(wěn)定性上了一個(gè)臺(tái)階。類型安全讓你在寫代碼時(shí)少犯錯(cuò)測(cè)試和 CI 讓你在改代碼時(shí)少犯錯(cuò)兩者缺一不可。5.2 團(tuán)隊(duì)協(xié)作中的實(shí)際收益帶過(guò)幾個(gè)人的小團(tuán)隊(duì)之后我發(fā)現(xiàn) t3code 這類方案對(duì)協(xié)作的改善很明顯。新人接手項(xiàng)目打開編輯器就能看到所有接口的輸入輸出類型不用翻文檔。接口變更時(shí)編譯器會(huì)告訴所有受影響的地方不會(huì)漏改。代碼 review 時(shí)類型層面的問(wèn)題編譯器已經(jīng)攔住了reviewer 可以專注在業(yè)務(wù)邏輯和邊界條件上。當(dāng)然也有代價(jià)。tRPC 的學(xué)習(xí)曲線對(duì)純前端來(lái)說(shuō)有點(diǎn)陡需要理解 procedure、context、middleware 這些概念。我的做法是先讓團(tuán)隊(duì)里一個(gè)人吃透寫一份內(nèi)部文檔然后帶著大家做一個(gè)小的內(nèi)部工具練手跑通之后再上正式項(xiàng)目。5.3 什么場(chǎng)景不適合這套方案前面提過(guò)需要被非 TypeScript 客戶端消費(fèi)的接口不適合。另外如果項(xiàng)目非常小比如就幾個(gè)頁(yè)面加兩個(gè)接口引入 tRPC 的收益可能抵不過(guò)配置成本。還有就是團(tuán)隊(duì)里如果有人強(qiáng)烈抵觸 TypeScript強(qiáng)行推這套方案會(huì)引發(fā)很多摩擦不如先用 REST 過(guò)渡。我的判斷標(biāo)準(zhǔn)是接口數(shù)量超過(guò)十個(gè)、前后端都是自己人、團(tuán)隊(duì)愿意投入時(shí)間學(xué) TypeScript這三個(gè)條件滿足兩個(gè)以上就值得上 t3code 這套方案。否則可以先觀望或者只引入其中一部分比如只用 Next.js Tailwind接口還是走 REST。5.4 后續(xù)可以怎么擴(kuò)展這套基礎(chǔ)搭好之后往上加?xùn)|西很順。加認(rèn)證就在 context 里解析 token加權(quán)限就在 procedure 上加 middleware加緩存就在 service 層包一層 Redis。因?yàn)轭愋玩溌肥峭ǖ拿考右粚佣寄鼙3诸愋桶踩?。我最近在嘗試把 tRPC 和 Server Actions 結(jié)合表單提交走 Server Action數(shù)據(jù)查詢走 tRPC兩者共享同一套 Zod schema。這樣既利用了 Server Action 的漸進(jìn)增強(qiáng)特性又保留了 tRPC 的類型推導(dǎo)優(yōu)勢(shì)。目前跑下來(lái)感覺(jué)不錯(cuò)等再穩(wěn)定一些可以單獨(dú)寫一篇分享。最后分享一個(gè)小技巧如果你在猶豫要不要上 t3code 這套方案先花一個(gè)下午搭個(gè) demo寫兩個(gè)接口跑通全流程。那種“改完服務(wù)端前端立刻知道”的體驗(yàn)比看十篇文章都有說(shuō)服力。我自己就是這么入坑的到現(xiàn)在為止新項(xiàng)目只要條件允許我都會(huì)優(yōu)先考慮這套組合。