
t3code 這個詞最近在技術群里出現的頻率越來越高。我第一次看到也是一愣這到底是個項目名、庫名還是某種框架的代號后來順著社區(qū)里的討論翻了一圈才大概弄明白它基本可以理解為一套 TypeScript 全棧工程化實踐的代稱而它的核心就是那套被反復討論過的 T3 Stack 組合——Next.js TypeScript tRPC Prisma Tailwind。再往下說t3code 想解決的事情其實很樸素用一種語言、一份類型定義把瀏覽器端、服務端、數據庫這條完整鏈路串起來寫代碼讓前后端之間的“接口”不再靠文檔和自覺維持。這套東西能干什么最直觀的好處是前端調用后端邏輯時不再需要對著一個隨意返回的 JSON 手寫 interface后端一旦改了字段結構前端在編譯期就會立刻報錯。同時它把傳統(tǒng) REST/GraphQL 常見的接口文檔、雙端類型同步、聯調成本砍掉一大截。適合誰想認真做一個全棧項目、又不想把大量時間消耗在類型對齊上的開發(fā)者尤其是剛接觸 Next.js 工程化的朋友。下面的內容我不打算寫成工具書式的羅列而是基于我實際動手搭 t3code 項目的經驗把底層選型邏輯、關鍵實現細節(jié)和踩過的坑一并說清楚。1. 先弄明白 t3code 到底是什么再決定要不要入坑1.1 從 T3 Stack 到 t3code一個代號的背后是什么T3 Stack 這個名字最早來自社區(qū)的create-t3-app腳手架T3 的由來和開發(fā)者 Theo 的個人項目代號有關但社區(qū)更愿意把它解讀為“TypeScript、Tailwind、tRPC”這三個以 T 開頭的核心成員。后來大家越用越順漸漸就把這套組合形成的一個完整工程實踐簡稱 t3code。我個人的理解是t3code 并不單指某個開源倉庫而是“用 TypeScript 統(tǒng)一前后端邊界”的整套工程范式。很多新人剛看到這套組合時會覺得有點繞已經有 REST 和 GraphQL 了為什么還要一個 tRPC這里的關鍵在于tRPC 把“寫接口”這件事變成“寫函數”。你不需要定義 URL、不需要寫 OpenAPI 文檔、不需要手動維護類型后端寫一個 procedure前端就能直接拿到推導完整的類型和方法名。這種體驗很像在同一個代碼庫里調用本地函數只是內部幫你封裝了網絡傳輸和序列化。當然t3code 得以流行的前提是 Next.js 這類全??蚣馨秧撁驿秩竞头斩诉壿嬂酵粋€進程里。沒有 SSR 這類能力tRPC 依然只?!鞍霔l腿”——它擅長服務端調用但瀏覽器側的接口調用還需要額外封裝。所以 t3code 實際的配方是Next.js 提供應用骨架TypeScript 做類型粘合劑tRPC 做遠程調用Prisma 做數據模型Tailwind 處理 UI 樣式。五個角色各管一段但共享一套類型系統(tǒng)。1.2 為什么是這五件套而不是其它方案先說我踩過的彎路。早年我做全棧項目時用過 Express REST 手寫 types前端維護一份api.d.ts后端維護一份routes.ts兩邊稍微不一致跑起來就是各種undefined is not a function。后來換過 GraphQL類型安全確實好很多但為了一個小項目去學 schema、resolver、codegen啟動成本不低而且很多團隊根本用不上 GraphQL 那種按需聚合的能力。t3code 能成立本質上是因為它把“全棧類型安全”這件事收斂到了一條清晰的鏈路Prisma Schema 定義數據庫模型prisma generate生成 TypeScript 類型tRPC procedure 用 zod 做輸入校驗自動推導輸入輸出類型前端 hooks 再把這兩者的類型直接暴露給組件。整條鏈中數據庫模型是源頭zod 是運行時閘門TypeScript 是編譯期防線。用生活化類比來說傳統(tǒng)前后端聯調像“兩個國家之間的邊境通關”JSON 是貨物兩邊各自要對照申報單填一堆字段t3code 則更像同一家公司里的兩個部門直接打電話所有信息格式從公司人事檔案系統(tǒng)里自動同步不用手動互發(fā)模板。這個比喻有點粗糙但核心意思是對的減少約定減少對齊成本。Tailwind 在這套體系里看起來和“類型安全”無關但它解決了另一個痛點傳統(tǒng) CSS 的類名作用域和樣式覆蓋問題。Tailwind 的原子類思路讓樣式直接寫在組件里配合 TypeScript 的模板字符串類型可以在編譯期發(fā)現很多無效類名寫法。從工程效率上說這類“約束式”工具和 t3code 的理念非常匹配。1.3 邊界感什么項目不適合套 t3code我不是來勸所有人都把項目改成 t3code 的。它也有很清晰的適用邊界。如果你要做一個面向第三方開放 API 的公共服務那 REST OpenAPI 或 GraphQL 還是更標準的方案因為外部調用方需要完善的契約文檔而不是“自己連進來調用函數”。tRPC 的客戶端是 TypeScript 強綁定的非 TS 生態(tài)的調用方支持起來比較別扭。另外如果團隊主要技術棧是 Java 或 Go只是某個小前端模塊需要數據t3code 的全棧一體化反而會成為集成負擔。這種場景更適合保持后端語言獨立用 OpenAPI 生成前端類型。還有一類是純展示類官網不涉及復雜業(yè)務邏輯和數據庫交互套 t3code 屬于殺雞用牛刀甚至 Next.js 靜態(tài)導出就夠了。我自己的判斷標準很簡單只要這個項目是“前端 服務端 數據庫”三條腿都齊的內部應用或中小體量產品t3code 就能發(fā)揮很好的作用如果它根本不涉及數據庫或者要開放給大量外部系統(tǒng)那就別硬套。工具這東西順手才是第一位。2. 全棧類型安全是怎么落地的tRPC、Prisma、Zod 三條鏈路拆解2.1 tRPC 路由結構與 Zod 校驗的配合tRPC 的核心概念有三個router、procedure和context。router就像一個命名空間用來組織一組 procedureprocedure 是真正可被客戶端調用的函數它支持query查和mutation寫context 是每次請求都會伴隨的公共數據比如用戶身份、數據庫連接實例。我在項目里最常用的一段根路由代碼如下import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.create(); export const appRouter t.router({ getTodo: t.procedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) { return ctx.prisma.todo.findUnique({ where: { id: input.id } }); }), createTodo: t.procedure .input( z.object({ title: z.string().min(1).max(100), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title, tags: input.tags ? { connect: input.tags.map((id) ({ id })) } : undefined, }, }); }), }); export type AppRouter typeof appRouter;這里有幾個細節(jié)值得展開。第一.input()如果你不寫 zod schema客戶端的參數類型就會是unknown我見過不少人掉在這個坑里服務端跑得好好的前端完全沒有提示。第二query和mutation之后返回的值會被自動推導到客戶端 hooks所以你在寫返回邏輯時盡量不要用any否則類型安全會斷裂。第三context一般放在初始化 tRPC 時注入比如把prisma和session掛上去方便每個 procedure 直接使用。很多人會問zod 到底是干嘛的它在這里承擔雙重角色運行時校驗輸入數據防止臟數據進入業(yè)務邏輯同時把校驗規(guī)則變成靜態(tài)類型供 TypeScript 編譯器使用。這比單純依賴 TypeScript 的編譯期類型要穩(wěn)得多因為編譯期不會攔你在接口里塞字符串。2.2 Prisma Schema 設計時容易忽略的細節(jié)Prisma 在整個 t3code 鏈路里是“源頭”數據庫模型直接決定 ORM 能生成什么類型。我見過不少項目寫 schema 時很隨意后來前端查詢時一直拿不到關系字段問題就出在模型關系沒定義好。舉個簡單例子待辦清單和標簽是多對多關系model Todo { id String id default(cuid()) title String completed Boolean default(false) createdAt DateTime default(now()) updatedAt DateTime updatedAt tags Tag[] relation(TodoTags) } model Tag { id String id default(cuid()) name String unique todos Todo[] relation(TodoTags) }這里有三個容易忽略的點。第一default(cuid())和default(uuid())要按場景選cuid 適合公開場合不容易被遍歷猜測uuid 更通用但在冷讀場景下索引稍大。第二多對多隱式關系表面上看只需要兩邊互相寫數組字段但如果你后續(xù)想給關聯本身加屬性比如給“TodoTag”記錄加上“創(chuàng)建時間”就必須改成顯式中間表。否則后面想加字段就要做一次不小的遷移。第三updatedAt這個字段 Prisma 會在更新時自動維護不需要你手動賦值但如果你用原生 SQL 直接 update 表它是不會自動變的這點在排查數據問題時可能會繞一下。另外prisma generate生成的類型在node_modules/.prisma/client下每次改完 schema 并遷移后如果發(fā)現前端類型沒反應我基本都會重啟一遍 dev server。編輯器里的 TS Server 偶爾會緩存舊的類型定義這屬于日常高頻問題。2.3 Tailwind 與組件庫樣式層怎么選型不后悔Tailwind 看上去是“樣式層”的小事但在 t3code 工程里選型選錯了后期會非常痛苦。我的建議是新項目先純 Tailwind 起步不加任何重型 CSS 框架等 UI 組件重復度上來了再考慮shadcn/ui這類基于 Radix Tailwind 的 headless 組件庫。為什么因為 Tailwind 的 JIT 編譯模式只生成你實際用到的類這保證了樣式包體積可控。而如果你一開始就引入一套完整組件庫很多組件和樣式你根本用不到還會帶來樣式覆蓋優(yōu)先級的問題。Tailwind 的設計哲學是“約束大于自由”它幫你減少類名命名的決策負擔這剛好和 t3code 想減少工程決策負擔的理念一致。實操方面我建議把主題色、間距、圓角這類 token 定義在tailwind.config.ts的theme.extend里而不是直接在業(yè)務組件里寫死色值。這樣后面需要換主題時只需要改配置不用全局搜索替換。另外不要在className里做字符串拼接來動態(tài)生成類名比如w-${size}Tailwind 的 JIT 編譯器無法掃描到這種動態(tài)類名樣式會莫名丟失。正確做法是寫全類名或者用safelist。3. 手把手搭一個 t3code 項目初始化、業(yè)務路由到部署3.1 create-t3-app 初始化時每個選項該如何選現在初始化 t3code 項目最省事的途徑還是官方腳手架。我習慣用 pnpm創(chuàng)建命令pnpm dlx create-t3-applatest t3code-demo交互過程會問你想用 App Router 還是 Vite以及其他一堆功能模塊。先說我的推薦選擇App Router 勾選TypeScript 勾選Tailwind 勾選tRPC 勾選Prisma 勾選ESLint 勾選NextAuth 看項目是否需要登錄不需要就別強行勾它會引入額外的 session 配置復雜度。App Router 和 Pages Router 的差異很多人剛開始不敏感但實際寫起來影響很大。App Router 引入了 React Server ComponentsRSC頁面的數據獲取可以直接在服務端完成配合 tRPC 的serverClient調用方式非常高效。當然RSC 有學習曲線比如“use client” 和 “use server” 的邊界剛開始確實容易繞暈所以我建議新項目直接適應它而不是繼續(xù)抱著 Pages Router因為生態(tài)里越來越多的庫已經在朝 App Router 對齊了。初始化完成后建議先跑一遍pnpm prisma migrate dev --name init pnpm prisma generate pnpm dev確認頁面正常打開后再開始加業(yè)務代碼。很多人喜歡一上來就堆代碼結果環(huán)境變量、數據庫連接都沒準備調試起來特別痛苦。3.2 一個能跑的示例帶標簽的待辦清單接下來我用一個具體例子走一遍流程。項目就叫 t3code-demo核心功能是創(chuàng)建待辦、給待辦打標簽、展示列表。先定義數據模型對接數據庫。假設你已經配置好 PostgreSQL 的DATABASE_URL寫好 Schema 后執(zhí)行遷移命令。seed 腳本我通常放在prisma/seed.ts用tsx運行import { PrismaClient } from prisma/client; const prisma new PrismaClient(); async function main() { await prisma.tag.createMany({ data: [{ name: 工作 }, { name: 生活 }], skipDuplicates: true, }); } main() .catch((e) { console.error(e); process.exit(1); }) .finally(async () { await prisma.$disconnect(); });別忘了在package.json里配置prisma: { seed: tsx prisma/seed.ts }然后業(yè)務 router 可以加一個 list 查詢和 create mutationimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const todoRouter createTRPCRouter({ list: publicProcedure .input(z.object({ tagId: z.string().optional() }).optional()) .query(async ({ ctx }) { return ctx.prisma.todo.findMany({ include: { tags: true }, orderBy: { createdAt: desc }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1).max(100), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { return ctx.prisma.todo.create({ data: { title: input.title, tags: { connect: input.tagIds.map((id) ({ id })), }, }, include: { tags: true }, }); }), });在前端頁面里調用時注意 App Router 的寫法。對于客戶端組件先要用createTRPCReact生成 hooksimport { createTRPCReact } from trpc/react-query; import type { AppRouter } from ~/server/api/root; export const trpc createTRPCReactAppRouter();然后在_app.tsx或 layout 的 Provider 里包裝。組件里這樣用use client; import { trpc } from ~/trpc/react; export function TodoList() { const { data: todos, isLoading } trpc.todo.list.useQuery(); const utils trpc.useUtils(); const createTodo trpc.todo.create.useMutation({ onSuccess: () utils.todo.list.invalidate(), }); if (isLoading) return div加載中.../div; return ( ul {todos?.map((todo) ( li key{todo.id}{todo.title}/li ))} /ul ); }這段代碼里invalidate是重點mutation 成功后讓 list 重新拉取保證頁面數據新鮮。實際項目里有人會手動setData更新但在大多數中小場景里invalidate 是最省心且不容易出錯的方案。3.3 部署前必須處理的三個環(huán)節(jié)很多項目開發(fā)期跑得好好的一到生產環(huán)境就各種莫名問題。根據我的經驗t3code 項目部署前一定要處理這三件事。第一DATABASE_URL要區(qū)分環(huán)境且生產環(huán)境最好單獨配置連接池相關的參數比如 PostgreSQL 需要pgbouncertrue的場合。不要直接在代碼里寫死任意庫的地址也別把配置提交到倉庫。Prisma Client 支持在連接串中帶sslmoderequire遠程數據庫一般都要加上。第二遷移的時機。開發(fā)環(huán)境你可以用prisma migrate dev它會自動重置數據庫、應用遷移并重新生成客戶端但生產環(huán)境絕對不能這么干。生產環(huán)境要用prisma migrate deploy它只會應用還沒有執(zhí)行過的遷移文件不會觸發(fā)重置或 seed。常見的 CI 步驟是prisma generate-prisma migrate deploy-next build-next start。第三Next.js 的部署產物。在next.config.js里設置output: standalone這樣構建產物里只包含運行所需的最小文件在容器平臺部署時鏡像體積會小很多。同時注意 Node 版本要和本地一致最好在package.json的engines字段里鎖一個避免不同環(huán)境下 Node 行為差異。4. 實戰(zhàn)中的高頻報錯與排查經驗4.1 類型推斷失靈先按這個順序查用 t3code 最爽的是類型安全但類型失靈時也是最讓人頭疼的。我總結了一個排查順序基本能覆蓋 90% 的情況。首先看客戶端 hooks 有沒有正確拿到AppRouter類型。如果你在createTRPCReactAppRouter()里傳錯了泛型或者appRouter沒有綁定到createNextApiHandler前端就會出現“無法找到屬性 xxx”的錯誤。特別是復制粘貼模板代碼時很容易漏掉root.ts的 export。其次檢查 procedure 的.input()是否寫了 zod。如果沒寫客戶端的入參類型就是unknown你會在useMutation里看到一個沒有類型的變量這不僅失去提示還會導致運行期數據校驗完全缺失。最后如果 Prisma 模型的字段類型和你預期不一致多半是prisma generate之后沒有重新加載 TypeScript Server 的診斷信息。我會先看一下生成的.prisma/client文件是否包含最新的模型再重啟一次編輯器。很多人不知道tsc --noEmit可以快速暴露這一類編譯問題建議有報錯時先跑一遍。現象根因解決方案客戶端方法完全沒提示AppRouter 泛型沒接好檢查createTRPCReact的泛型定義入參類型是 unknown缺少 zod input 校驗為 procedure 補.input(z.object({ ... }))關系字段報類型錯誤查詢沒include或select在 route 內正確聲明 include字段類型和 Schema 不一致未重新生成 Prisma Client執(zhí)行prisma generate并重啟 dev4.2 頁面水合不一致與 tRPC 請求重復Next.js 的 SSR 在服務端生成 HTML然后在客戶端做 hydration水合。如果兩邊渲染的結果不一致就會報水合錯誤。最常見的來源是組件里直接讀取localStorage、sessionStorage或者用new Date()渲染時間。服務端看到的日期和客戶端不同HTML 結構就對不上。解決方式相對固定把依賴瀏覽器環(huán)境的邏輯放進useEffect里或者用 Next.js 的動態(tài)導入關閉 SSR例如dynamic(SomeComponent, { ssr: false })。我用純前端狀態(tài)展示用戶登錄信息時通常先渲染一個骨架等到 useEffect 里拿到瀏覽器數據再更新這樣水合永遠一致。再說 tRPC 的請求重復問題。默認情況下如果客戶端組件里的useQuery同時又在服務端預取數據很容易出現“同一接口打兩次”的現象。App Router 里的原則是RSC 直接調 serverClient 取數據不要再用客戶端useQuery重復拉客戶端交互較多、實時性要求高的組件則把ssr: false配合客戶端 hooks 使用。這樣分層既避免多余請求又能保持數據流清晰。4.3 服務端組件和 tRPC 的配合誤區(qū)App Router 的 RSC 不能直接使用useQuery這類 hooks這是很多初學者的第一個大坑。正確的做法是使用 tRPC 的 server caller。在~/trpc/server.ts里創(chuàng)建一個serverClientimport { createCallerFactory, createTRPCContext } from ~/server/api/trpc; import { appRouter } from ~/server/api/root; const createCaller createCallerFactory(appRouter); export const serverClient createCaller(createTRPCContext);然后在 RSC 頁面里直接調用export default async function Page() { const todos await serverClient.todo.list(); return TodoList initialTodos{todos} /; }注意這個組件本身不需要 “use client”它默認就是服務端組件可以被異步函數直接調用。把拿到的數據作為 props 傳給客戶端組件再由客戶端組件接管交互邏輯。這個模式把“數據獲取”和“用戶交互”分得很清楚RSC 負責讀客戶端組件負責寫和狀態(tài)更新。另一個誤區(qū)是“把所有頁面都標記成 use client”。很多人覺得 tRPC hooks 用起來方便就直接把整個頁面組件標成客戶端組件結果服務端渲染的優(yōu)勢全沒了首屏 JS 體積也變大。實際上大部分數據獲取都可以先通過 serverClient 在服務端完成只有真正需要事件處理和實時狀態(tài)的部分才需要客戶端化。4.4 幾組容易被忽略的配置小坑最后列幾個我踩過的小配置問題都屬于“百度半天找不到其實就一行配置”的范疇。第一個是 zod 版本升級帶來的類型推斷差異。tRPC 官方對 zod 版本有兼容性要求如果你在項目里強行升級到不兼容的大版本可能會出現input類型無法推斷的詭異現象。遇到這種問題先對齊一下依賴版本tRPC 文檔里有明確的 peerDependencies 表。第二個是 Prisma 在連接遠程數據庫時的 SSL 配置。本地開發(fā)可能不需要但云端 PostgreSQL 基本都要帶?sslmoderequire。少了這個參數prisma migrate deploy會報連接被拒。第三個是 Tailwind 的content配置。如果你在tailwind.config.ts的content數組里沒有包含實際用到類名的文件路徑部分樣式會莫名不生效。特別是新增了某個模塊卻沒把它加進掃描范圍排查起來相當鬧心。我現在的習慣是內容路徑寫得寬一點哪怕是./src/**/*.{ts,tsx}這樣也不會明顯拖慢構建。還有一個不算配置但很影響體驗的問題不要圖省事在server/api/routers底下把所有代碼寫到同一個文件里。剛開始項目小確實沒問題但隨著業(yè)務增長這個文件會膨脹得非常快。按領域拆分 router再在 root router 里聚合才是 t3code 工程能長期維護的關鍵。個人實踐里的一個體會是t3code 這套組合最大的價值不是“少寫代碼”而是“減少猜測”。以前前后端聯調我經常對著接口文檔猜某個字段是不是可選現在所有數據結構在編譯器和運行時都被卡得死死的我可以把精力放到業(yè)務本身。最后再分享一個小習慣每改一次 Prisma Schema 或者 router 的輸入輸出先跑一遍tsc --noEmit再啟動開發(fā)服務器。這點額外花的時間基本能在后續(xù)調試里幾倍地賺回來。