前后端類型安全打通)
前陣子我在梳理全棧項目模板時發(fā)現(xiàn)一個很有意思的現(xiàn)象很多人把 T3 Stack 掛在嘴邊但真正能把 Next.js、TypeScript、tRPC、Prisma、Tailwind 這套組合落成一個可復用、可擴展的代碼體系的人其實并不多。t3code 這個名字乍一看只是個簡單的“代碼”集合但往深了挖它更像是一套以 TypeScript 為骨架、以類型安全為靈魂的全棧項目落地模板。它能解決的是開發(fā)團隊在啟動新項目時反復做重復選型、重復配環(huán)境、重復寫膠水代碼的問題也能幫助個人開發(fā)者從零快速搭出一個結(jié)構(gòu)清晰、前后端類型打通的應用骨架。這篇文章不適合只想抄一段代碼就跑的人更適合真正想把全棧 TypeScript 項目做出“標準姿勢”的人。無論你是剛開始接觸 T3 相關(guān)技術(shù)棧還是已經(jīng)在用 Next.js 但總感覺前后端類型沒有打通都可以從這套代碼實踐中找到可以照搬的結(jié)構(gòu)和思路。我會直接把 t3code 在架構(gòu)選型、目錄組織、數(shù)據(jù)模型、接口封裝、前端接入、部署細節(jié)這幾個層面的實際做法拆開來講。1. t3code 在解決什么問題一套全棧 TypeScript 的“標準姿勢”1.1 從技術(shù)選型說起為什么是 Next.js tRPC Prisma很多人在搭新項目時第一個糾結(jié)的地方就是技術(shù)選型。前端用 React 沒問題但 Next.js 是不是過度設(shè)計后端接口用 REST 還是 GraphQL數(shù)據(jù)庫 ORM 用 Prisma 還是 Drizzle這些問題一旦在團隊里展開基本就是一場沒完沒了的討論。t3code 的選擇邏輯非常直白如果整個應用都用 TypeScript 寫那就讓類型從前端一路穿透到數(shù)據(jù)庫。Next.js 負責應用框架和應用路由既有 SSR 能力也可以純靜態(tài)導出團隊可以根據(jù)場景分別使用 Server Component 和 Client Component。TypeScript 負責語言層面的類型約束從組件 props 到接口參數(shù)所有邊界都有類型定義。tRPC 負責前后端接口通信它的特點是不需要額外定義 REST 路由也不需要寫 GraphQL Schema直接利用 TypeScript 的類型推斷能力讓前端調(diào)用后端方法時獲得完整的自動補全和類型校驗。Prisma 負責數(shù)據(jù)模型定義和數(shù)據(jù)庫訪問schema 文件本身就是一份數(shù)據(jù)庫設(shè)計文檔同時生成的客戶端自帶類型。這四樣東西放在一起等于把“前端組件、后端邏輯、數(shù)據(jù)模型”三層的類型全部串成一整條鏈。說白了你在前端寫const data await trpc.user.list.query()如果后端list方法沒寫編輯器直接就報紅了根本不用等運行時接口報 404。1.2 T3 理念的核心類型安全不是口號是流程本身很多人會覺得類型安全就是“用了 TypeScript 而已”但 t3code 做的事情不止于此。它把類型安全延伸到了三個層面第一層是數(shù)據(jù)模型的類型安全。Prisma Schema 里定義了User、Post這些模型字段類型、關(guān)聯(lián)關(guān)系、索引都在這里確認生成的 TypeScript 類型直接成為整個應用的唯一事實來源。第二層是接口調(diào)用的類型安全。tRPC Router 的每個 procedure 都帶有輸入輸出類型。你在前端寫調(diào)用時傳的參數(shù)類型不對會立刻報錯返回的數(shù)據(jù)結(jié)構(gòu)也是自動推斷出來的不需要手動維護一份接口文檔。第三層是業(yè)務(wù)狀態(tài)的類型安全。React Query 配合 tRPC請求狀態(tài)、緩存 key、數(shù)據(jù)更新都能類型化。重新取數(shù)、失效緩存、樂觀更新這些操作不再是靠字符串約定而是靠類型系統(tǒng)約束。所以 t3code 的核心價值不是它用了多新的技術(shù)而是它把類型安全變成了整個開發(fā)流程的默認行為。這就像你寫后端代碼時函數(shù)參數(shù)不寫類型會渾身難受一樣t3code 讓你在寫全棧代碼時體會一次“類型不到處傳心里就不踏實”的狀態(tài)。2. t3code 的結(jié)構(gòu)設(shè)計與代碼組織2.1 項目目錄與職責劃分t3code 的目錄結(jié)構(gòu)看起來并不復雜但每個目錄的職責邊界非常清晰。我基于常見實踐把它整理成下面這套劃分方式新項目直接照抄都可以├── prisma │ ├── schema.prisma # 數(shù)據(jù)模型的唯一事實來源 │ └── migrations # 數(shù)據(jù)庫遷移記錄 ├── src │ ├── server │ │ ├── api │ │ │ ├── routers # 按業(yè)務(wù)域拆分的 tRPC Router │ │ │ │ ├── user.ts │ │ │ │ └── post.ts │ │ │ └── root.ts # 所有子 Router 匯總到根 Router │ │ ├── auth.ts # 認證相關(guān)邏輯如果啟用了鑒權(quán) │ │ └── db.ts # Prisma Client 單例 │ ├── pages # Next.js 頁面路由 │ │ ├── _app.tsx # 全局 Provider 掛載點 │ │ └── index.tsx │ ├── components # 通用 UI 組件 │ ├── styles # Tailwind 入口文件 │ ├── utils # 工具函數(shù) │ └── trpc │ ├── client.ts # 前端 tRPC 客戶端配置 │ └── server.ts # SSR 時的 tRPC 調(diào)用輔助 └── .env # 環(huán)境變量這個結(jié)構(gòu)的核心思想是按職責分層不按文件類型亂堆。server 目錄只放后端相關(guān)代碼pages 目錄只負責路由入口components 目錄只放可復用 UI。真正業(yè)務(wù)相關(guān)的代碼比如查詢邏輯、數(shù)據(jù)轉(zhuǎn)換都收在 routers 和對應的業(yè)務(wù)模塊里。2.2 最容易被忽略的配置細節(jié)目錄結(jié)構(gòu)誰都會列真正拉開差距的是配置細節(jié)。我見過不少項目從表面看結(jié)構(gòu)一樣但用起來就是各種不舒服問題基本都出在下面這幾個地方。路徑別名的統(tǒng)一。tsconfig.json 里配置server/*、/*這些路徑別名本身不難難的是讓 ESLint、Prettier、Jest 這些周邊工具都認同一套路徑。t3code 的做法是在 tsconfig 和 tailwind.config 里都顯式聲明同樣的別名確保編輯器提示、構(gòu)建解析、測試環(huán)境三者行為一致。嚴格模式必須開滿。TypeScript 的strict: true只是底線。t3code 還會額外開啟noUncheckedIndexedAccess這意味著arr[0]不會自動推斷為元素類型而是T | undefined。這個配置一開始會覺得煩但實際上會逼你把所有數(shù)組訪問的邊界都想清楚在寫業(yè)務(wù)代碼時特別能預防線上才出現(xiàn)的取不到值問題。環(huán)境變量的類型化。項目啟動時如果DATABASE_URL沒設(shè)置Prisma 會報錯但報錯信息有時候不夠直觀。t3code 會做一個統(tǒng)一的環(huán)境變量校驗模塊啟動前把所有必需變量一次性檢查完缺哪個就明確提示缺哪個而不是等數(shù)據(jù)庫連接超時之后才猜原因。依賴安裝順序。先裝運行時依賴再裝開發(fā)依賴。因為 t3code 涉及 Next.js 的編譯、Prisma 的 generate、tRPC 的類型生成這幾個工具的安裝順序如果不一致在某些 Node 版本下會出現(xiàn)原生模塊編譯失敗或者 Prisma Engine 下載不完整。3. 從零搭建一次 t3code 全流程實操3.1 環(huán)境準備與腳手架初始化如果你要復現(xiàn) t3code 這套代碼實踐建議直接從干凈的目錄開始。我假設(shè)你已經(jīng)裝好了 Node.js 18 LTS 以上版本的運行環(huán)境并且本機有可用的包管理器。創(chuàng)建一個新項目時可以直接基于 create-t3-app 起步。執(zhí)行初始化命令之后交互式問答里重點確認幾個選項是否啟用 Next.js App Router、是否啟用 tRPC、是否啟用 Prisma、是否啟用 Tailwind CSS。我的建議是全選啟用因為 t3code 的完整價值就是讓這四樣東西協(xié)同工作去掉任何一樣類型安全鏈路都會出現(xiàn)斷點。完成初始化后第一件事不是寫業(yè)務(wù)代碼而是先跑一遍npm run dev確認基礎(chǔ)頁面能正常打開。然后再改schema.prisma讓 Prisma 客戶端和后端類型生成到node_modules/.prisma與src/server/db.ts能正常聯(lián)動。這里有一個非常關(guān)鍵的細節(jié)不要一上來就用最新版 Prisma除非你已經(jīng)確認它與 Next.js 當前版本兼容。我在實操中遇到的多數(shù)疑難問題都源于 Prisma 升級帶來的生成路徑變化。穩(wěn)妥做法是鎖定一個經(jīng)過驗證的版本組合比如 Next.js 14 Prisma 5.x跑通整條鏈路后再考慮升級。3.2 數(shù)據(jù)模型設(shè)計用 Prisma 定義業(yè)務(wù)實體并生成客戶端數(shù)據(jù)模型設(shè)計是 t3code 里最需要提前想清楚的部分因為 Prisma Schema 一旦確定數(shù)據(jù)庫遷移和所有關(guān)聯(lián)類型都會跟著確定下來。以一個簡單的內(nèi)容發(fā)布系統(tǒng)為例我會在prisma/schema.prisma里定義出兩個相互關(guān)聯(lián)的模型generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) email String unique name String? posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String createdAt DateTime default(now()) updatedAt DateTime updatedAt }一切模型設(shè)計都圍繞“關(guān)系”展開。author和posts這兩個字段在數(shù)據(jù)庫層面對應外鍵關(guān)系在 Prisma 層面對應關(guān)聯(lián)查詢能力。當你寫好這條 schema 后執(zhí)行npx prisma migrate dev --name initPrisma 會幫你生成遷移文件并同步數(shù)據(jù)庫結(jié)構(gòu)同時自動重新生成客戶端代碼。我使用 Prisma 客戶端時習慣在同一文件里導出單例避免在開發(fā)環(huán)境下因為模塊熱更新創(chuàng)建多個連接實例import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const prisma globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma prisma; }這個單例模式雖然不是 Prisma 官方文檔開篇就講的內(nèi)容但在 Next.js 開發(fā)模式下幾乎屬于標配不這樣做的話每次文件改動熱更新都可能產(chǎn)生數(shù)據(jù)庫連接堆積。3.3 接口層封裝tRPC Router 的寫法與調(diào)用鏈數(shù)據(jù)模型就緒后接下來就是最容易寫出“樣板代碼”的部分接口層。tRPC 的 Router 并不等同于傳統(tǒng) REST 路由它更像是一個“可遠程調(diào)用的函數(shù)集合”。每個 procedure 就是一個可被前端調(diào)用的函數(shù)由輸入解析、權(quán)限檢查、業(yè)務(wù)邏輯三部分組成。我先建立一個基礎(chǔ) Router 工廠import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.context{ userId: string | null }().create(); export const router t.router; export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.userId) { throw new Error(Unauthorized); } return next({ ctx: { ...ctx, userId: ctx.userId } }); });這里有個關(guān)鍵點輸入校驗用 zod而且是必選項。很多從 REST 轉(zhuǎn)過來的開發(fā)者會覺得 zod 是多余的因為 TypeScript 類型已經(jīng)定義了參數(shù)結(jié)構(gòu)。但運行時輸入是不可信的TypeScript 類型在編譯后會消失zod 的作用就是接管運行時校驗。tRPC 和 zod 是官配procedure 的輸入一旦定義好前端調(diào)用時不僅自動獲得類型推導錯誤信息也會非常直觀。具體到業(yè)務(wù) Router我會這樣寫import { z } from zod; import { router, publicProcedure, protectedProcedure } from ../trpc; import { prisma } from ../db; export const postRouter router({ list: publicProcedure .input(z.object({ take: z.number().min(1).max(50).default(10) })) .query(async ({ input }) { return prisma.post.findMany({ take: input.take, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { return prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.userId, }, }); }), });接著在 root Router 里合并所有子 Routerimport { router } from ./trpc; import { postRouter } from ./routers/post; import { userRouter } from ./routers/user; export const appRouter router({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;上面這一步最重要的東西是export type AppRouter。這個類型導出之后前端可以基于它生成完整的類型安全的 tRPC 客戶端。整個調(diào)用鏈的銜接靠的就是這個類型定義。類型的穿透力有多強前端代碼里手寫trpc.post.create.mutate({ title: x, content: y })時title和content都是必填的參數(shù)類型錯誤在保存文件的那一刻就會顯示出來。整個鏈路里只要有一層類型對不上編輯器就是一面紅色錯誤墻這比任何接口文檔都來得可靠。3.4 前端頁面接入與樣式處理t3code 的前端接入核心工作就是把 tRPC 客戶端掛到 React 應用上然后在組件里直接調(diào)用查詢方法。頁面入口_app.tsx要包一層 Providerimport { httpBatchLink } from trpc/client; import { QueryClient, QueryClientProvider } from tanstack/react-query; import { trpc } from ../utils/trpc; import type { AppProps } from next/app; const queryClient new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, }, }, }); const trpcClient trpc.createClient({ links: [ httpBatchLink({ url: /api/trpc, }), ], }); export default function App({ Component, pageProps }: AppProps) { return ( trpc.Provider client{trpcClient} queryClient{queryClient} QueryClientProvider client{queryClient} Component {...pageProps} / /QueryClientProvider /trpc.Provider ); }實際業(yè)務(wù)組件里的寫法是import { trpc } from ../utils/trpc; export default function PostList() { const { data, isLoading } trpc.post.list.useQuery({ take: 10 }); if (isLoading) return div加載中.../div; return ( ul {data?.map((post) ( li key{post.id} h2{post.title}/h2 p{post.content}/p /li ))} /ul ); }注意看調(diào)用useQuery時傳入的({ take: 10 })是有類型檢查的data的每一項直接就是 Prisma 返回的Post類型。這里不需要手寫任何接口類型定義也不需要在前后端之間復制 DTO。狀態(tài)管理這部分用 React Query 處理緩存、重試、加載態(tài)都不需要自己造輪子。樣式方面Tailwind 主要負責原子類但 t3code 并不強制你用 Tailwind。實際用下來Tailwind 在快速搭建頁面時確實高效可一旦設(shè)計系統(tǒng)復雜到一定程度單獨抽離組件樣式反而更明智。所以我的建議是項目前期用 Tailwind 打通所有基礎(chǔ)樣式等出現(xiàn)重復性極高的樣式組合時再抽成組件類不用把每個頁面都寫成純原子類堆疊。4. 實際踩過的坑t3code 常見問題與排查記錄這一部分是我最想寫的因為 t3code 這類全棧模板真正的門檻不是學會某個框架而是各種配置在真實開發(fā)環(huán)境里互相作用時的那些隱藏問題。我整理了一份速查表然后挑幾個最典型的展開講。問題現(xiàn)象根本原因處理方式啟動時提示PrismaClientInitializationErrorDATABASE_URL未設(shè)置或格式不正確檢查.env文件和.env.example是否同步前端調(diào)用接口時類型不匹配tRPC 客戶端基于的AppRouter類型是舊版本重新跑一遍prisma generate和類型檢查確認根組件導出類型已更新頁面顯示舊數(shù)據(jù)刷新后恢復客戶端 Query 緩存未失效在 mutation 成功回調(diào)里調(diào)用trpc.useUtils().post.list.invalidate()熱更新之后數(shù)據(jù)庫連接數(shù)暴漲Next.js dev 模式下創(chuàng)建了多個 Prisma 實例使用全局單例模式復用 PrismaClientTailwind 類名沒有生效內(nèi)容路徑配置不全新目錄未被掃描在tailwind.config.ts中顯式添加./src/**/*.{ts,tsx}tRPC procedure 調(diào)用報 500但日志不詳細異常被 tRPC 默認處理攔截開發(fā)環(huán)境開啟onError日志生產(chǎn)環(huán)境使用錯誤格式化中間件4.1 Prisma 版本升級導致的生成異常這個問題我會放第一個說因為幾乎每個跑了一段時間的 t3code 項目都會遇到。某天執(zhí)行npm update后突然發(fā)現(xiàn)prisma generate生成的結(jié)果和舊版本不兼容數(shù)據(jù)庫訪問層報出一堆類型錯誤或者 Prisma Client 報“The generated client has been modified”的提示。排查思路很簡單先看項目鎖定文件里 Prisma 和prisma/client的版本是否完全一致。這兩個包必須同步升級不能一個升一個不升。其次看 schema 文件是否有舊的生成器配置殘留。最后如果升級后實在找不到原因直接刪除node_modules、刪除生成的prisma/migrations中未提交的遷移記錄重新安裝并執(zhí)行prisma migrate dev基本都能恢復。4.2 tRPC 類型在跨包引用時丟失tRPC 在單一代碼庫里效果最好但如果你的項目一開始就設(shè)計成 monorepo或者把業(yè)務(wù)模塊抽成了多個 npm 包類型穿透的鏈路就可能斷掉。我遇到過的典型情況是后端新增了一個 procedure前端包里的AppRouter類型始終沒有同步更新不管怎么重新安裝依賴編輯器里都看不到新接口。這個問題在 t3code 里的標準解法是確保類型導出的路徑是純類型導出并且業(yè)務(wù)包之間的依賴方式使用 workspace 協(xié)議。如果前端包直接依賴后端包的構(gòu)建產(chǎn)物那么每次后端代碼變更后都要重新構(gòu)建一次否則類型就是舊的。使用 workspace 協(xié)議配合 TypeScript Project References可以讓類型在源文件層面直接關(guān)聯(lián)省掉構(gòu)建這一步。4.3 HMR 與路由緩存帶來的“看得到舊數(shù)據(jù)”Next.js 開發(fā)模式下熱更新有時會觸發(fā)頁面路由緩存導致你明明改了后端返回邏輯刷新頁面看到的還是舊數(shù)據(jù)。這種行為不一定是 tRPC 或 Prisma 的問題而是 React Query 的緩存策略在作祟。解決方案是在 mutation 成功后主動失效相關(guān)查詢緩存。t3code 習慣在封裝的創(chuàng)建、更新、刪除操作里統(tǒng)一調(diào)用失效方法const utils trpc.useUtils(); const createPost trpc.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); }, });這樣每次數(shù)據(jù)變更后列表都會自動重新請求頁面展示的數(shù)據(jù)和數(shù)據(jù)庫狀態(tài)保持一致不需要手動刷新。這個細節(jié)如果沒處理用戶會反復反饋“我提交了但看不到新內(nèi)容”特別影響體驗。4.4 環(huán)境變量缺失引發(fā)的隱蔽報錯環(huán)境變量相關(guān)的問題總是最隱蔽的因為本地開發(fā)時.env存在一切正常一到部署或者新成員拉代碼時缺少必要變量報錯五花八門。我見過最心累的一種是DATABASE_URL寫錯但不是完全缺失導致 Prisma 能初始化但每次查詢都超時。t3code 的做法值得借鑒在服務(wù)端入口處統(tǒng)一校驗環(huán)境變量而不是用到哪就在哪讀取。我會寫一個簡單的校驗函數(shù)在應用啟動時檢查DATABASE_URL、NEXTAUTH_URL如果啟用了認證等核心變量缺失時直接拋出帶有具體變量名的錯誤讓排查時間縮短到一分鐘以內(nèi)。5. t3code 的擴展方向與我的個人體會5.1 從模板到業(yè)務(wù)腳手架還能往哪些方向長t3code 作為一套代碼實踐它的價值不止于“創(chuàng)造一個項目”更在于后續(xù)可以沿著多個方向延伸成完整的業(yè)務(wù)腳手架。第一個方向是接入文件上傳與對象存儲。目前大多數(shù) T3 項目在涉及文件上傳時都會臨時找方案有的用第三方存儲 SDK有的用云函數(shù)直傳。如果把這部分抽象成統(tǒng)一的上傳模塊配合 Prisma 保存文件元數(shù)據(jù)就會省去很多重復勞動。第二個方向是把權(quán)限模型做進 Router 中間件。tRPC 的中間件機制非常適合做 RBAC。你可以根據(jù)業(yè)務(wù)角色封裝出一套adminProcedure、editorProcedure把權(quán)限檢查從業(yè)務(wù)代碼里完全剝離出去。到這一步t3code 就從一個技術(shù)模板變成了貼合業(yè)務(wù)的開發(fā)框架。第三個方向是服務(wù)端渲染與 tRPC 的深度配合。t3code 的基礎(chǔ)演示大多集中在客戶端調(diào)用但在實際項目中很多頁面需要服務(wù)端直接取數(shù)首屏渲染或者利用 Next.js 的 Server Component 來做數(shù)據(jù)預取。tRPC 的服務(wù)端調(diào)用器和客戶端調(diào)用器可以在同一套類型體系下工作這意味著你在服務(wù)端可以直接調(diào)用喂給組件的預取數(shù)據(jù)不需要額外維護一套數(shù)據(jù)獲取方案。這塊做深之后整個應用的響應速度和首屏體驗都會明顯提升。5.2 關(guān)于 t3code 的幾句實在話根據(jù)我個人的使用經(jīng)驗t3code 這套技術(shù)組合并不適合所有場景。如果你的項目非常簡單只是一個展示頁加上一個聯(lián)系表單那完全沒有必要上 Prisma 和 tRPC直接用 Next.js 的 API Route 就夠了引入整條類型安全鏈路反而是負擔。如果你的項目業(yè)務(wù)邏輯復雜、接口數(shù)量多、團隊協(xié)作頻繁那 t3code 帶來的類型安全收益會非常可觀。前后端類型統(tǒng)一之后接口聯(lián)調(diào)的時間會大幅壓縮很多運行時的“字段不存在”“類型是 string 但實際是 number”這類問題會大幅減少。它不保證沒有 bug但確實能把一類特別難排查的集成 bug 在編譯期擋在門外。另外我想特別提一點t3code 這種模板的價值會隨著項目演進逐漸上升。剛開始你可能不覺得類型穿透有多重要但當項目到兩萬行代碼、幾十個 Router、十幾個頁面模型時你會慶幸每一次數(shù)據(jù)結(jié)構(gòu)的改動都有 TypeScript 替你兜底。那種“改一個字段所有引用點全部提示更新”的體驗值得你為它多花點時間把基礎(chǔ)架構(gòu)搭穩(wěn)。最后再說一個小技巧如果你打算把 t3code 長期用在團隊項目里一定要維護一份自定義的初始化文檔記錄你安裝依賴的版本、遇到過的問題、團隊約定的目錄職責甚至可以把初始化腳本固化下來。模板的意義在于縮短啟動時間而不是成為團隊里人人都不敢碰的“黑盒”。把這些過程沉淀下來新成員進入項目的成本會低很多整個團隊的開發(fā)效率也會真正受益。