ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

t3code:一套打通前后端共享类型的全栈TypeScript工程模板

t3code:一套打通前后端共享类型的全栈TypeScript工程模板 t3code 这个项目我一个人打磨了小半年。名字里的 t3 有两层意思一是 three-tier也就是经典的三层架构二是 third iteration因为这是我第三次推倒重来的版本。最开始只是一段写着玩的 API 代码后来慢慢长成了一个前后端共用一套 TypeScript 类型、结构清晰、可以直接拿来当脚手架的全栈工程模板。如果你写过几年业务代码一定经历过这种场景接口文档更新了前端还在用旧字段后端改了参数名前端编译不报错一运行全崩新同事接手项目光搞清楚某个数据从哪来、到哪里去就要花掉半天。这些都是典型的分层混乱问题。t3code 要解决的就是让前端代码、后端代码、共享类型三者真正打通用一套规则管理数据流转让错误在编码阶段暴露而不是留到线上。这个项目适合正在做全栈开发、准备从零搭建新项目脚手架、或者想理清前后端边界的朋友参考不需要有多深的架构功底但读完之后你对代码该放哪、类型该怎么管会有一个比较清晰的判断。1. 项目整体设计与思路拆解1.1 为什么做三层架构代码分家的真正意义很多人在谈到三层架构时第一反应是Controller、Service、DAO那套传统分层。t3code 里的三层不是这个过时概念而是按照运行环境和类型依赖方向拆分为前端层、后端层、共享层。前端层跑在浏览器里负责渲染和用户交互后端层跑在 Node 服务里负责处理请求、读写数据库共享层是一个不依赖任何运行时环境的纯 TypeScript 包专门存放前端和后端都需要的数据结构和类型定义。这套拆分的第一性原理是依赖方向前端只依赖共享层后端只依赖共享层共享层不依赖任何一层前后端之间永不直接引用对方的代码。很多项目代码混乱本质就是依赖方向被破坏了。比如一个前端组件直接 import 了 Express 的 Request 类型或者一个后端工具函数被前端模块引用短期内看起来方便但项目一旦变大依赖关系就变成一团乱麻改任何一处都会牵动全身。我把这种分家和租房做了一次类比共享层是公共水电管道前端层是用户住的客厅后端层是厨房和储物间。客人用户请求只进客厅做饭只能在厨房管道坏了大家一起受影响但你不能为了修水管把沙发搬进厨房。这个类比虽然简单但在跟团队同学解释项目结构时非常管用。1.2 技术选型背后的取舍为什么是 TypeScript pnpm Honot3code 的技术栈不算新潮但每一环都是对比过替代方案后沉淀下来的。核心选型如下语言层全链路 TypeScript严格模式开启strict: true是底线。包管理pnpm workspace支持将项目拆成多个独立 package同时保证共享依赖的软链接复用磁盘占用小、安装速度快。后端框架Hono。我最初用的是 Express后来换成了 Fastify最后锁定了 Hono。主要原因是它足够轻量类型推导自然同时又兼容多种运行时Node、Bun、Deno以后无论是迁移到 Serverless 还是 Edge 环境代码改动量都极小。前端框架React 18 Vite。Vite 的启动速度和 HMR 会让开发体验好非常多React 则保持团队招聘和上手的学习成本优势。数据库访问Prisma。它生成的 Client 自带类型在共享层可以直接复用 Prisma 生成的枚举和模型类型省去手写重复类型的时间。很多人在选型时容易走两个极端要么追求新框架的热度要么固守老框架的惯性。我的经验是这个项目的核心诉求是结构清晰、类型安全、易迁移所以每一环都优先考虑类型推导友好度和生态成熟度而不是单纯看 star 数量。1.3 目录结构与模块边界定义t3code 采用 monorepo 结构整个仓库看起来是这样的t3code/ ├── apps/ │ ├── web/ # 前端 React 应用 │ │ ├── src/ │ │ ├── package.json │ │ └── vite.config.ts │ └── api/ # 后端 Hono 应用 │ ├── src/ │ ├── package.json │ └── tsconfig.json ├── packages/ │ └── shared/ # 共享类型与工具库 │ ├── src/ │ │ ├── types/ # 所有跨端共享的类型定义 │ │ ├── schemas/ # zod 校验 schema │ │ └── utils/ # 纯函数工具 │ ├── index.ts │ └── package.json ├── package.json # workspace 根配置 ├── pnpm-workspace.yaml ├── tsconfig.base.json └── .eslintrc.cjs模块边界的核心原则可以概括为三条第一apps/web和apps/api互相看不见只能在package.json里声明依赖t3code/shared第二共享层里禁止出现react、express、hono等特定环境的依赖保证它能在任何环境运行第三共享层导出公共类型时必须做好命名收敛统一从入口文件导出避免使用深层路径引用。我在第一次迭代时没有给共享层单独的 package而是放了一个shared/目录结果代码里到处都是相对路径引用前端 import../../../shared/types既难看又容易出错。拆成独立 package 之后所有引用都变成t3code/shared清爽很多。这个调整看似只是路径变化实质上是从严格约束变成了物理隔离——monorepo 的 package 边界比目录约定要强得多因为在 CI 里可以直接检测依赖关系是否越界。2. 核心细节解析与实操要点2.1 共享层类型、校验、枚举的治理策略共享层是整个项目的基石它的设计质量直接决定前后端联调的顺畅程度。我在 t3code 里给共享层定了四个职责跨环境类型、请求/响应结构、校验规则、常量枚举。跨环境类型很好理解比如一个User对象前端列表展示需要id、name、avatar后端存储还需要passwordHash、createdAt、updatedAt。如果把完整的数据库模型直接暴露给前端存在两个问题一是字段冗余网络传了一堆前端用不到的数据二是安全问题万一密码哈希被传到前端风险极高。所以共享层采用 DTO 模式定义用户返回结构时用Omit工具类型import { User } from prisma/client; export type UserPublic OmitUser, passwordHash | emailVerified; export type UserProfile PickUser, id | name | avatarUrl | bio;校验规则统一放在共享层用 zod 定义 schema。这里有一个很容易踩的坑不要在前端只调用schema.parse()在后端又写一遍 if/else 手写校验那样等于维护了两套规则。正确做法是前后端都从共享层导入同一个 zod schema后端用schema.safeParse()做入参校验前端用schema.safeParse()做表单预校验。同一个源行为一致。比如用户注册的 schemaimport { z } from zod; export const registerSchema z.object({ email: z.string().email(邮箱格式不正确), password: z .string() .min(8, 密码至少 8 个字符) .regex(/[a-zA-Z]/, 密码需包含字母) .regex(/[0-9]/, 密码需包含数字), nickname: z.string().min(2, 昵称至少 2 个字符).max(20, 昵称最多 20 个字符), }); export type RegisterInput z.infertypeof registerSchema;枚举治理也值得单独说。业务里常见的状态字段比如订单状态、用户角色、任务状态我建议在共享层统一用as const定义然后用类型推导取联合类型而不是直接写个enum。原因是enum在编译后会产生运行时对象在跨包引用时容易出现不同副本问题而as const更纯粹export const ORDER_STATUS { PENDING: PENDING, PAID: PAID, SHIPPED: SHIPPED, COMPLETED: COMPLETED, CANCELLED: CANCELLED, } as const; export type OrderStatus (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];2.2 前端层接口封装、数据获取与状态管理前端层的设计核心是不要自己瞎猜数据。我见过太多前端代码直接fetch(/api/user)然后.then(res res.json())没有任何类型约束接口字段一变就是一场灾难。t3code 的前端对 API 的封装做了三层收敛第一层统一的 HTTP 客户端实例。基于 axios 或 fetch 封装配置基础 URL从环境变量读取、超时时间、请求拦截器注入鉴权 token、响应拦截器统一处理错误码。t3code 选用了 axios因为拦截器的生态和错误处理机制更成熟。第二层接口函数模块化。每个业务域对应一个 API 模块所有参数和返回类型都引用共享层类型import { client } from /lib/client; import type { UserProfile } from t3code/shared; export const userApi { async getProfile(userId: string): PromiseUserProfile { const { data } await client.get(/api/users/${userId}); return data; }, };第三层数据请求管理使用 TanStack Query。它不只是简单的数据请求库更是一个完整的服务端状态管理方案内置了缓存、重试、失效刷新、乐观更新。在封装接口函数后页面里通过useQuery/useMutation调用配合在共享层定义好的类型几乎可以做到前端所有数据流都有据可依。状态管理我推荐精兵简政。t3code 里我保留了 Zustand但只用于全局 UI 状态主题、侧边栏折叠状态、全局通知用户数据一律走 TanStack Query 的缓存。这么做的好处是职责划分清晰服务器状态有生命周期、需要异步更新交给 Query纯客户端状态是即时、同步的交给 Zustand。如果把用户信息放在 Zustand 里你还要手写同步、刷新、失效逻辑纯属给自己加戏。2.3 后端层规范校验、错误处理与响应结构后端层在 t3code 里只是一层薄薄的中间层接收请求后做入参校验调用业务函数处理数据返回统一结构。这部分最容易犯错的是响应结构不一致。有的接口返回{ code: 0, data: ... }有的接口失败时返回字符串前端处理起来需要各种防御式判断。我统一了所有接口的响应结构export type ApiResponseT | { success: true; data: T; message?: string } | { success: false; error: string; detail?: unknown };后端在错误处理中间件里统一捕获异常。采用特定异常抛错 兜底中间件转换的双层模式。业务代码里该抛就抛不该在业务层写一堆 try/catch 包裹最后兜底中间件把错误转换成统一格式返回。app.onError((err, c) { const requestId c.get(requestId); if (err instanceof ZodError) { return c.json( { success: false, error: 参数校验失败, detail: err.flatten().fieldErrors }, 400 ); } logger.error({ err, requestId }, unhandled error); return c.json({ success: false, error: 服务器内部错误 }, 500); });校验流程上我用了 zod 的safeParse并没有使用 Hono 内置的 validator原因是 Hono 的 validator 在类型推导到 Handler 时有一些绕不过去的泛型设计如果项目里需要非常精确的入参类型直接用 zodsafeParse更可控const parsed registerSchema.safeParse(c.get(body)); if (!parsed.success) { return c.json({ success: false, error: 参数校验失败, detail: parsed.error.flatten().fieldErrors }, 400); } const body parsed.data; // 已被推导为 RegisterInput这样做的额外好处是除了 HTTP 接口以后如果新增了 WebSocket 消息处理或者内部事件队列消费函数可以用同一套 schema 对消息体做校验而不再为每种入口写重复的校验规则。3. 实操过程与核心环节实现3.1 初始化 monorepo 工作区与基础配置搭建 t3code 的第一步是初始化 pnpm workspace。根目录下创建pnpm-workspace.yamlpackages: - apps/* - packages/*然后创建根目录package.json关键脚本和依赖管理{ name: t3code, private: true, scripts: { dev: pnpm --parallel -r dev, build: pnpm -r build, lint: pnpm -r lint, typecheck: pnpm -r typecheck }, devDependencies: { typescript: ^5.4.5, eslint: ^8.57.0, prettier: ^3.3.0 } }dev脚本用--parallel同时启动前后端。这里有一个容易忽略的细节root 级依赖和子包依赖要分开管理。TypeScript、ESLint、Prettier 这类统一工具链放根目录各子包只放自己的运行依赖避免出现前端用某个 eslint 版本、后端用另一个 eslint 版本的分裂情况。再创建tsconfig.base.json作为公共基础配置{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, lib: [ES2022], strict: true, skipLibCheck: true, esModuleInterop: true, declaration: true, forceConsistentCasingInFileNames: true } }skipLibCheck: true必须开否则第三方的 .d.ts 类型冲突会浪费你大量时间。strict: true不能关这是类型安全的根基。moduleResolution: Bundler是为了兼容 Vite 的处理方式如果用 Node16 或 Classic很多 ts 文件里的路径解析会出问题。3.2 共享包打包配置与引用方式共享包要同时被前端和后端引用所以它的打包方式需要特别注意。我使用了 tsup 作为打包工具因为它基于 esbuild速度快配置极简天然支持多入口和类型声明文件输出。先创建packages/shared/package.json{ name: t3code/shared, version: 0.1.0, main: ./dist/index.cjs, module: ./dist/index.js, types: ./dist/index.d.ts, exports: { .: { import: ./dist/index.js, require: ./dist/index.cjs, types: ./dist/index.d.ts } }, files: [dist], scripts: { build: tsup, dev: tsup --watch } }这里需要解释exports字段的作用。它约束了外部包只能从t3code/shared入口导入无法直接引用内部的深层目录从物理上防止t3code/shared/src/types/user这种越界路径出现。我见过不少项目因为没配 exports共享代码被人从奇怪路径 import后面重构时牵一发动全身。tsup.config.ts配置import { defineConfig } from tsup; export default defineConfig({ entry: [src/index.ts], format: [esm, cjs], dts: true, sourcemap: true, clean: true, });提供 ES Module 和 CommonJS 两种格式是因为后端如果跑在 Node 环境下且没有开启 ESM 模式可能需要 require前端和 Vite 则偏好 ESM双向兼容最稳。开发时运行pnpm --filter t3code/shared dev开启 watch 模式代码改动会实时编译。3.3 后端层完整实现链路Hono 的入口apps/api/src/index.ts我保持了极简import { serve } from hono/node-server; import { Hono } from hono; import { cors } from hono/cors; import { logger } from ./middleware/logger; import { onError } from ./middleware/error; import { authRouter } from ./routes/auth; import { userRouter } from ./routes/user; const app new Hono(); app.use(*, cors()); app.use(*, logger()); app.route(/api/auth, authRouter); app.route(/api/users, userRouter); app.onError(onError); const port Number(process.env.PORT || 3001); serve({ fetch: app.fetch, port }, () { console.log(API server started on port ${port}); });用户路由示例演示了共享类型和 zod 校验如何接入import { Hono } from hono; import { z } from zod; import { updateProfileSchema } from t3code/shared; export const userRouter new Hono() .get(/:id, async (c) { const { id } c.req.param(); // 从数据库查询用户使用 Prisma 生成的类型 const profile await prisma.user.findUnique({ where: { id }, select: { id: true, name: true, avatarUrl: true, bio: true }, }); if (!profile) { return c.json({ success: false, error: 用户不存在 }, 404); } return c.json({ success: true, data: profile }); }) .put(/:id, async (c) { const { id } c.req.param(); // 简化处理假设 auth 中间件已经注入当前用户 id if (id ! c.get(userId)) { return c.json({ success: false, error: 无权修改他人资料 }, 403); } const body await c.req.json(); const parsed updateProfileSchema.safeParse(body); if (!parsed.success) { return c.json( { success: false, error: 参数校验失败, detail: parsed.error.flatten().fieldErrors }, 400 ); } const updated await prisma.user.update({ where: { id }, data: parsed.data, }); // 注意不要直接把 Prisma 返回的完整对象返回前端这个对象里有 passwordHash const { passwordHash, emailVerified, ...publicUser } updated; return c.json({ success: true, data: publicUser }); });这里有一个非常重要的安全细节Prisma 查询结果直接返回给前端会泄漏敏感字段。t3code 里我统一要求select字段必须显式列出绝对禁止findUnique后不 select 直接返回。第二种安全措施是在响应前做一次 DTO 映射只保留公共字段。这个习惯帮我躲过了不止一次线上事故。3.4 前端从首页渲染到完整请求闭环前端层使用 Vite React 搭建。apps/web/vite.config.ts里需要配置共享包的解析别名避免构建时因符号链接导致热更新失效import { defineConfig } from vite; import react from vitejs/plugin-react; import path from path; export default defineConfig({ plugins: [react()], resolve: { alias: { : path.resolve(__dirname, src), t3code/shared: path.resolve(__dirname, ../../packages/shared/src), }, }, server: { port: 5173, proxy: { /api: { target: http://localhost:3001, changeOrigin: true, }, }, }, });开发环境代理很顺手让前端请求走同域/api省去跨域配置的麻烦。正式部署时用 Nginx 或者网关做类似转发即可。页面组件从 API 拿数据的最小闭环import { useQuery } from tanstack/react-query; import { userApi } from /api/user; import type { UserProfile } from t3code/shared; function UserProfilePage({ userId }: { userId: string }) { const { data, isLoading, isError, error } useQuery({ queryKey: [user, userId], queryFn: () userApi.getProfile(userId), }); if (isLoading) return div加载中.../div; if (isError) return div加载失败: {error.message}/div; if (!data) return null; return ( div h2{data.name}/h2 p{data.bio}/p {data.avatarUrl img src{data.avatarUrl} alt用户头像 /} /div ); }整个过程里没有一处手写类型断言。因为userApi.getProfile的返回类型已经是UserProfileTanStack Query 会自动推导data的类型。你如果用了不存在的字段VSCode 会直接标红这就把问题拦截在了编译之前。4. 常见问题与排查技巧实录4.1 TypeScript 类型推导失效的三种典型场景我在开发 t3code 和日常工作中整理了几个最常遇到的类型问题。第一个是 zod 的z.infer在跨包引用时类型不同步。这是因为共享包在 watch 模式下编译出了新的 .d.ts但是前端或后端进程里的依赖缓存还没刷新。解决方法是先确认共享包确实重新编译了再重启前端/后端 dev server。如果还不行删掉根目录下的node_modules/.vite缓存和tsconfig.tsbuildinfo增量缓存。这里不建议开启tsc --watch的增量编译 monorepo 引用模式组合容易在共享包重新编译后不触发下游重载。第二个是 Prisma 生成的类型没有及时更新。每次修改schema.prisma必须重新执行npx prisma generate。这步骤容易漏一旦漏了所有引用prisma/client类型的地方都会报错。我建议在 shared 包的 postinstall 脚本里加上prisma generate或者至少把这条命令写进 README 顶部加大加粗。我在团队里就要求所有人改 model 之后必须跑 generate否则无法通过 code review。第三是没有用satisfies导致字面量类型被放大。比如一个配置对象const theme { primary: #333 }被推导成{ primary: string }而不是#333这个字面量类型。用satisfies可以既校验类型又保留字面量export const themeConfig { primary: #333, secondary: #666, } as const satisfies Recordstring, string;4.2 共享包构建后未重新编译前后端拿到旧类型这是 monorepo 里最常见的联调痛点。表现是明明在共享层加了一个新类型或新 schema前端项目里却一直报错说找不到导出。排查步骤按顺序走查看packages/shared/dist目录里是否生成新文件。没有的话说明tsupwatch 没生效手动跑一次pnpm --filter t3code/shared build。重新构建后暂停前端 dev server 再重启。Vite 对 node_modules 里 alias 的依赖预构建缓存可能在作怪。检查packages/shared/package.json的exports字段是否把新建的文件排除在导出范围外。如果只导出了入口那么新增的类型必须从入口重新导出。最后删除根目录node_modules/.vite缓存目录再启动。这能解决 90% 的前端怎么总是拿到旧版本问题。4.3 前后端类型不同步接口返回和共享类型对不上这个问题在大多数项目里是信仰问题——大家都说要对齐实际没人维护。t3code 的做法不是靠自觉而是建立了两道检查机制第一道检查在 CI 里运行全量typecheck统一执行pnpm typecheck脚本。任何一方代码改了共享类型但另一方还在用旧类型只要代码里出现了不被允许的字段访问或参数类型不匹配类型检查就会直接失败。第二道检查接口测试里加入运行时响应结构断言。用 zod schema 对接口响应做safeParse一旦运行时数据和开发时的类型定义不一致测试会在 CI 上报红。这种方式把类型的静态检查和运行时的动态校验结合起来实现真正的双保险。举一个真实例子后端团队把用户bio字段允许的最大长度从 20 改到 50MySQL 里的字段类型从VARCHAR(20)改成VARCHAR(50)但共享层的 zod schema 没更新。前端用updateProfileSchema提交一个 40 字符的 bio后端的safeParse会通过因为校验规则还是旧的但实际上数据库已经支持更长了可是如果用我上面说的响应断言来检查后端返回的数据对象并不会报错。最后会发现问题是出在前端的 UI 限制文案和 schema 不一致这个例子告诉我们改共享 schema 时所有周边限制比如前端 maxLength 输入框限制都要一起查。我只吃过一次这种亏后来就养成了共享层 schema 变更必须全仓库搜索所有相关引用的习惯。4.4 构建优化与启动提速技巧t3code 在优化构建体验上踩过几个值得一提的坑。第一个是共享包体积失控。tsup默认会打包成单个文件但如果依赖了 zod 和 Prisma 的完整实现产物可能超过几百 KB。t3code 的共享包专门抽了两个入口schema入口供后端校验使用types入口只导出类型这样前端如果只用到类型可以直接 import 类型入口而不把 zod 的运行时逻辑打进去。通过按需导入和 tree-shaking前端总包体积显著下降。启动提速方面前后端同时 dev 的时候最容易卡的是共享包的 watch 编译。后来我调整了方案给共享包开启tsup --watch --dts-watch这样类型声明文件会单独监听前端 dev server 不需要频繁重启。另外 Vite 的optimizeDeps.exclude把t3code/shared排除掉让它走原生 ESM 而不是预打包改动实时生效问题就基本消失了。4.5 问题排查速查表现象可能原因优先排查项前端编译报错找不到t3code/shared的导出共享包未重新构建 / exports 配置缺失检查 dist 目录、package.json 的 exports 字段共享类型修改后前端不热更新Vite 预构建缓存删除 node_modules/.vite 后重启后端收到的请求体类型不是预期zod schema 未更新检查两个端引用的 schema 版本是否一致Prisma 客户端类型报错prisma generate 未重新执行重新执行npx prisma generateAPI 返回字段包含敏感字段查询时未显式 select代码 review 时强制要求 select 白名单编译慢、watch 频繁触发共享包打包配置不够细粒度拆类型入口与 schema 入口并启用 --dts-watch4.6 代码审查与提交规范配置t3code 的仓库规范性还依赖 git 钩子。我在根目录配置了 husky 和 lint-staged在每次提交前自动执行 lint 和类型检查。这里有个细节只在 pre-commit 里跑 lint-staged不要跑全量 typecheck否则大项目提交一次要等几十秒。全量 typecheck 放在 CI 里跑本地只检查暂存区文件。package.json里增加{ lint-staged: { *.{ts,tsx}: [eslint --fix, prettier --write], *.{json,md,yaml}: [prettier --write] } }husky 的 pre-commit 钩子这样写pnpm exec lint-staged这样一个几百行的 commit 只需要几秒不会影响日常开发节奏。常见疑问集中回答5.1 t3code 能直接用在自己的业务项目里吗可以但要有心理准备它不预设任何业务模块只提供架构骨架和工程规范。你要把业务代码按照前端只依赖共享层、后端只依赖共享层的边界去填充。花半天时间理清楚自身项目的领域模型后面开发新需求的效率会有非常明显的提升。5.2 如果团队里只有我一个人用 TypeScript其他人用 JS还能用这套结构吗可以但有代价。团队里有 JS 成员时共享层的类型安全会不断被绕过比如有人用any把入参类型擦掉。建议在 CI 里开启strict检查的同时禁用any的显式使用typescript-eslint/no-explicit-any。如果团队决定全面引入 TS最好从新项目开始执行这套结构而不是在存量老项目里生硬地嵌套进去。5.3 这套结构对后端性能有影响吗影响可以忽略不计。共享层的 zod 校验在常规业务接口中耗时典型在微秒到几十微秒级别相比数据库查询和网络传输可以忽略。重点是把校验逻辑保持在必要范围内不要写无意义的复杂正则和嵌套循环。实际项目中我用一套包含 20 个字段的大 schema 验证请求体p99 增加时间也只有不到 1ms完全不影响服务。5.4 数据库字段变了老数据怎么办这个问题和 t3code 本身不冲突但实践中要考虑。共享层的 schema 变更属于 breaking change如果老数据不符合新 schema后端校验会被卡住。稳妥做法是schema 变更和数据库迁移放到一起迁移脚本里处理历史数据再调整共享类型最后前后端同步发版。t3code 面向的基本是新项目或者有规范迁移流程的项目不太适合带病运行的老系统。5.5 前端一定要用 React 吗不一定。t3code 的前端层也可以替换成 Vue、Svelte 或者纯 Web Components因为核心的共享层 前端层 后端层边界不变React 只是我当时顺手的选择。替换前端框架时只需要重建一层api封装和页面组件共享层的类型和校验可以原封不动地保留。这本身就是分层架构的优势。6. 一些个人后续实践建议这里想分享几个我认为对日常开发有长远价值的小细节。第一养成改共享层即改文档的习惯。每当我新增或修改共享层里的一个类型、一个 schema我都会顺手更新 README 里对应的说明段落不需要长篇大论一句话即可。这样可以避免三个月之后自己看着类型定义也猜不出当初的设计意图。比如我在 README 里会标注ORDER_STATUS 的 CANCELLED 状态不能被更改为 PAID这样一句话比写一百行注释都精准。第二定期检查前后端接口的类型覆盖情况。我后来给 t3code 加了一个小脚本扫描前端所有useQuery的queryFn返回类型再和后端的接口文档对比找出还未接入类型的孤岛接口。原理非常简单就是 AST 解析和数据表对比但效果非常明显它让我在新功能开发时始终清楚哪些接口是类型裸奔状态。第三把端到端类型安全当作质量指标。很多项目上了 TypeScript但只在局部用类型并没有把前后端链路彻底打通。t3code 的思路就是这一件事让类型从数据库一路流到页面组件中间不做任何断点。一旦做到代码里的防御性判断会少很多因为类型系统已经替你挡了大部分低级错误。最后再分享一个我自己的经验总结新项目铺架构时最难的不是写代码而是忍住不绕过约束。比如有时候为了快速交付在共享层里临时 import 了一个 react 状态库、在后端路由里直接引入了一个前端工具函数——这种快速交付的代价是架构信任的崩塌。t3code 里我给自己留了一条规则任何跨层依赖都必须以 package 级别的依赖声明存在禁止相对路径越界。严格执行这条规矩之后重构的底气就完全不一样了。这个项目后续我还会继续在边缘部署、多数据库适配这些方向做扩展但核心的分层与类型安全思路已经帮我把最麻烦的架构问题钉死在了早期设计里。
返回列表