
1. 从“t3code”这个名字说起它到底是什么第一次看到“t3code”这个词很多人会下意识地把它当成某个开源库、某个命令行工具或者某个小众框架的缩写。我在几个技术群里也见过类似的讨论有人猜是“TypeScript 3 Code”的简写有人觉得是某个代码生成器的代号还有人把它和某些低代码平台联系在一起。实际上如果你去翻一翻近两年的开发者社区讨论会发现“t3code”更多时候是以一种“项目代号”或者“个人工具集”的身份出现的——它不是一个官方标准也没有一个统一的定义而是被不同的人用来指代各自那套“围绕代码生成、代码转换、代码模板”的小型工程实践。我最早接触这个词是在一个前端工程化的内部分享里。当时一位做中后台系统的朋友提到他们团队内部把一套“从接口定义自动生成前端请求代码和类型声明”的脚本集合叫做 t3code。后来我又陆续看到几种不同的用法有人用它指代“把设计稿转成代码”的尝试有人用它命名自己写的“多语言代码片段管理工具”还有人干脆把它当成一个“代码模板仓库”的代号。这种模糊性其实很有意思它说明“t3code”背后真正吸引人的不是某个具体产品而是一个共性的需求如何让代码的生成、转换和复用变得更自动、更可控、更贴合团队自己的习惯。所以这篇文章我不打算去考证“t3code”的官方定义而是把它当作一个“引子”来聊一聊这类以“代码”为核心对象的轻量级工程实践。它适合那些已经写过一些脚本、做过一些模板、但总觉得不够系统的人也适合刚入行不久、想了解“代码生成”到底能解决什么问题的朋友。我会从整体设计思路、核心细节、实操过程、常见问题几个角度展开尽量把我在实际项目里踩过的坑和总结出来的经验都写进去。你不需要有很深的编译原理背景只要写过业务代码就能看懂。2. 整体设计与思路拆解为什么是“生成”而不是“手写”2.1 核心需求重复代码的边际成本太高任何一个稍微大一点的项目都会出现大量结构相似的代码。比如前端要写几十个接口请求函数每个函数的差别只是 URL、请求方法和参数类型后端要写几十个 CRUD 接口每个接口的差别只是实体名和字段甚至写文档、写测试用例也有大量重复的骨架。这些代码单看每一段都不难但加起来就会消耗大量时间而且容易出错——复制粘贴的时候漏改一个字段名或者参数类型写错都是很常见的事。t3code 这类实践的核心目标就是把这些“有规律但重复”的代码从“手写”变成“生成”。这里的“生成”不一定是复杂的代码生成器也可以是一个简单的脚本、一个模板文件、甚至是一组编辑器 snippet。关键在于把变化的量抽出来把不变的结构固定下来。这样你只需要维护一份“源头定义”就能批量产出符合规范的代码。我见过很多团队一开始是靠“复制粘贴 全局替换”来应付的短期看确实快但一旦源头定义变了比如接口字段调整了你就得把所有生成过的代码再改一遍。而如果一开始就用生成的方式改源头、重新生成几分钟就能同步所有地方。这个账其实很好算假设你有 50 个接口每次改动平均涉及 3 个文件手写同步一次大概要半小时一周改两次就是 1 小时而写一个生成脚本可能只要 2 小时之后每次同步只要几秒钟。只要项目周期超过一个月生成方案就明显更划算。2.2 方案选型模板驱动 vs AST 驱动在具体实现上t3code 这类实践通常有两条路线模板驱动和AST 驱动。模板驱动就是准备一个代码模板文件里面用占位符表示可变部分然后用数据去填充占位符生成最终代码。AST 驱动则是先解析现有代码构建抽象语法树再对树进行修改或生成最后输出代码。两者各有优劣选择哪个取决于你的具体场景。模板驱动的优点是上手快、门槛低。你不需要懂编译器原理只要会写字符串替换就行。比如一个简单的请求函数模板export function ${functionName}(${params}) { return request({ url: ${url}, method: ${method}, data: ${data} }); }然后用一个 JSON 文件描述每个接口的 functionName、url、method、params循环渲染就能生成所有请求函数。这种方式的缺点是灵活性有限如果生成逻辑很复杂模板里会塞满条件判断可读性会变差。而且模板生成的代码格式往往不够理想需要额外做格式化。AST 驱动的优点是精确、灵活。你可以精确控制每个节点的生成生成的代码格式也更好。但缺点是门槛高需要熟悉解析器比如 Babel、TypeScript Compiler API、Tree-sitter 等调试也更麻烦。我个人的经验是如果只是生成新代码模板驱动足够如果需要修改现有代码或者生成逻辑涉及复杂的类型推导AST 驱动更合适。很多团队会混合使用用 AST 解析接口定义提取出结构化数据再用模板生成代码。这样既利用了 AST 的精确性又保持了模板的简单性。2.3 数据来源接口定义、数据库 Schema 还是设计稿生成代码的前提是有“源头数据”。t3code 类项目常见的数据来源有三种接口定义文件如 OpenAPI/Swagger、GraphQL Schema、Proto 文件、数据库 Schema如 SQL 建表语句、ORM 模型定义、设计稿如 Figma、Sketch 的 JSON 导出。不同的数据来源对应的生成目标和难度也不同。接口定义是最常见的来源。OpenAPI 和 GraphQL 都有结构化的描述文件解析起来相对容易。你可以从这些文件里提取出路径、方法、参数、响应类型然后生成前端请求代码、后端 Controller 骨架、甚至接口文档。数据库 Schema 则适合生成后端 CRUD 代码、实体类、迁移脚本。设计稿转代码难度最大因为设计稿里的信息往往不够精确需要大量人工干预目前更多是辅助而不是全自动。我建议刚开始做的时候优先选择结构化程度最高的数据源。比如你们团队已经在用 OpenAPI 描述接口那就从它入手不要一上来就挑战设计稿转代码。先把一个场景跑通再逐步扩展。另外数据源的质量很关键如果接口定义本身就不完整、不规范生成出来的代码也会有问题。所以在生成之前最好先做一轮数据校验把缺失的字段、不一致的类型都处理掉。2.4 输出目标代码、类型、文档还是测试t3code 类实践的输出目标也很多样。最常见的是生成业务代码比如请求函数、Controller、Service。其次是生成类型声明比如 TypeScript 的 interface、type或者后端的 DTO。还有生成文档的比如把接口定义转成 Markdown 或 HTML 文档。生成测试用例的也有比如根据接口定义生成基础的单元测试骨架。我的建议是不要贪多先聚焦一个输出目标。很多团队一开始想“既然都解析了接口定义那就把代码、类型、文档、测试全生成了吧”结果每个都做了一点每个都不够好用。更好的做法是先选一个最痛的点比如“前端请求函数手写太麻烦”就只生成请求函数和对应的类型声明。等这个流程稳定了再考虑扩展到文档和测试。这样每一步都有明确的收益也更容易获得团队认可。3. 核心细节解析与实操要点从数据到代码的关键环节3.1 数据解析把非结构化变成结构化不管数据源是什么格式第一步都是把它解析成程序能处理的结构化数据。以 OpenAPI 为例你可以用现成的解析库比如swagger-parser、openapi-types也可以自己写一个简单的解析器。解析的目标是提取出每个接口的路径、HTTP 方法、请求参数路径参数、查询参数、请求体、响应类型、接口描述。这些信息会作为后续生成代码的输入。这里有个细节很容易被忽略参数的类型映射。OpenAPI 里的类型是 JSON Schema 类型比如string、integer、boolean、array、object而你要生成的代码可能是 TypeScript、Java、Python需要做类型转换。比如 OpenAPI 的integer在 TypeScript 里是number在 Java 里可能是int或long在 Python 里是int。如果类型映射做错了生成的代码就会报错。我建议把类型映射单独抽成一个配置文件方便调整和扩展。另一个细节是命名规范。接口定义里的字段名可能是下划线风格user_name而生成的代码可能需要驼峰风格userName。你需要一个命名转换函数把不同风格的名称统一成目标语言的习惯。这个函数看起来简单但实际写起来要考虑很多边界情况比如连续下划线、数字开头、保留字冲突等。我一般会准备一个toCamelCase、toPascalCase、toSnakeCase的工具函数在生成前统一处理。3.2 模板设计让生成的代码像人写的模板是生成代码的“模具”它的质量直接决定了生成代码的可读性。一个好的模板应该满足几个条件结构清晰、占位符明确、易于维护。我见过一些模板里面塞满了if-else和循环读起来比生成的代码还费劲。这种模板虽然能工作但维护成本很高一旦需求变化改起来很痛苦。我的经验是把复杂的逻辑从模板里抽出来放到数据预处理阶段。比如如果某个接口需要特殊处理不要直接在模板里写if (interfaceName xxx)而是在解析数据的时候就把这个接口标记出来生成时用统一的方式处理。这样模板里只有简单的占位符替换逻辑都集中在数据层更容易测试和调试。另外模板的格式也很重要。生成的代码最好能直接通过项目的 lint 检查不需要手动格式化。你可以在生成之后调用 Prettier、ESLint 或 gofmt 等工具做一次格式化。这样生成的代码就能和手写代码保持一致减少 review 时的摩擦。我一般会在生成脚本的最后加一步prettier --write确保输出的代码风格统一。3.3 生成策略全量生成还是增量生成生成代码的时候有一个策略选择全量生成还是增量生成。全量生成就是每次根据数据源重新生成所有文件覆盖旧文件。增量生成则是只生成有变化的文件保留手动修改过的文件。两者各有适用场景。全量生成的优点是简单、一致不会出现“生成代码和源头定义不一致”的情况。缺点是如果生成的文件被手动修改过重新生成会覆盖掉这些修改。所以全量生成适合那些“完全由生成器控制”的文件比如纯请求函数、纯类型声明。增量生成则适合那些“生成后还需要手动补充”的文件比如 Controller 骨架生成后可能还要加业务逻辑。增量生成需要记录生成状态实现起来更复杂但能避免覆盖人工修改。我一般会采用混合策略对于纯生成的代码用全量生成并在文件头加一个注释“此文件由 t3code 自动生成请勿手动修改”对于需要人工补充的代码用增量生成只在文件不存在时生成或者用特殊的标记区分生成区域和手动区域。比如// t3code-generated export function getUser() { ... } // /t3code-generated // 以下为手动补充代码这样重新生成时只替换标记之间的内容手动代码不受影响。这个技巧在实际项目中非常实用可以大大减少“生成覆盖手动修改”的烦恼。3.4 版本管理生成代码要不要提交到仓库这是一个经常被讨论的问题生成的代码要不要提交到 Git 仓库我的观点是看情况但大多数时候建议提交。不提交的理由是“生成代码是衍生物不应该进版本库”听起来很合理但实际执行起来会有问题。比如新同事拉下代码后需要先跑一遍生成脚本才能启动项目增加了上手成本CI 环境也需要额外配置生成步骤如果生成脚本依赖的数据源在另一个仓库还可能因为权限问题拉不到。提交生成代码的好处是开箱即用减少环境依赖。新同事 clone 下来就能跑CI 也不需要特殊配置。缺点是每次修改数据源后都要重新生成并提交仓库里会有一些“看起来像手写但其实是生成”的代码。为了减少混淆我建议在生成的文件头加注释说明并且在 README 里写清楚哪些目录是生成的、如何重新生成。这样既方便了日常开发也保留了可追溯性。如果你们团队坚持不提交生成代码那至少要保证生成脚本足够稳定、足够快并且有明确的文档说明。另外可以在 CI 里加一个检查如果生成代码和源头定义不一致就报错提醒。这样能避免“有人改了源头但忘了重新生成”的情况。4. 实操过程与核心环节实现一个可复现的 t3code 小项目4.1 环境准备与依赖安装下面我以一个具体的例子来演示 t3code 类项目的完整实现。假设我们有一个 OpenAPI 描述文件api.yaml目标是生成 TypeScript 的请求函数和类型声明。这个例子足够简单你可以跟着一步步做也可以根据自己的需求调整。首先准备环境。你需要 Node.js建议 18 以上和 npm。然后创建一个新目录初始化项目mkdir t3code-demo cd t3code-demo npm init -y npm install js-yaml swagger-parser prettier --save-dev这里用了三个依赖js-yaml用来解析 YAML 格式的 OpenAPI 文件swagger-parser用来校验和解析 OpenAPI 结构prettier用来格式化生成的代码。如果你用的是 JSON 格式的 OpenAPI可以不用js-yaml。swagger-parser的好处是它会帮你处理$ref引用把嵌套的定义展开省去很多手动处理的麻烦。接下来准备一个简单的api.yamlopenapi: 3.0.0 info: title: Demo API version: 1.0.0 paths: /users: get: operationId: getUsers summary: 获取用户列表 parameters: - name: page in: query schema: type: integer responses: 200: description: 成功 content: application/json: schema: type: array items: $ref: #/components/schemas/User /users/{id}: get: operationId: getUserById summary: 获取单个用户 parameters: - name: id in: path required: true schema: type: string responses: 200: description: 成功 content: application/json: schema: $ref: #/components/schemas/User components: schemas: User: type: object properties: id: type: string name: type: string age: type: integer这个文件描述了两个接口获取用户列表和获取单个用户。每个接口有 operationId、参数和响应类型。我们的目标是根据这个文件生成对应的 TypeScript 代码。4.2 解析 OpenAPI 并提取关键信息接下来写解析脚本。创建一个generate.js文件const SwaggerParser require(swagger-parser); const fs require(fs); const path require(path); async function parseApi(filePath) { const api await SwaggerParser.dereference(filePath); const operations []; for (const [route, methods] of Object.entries(api.paths)) { for (const [method, operation] of Object.entries(methods)) { if (method parameters) continue; operations.push({ operationId: operation.operationId, method: method.toUpperCase(), route, summary: operation.summary || , parameters: operation.parameters || [], responseSchema: extractResponseSchema(operation), }); } } return { operations, schemas: api.components?.schemas || {} }; } function extractResponseSchema(operation) { const successResponse operation.responses?.[200] || operation.responses?.[201]; if (!successResponse) return null; const content successResponse.content?.[application/json]; return content?.schema || null; }这段代码做了几件事用SwaggerParser.dereference解析并展开引用遍历所有路径和方法提取出 operationId、HTTP 方法、路由、参数、响应 schema。dereference会把$ref替换成实际的定义这样后面处理起来就不用再关心引用了。这里有个细节operation.responses里的状态码可能是字符串200也可能是数字200不同版本的 OpenAPI 写法不一样。我一般会同时检查200和200或者用Object.keys遍历找到第一个 2xx 的响应。另外有些接口可能返回 204无内容这时候没有 content需要特殊处理。4.3 生成 TypeScript 类型声明有了结构化的数据接下来生成类型声明。我们先处理components.schemas里的每个 schema把它转成 TypeScript 的 interface。写一个generateTypes函数function mapType(schema) { if (!schema) return any; if (schema.type string) return string; if (schema.type integer || schema.type number) return number; if (schema.type boolean) return boolean; if (schema.type array) return ${mapType(schema.items)}[]; if (schema.$ref) { const name schema.$ref.split(/).pop(); return name; } if (schema.type object schema.properties) { const props Object.entries(schema.properties) .map(([key, value]) ${key}: ${mapType(value)};) .join(\n); return {\n${props}\n}; } return any; } function generateTypes(schemas) { const lines []; for (const [name, schema] of Object.entries(schemas)) { lines.push(export interface ${name} {); for (const [prop, propSchema] of Object.entries(schema.properties || {})) { lines.push( ${prop}: ${mapType(propSchema)};); } lines.push(}); lines.push(); } return lines.join(\n); }这个mapType函数处理了基本类型、数组、对象和引用。实际项目中你可能还需要处理enum、oneOf、allOf、nullable等情况。我建议一开始只支持最常见的几种遇到不支持的就在生成日志里打警告后续再逐步补充。不要试图一次性覆盖所有 OpenAPI 特性那样会陷入无穷无尽的边界情况。4.4 生成请求函数类型声明生成之后接着生成请求函数。每个 operation 对应一个函数函数名用 operationId参数根据 parameters 生成返回值类型根据 responseSchema 生成。写一个generateRequests函数function generateRequests(operations) { const lines []; lines.push(import request from /utils/request;); lines.push(); for (const op of operations) { const params op.parameters.map((p) { const type mapType(p.schema); const optional p.required ? : ?; return ${p.name}${optional}: ${type}; }); const returnType op.responseSchema ? mapType(op.responseSchema) : void; const paramStr params.length ? params: { ${params.join(; )} } : ; lines.push(export function ${op.operationId}(${paramStr}): Promise${returnType} {); lines.push( return request({); lines.push( url: \${op.route.replace(/{(\w)}/g, ${$1})}\,); lines.push( method: ${op.method},); if (params.length) { lines.push( params,); } lines.push( });); lines.push(}); lines.push(); } return lines.join(\n); }这里有几个细节值得说明。第一路由里的路径参数{id}需要转成模板字符串${id}我用了一个正则替换。第二参数统一放在一个params对象里这样调用时更清晰。第三返回值类型用PromiseReturnType假设request返回的是 Promise。实际项目中你可能需要根据不同的请求方法决定参数放在params还是data里这里为了简化统一用了params。4.5 整合与格式化输出最后把类型声明和请求函数整合起来写入文件并用 Prettier 格式化async function main() { const { operations, schemas } await parseApi(./api.yaml); const typesContent generateTypes(schemas); const requestsContent generateRequests(operations); const outputDir path.join(__dirname, src, api); fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(path.join(outputDir, types.ts), typesContent); fs.writeFileSync(path.join(outputDir, requests.ts), requestsContent); console.log(Generated ${operations.length} requests and ${Object.keys(schemas).length} types.); } main().catch((err) { console.error(err); process.exit(1); });运行node generate.js你会在src/api目录下看到types.ts和requests.ts。生成的types.ts大概是这样export interface User { id: string; name: string; age: number; }requests.ts大概是这样import request from /utils/request; export function getUsers(params: { page?: number }): PromiseUser[] { return request({ url: /users, method: GET, params, }); } export function getUserById(params: { id: string }): PromiseUser { return request({ url: /users/${id}, method: GET, params, }); }到这里一个最基础的 t3code 流程就跑通了。你可以把它扩展成支持更多特性比如生成 React Query 的 hooks、生成 Mock 数据、生成接口文档等。关键是把核心流程跑通然后再逐步迭代。5. 常见问题与排查技巧实录那些文档里不会写的事5.1 生成代码和手写代码冲突怎么办这是最常见的问题。你生成了一个文件然后手动改了几行下次重新生成时改动被覆盖了。解决思路有三种一是把生成和手写分离生成的文件只包含纯生成内容手写内容放到另一个文件里通过继承或组合的方式使用。比如生成的UserServiceBase只包含 CRUD 方法手写的UserService继承它并添加业务逻辑。二是用标记区分生成区域和手动区域重新生成时只替换标记之间的内容。三是用增量生成只在文件不存在时生成已存在的文件跳过。我一般推荐第一种因为它最清晰不会出现“生成代码和手写代码混在一起”的情况。5.2 类型映射出错怎么排查类型映射是生成代码时最容易出错的地方。常见的问题包括OpenAPI 的integer被映射成了stringarray没有正确处理items$ref没有解析导致生成了any。排查的时候我建议先把解析出来的结构化数据打印出来看看每个字段的类型是什么。如果类型不对再检查mapType函数的逻辑。另外swagger-parser的dereference会把$ref展开但有时候展开后的结构和你预期的不一样需要仔细看文档。我一般会在生成脚本里加一个--debug参数开启后打印详细的解析日志方便定位问题。5.3 生成的代码格式混乱怎么办模板生成的代码往往格式不理想比如缩进不一致、换行位置奇怪。最省事的办法是生成之后调用 Prettier 或 ESLint 格式化。你可以在生成脚本的最后加一步const prettier require(prettier); const formatted prettier.format(content, { parser: typescript }); fs.writeFileSync(filePath, formatted);如果项目有自己的 Prettier 配置Prettier 会自动读取.prettierrc保持和手写代码一致的风格。这样生成的代码就能直接通过 lint 检查减少 review 时的摩擦。另外模板本身也要注意格式尽量让生成的代码在格式化之前就接近最终形态这样即使格式化工具出问题代码也不会太难看。5.4 数据源更新后如何同步数据源更新后你需要重新运行生成脚本。如果生成代码提交到了仓库记得把重新生成的文件也提交上去。为了避免“有人改了数据源但忘了重新生成”可以在 CI 里加一个检查运行生成脚本然后检查git diff是否有变化如果有就报错。这样能强制大家在修改数据源后重新生成。另外如果数据源在另一个仓库可以考虑用 Git Submodule 或者定时任务来同步确保生成脚本总是基于最新的数据源。5.5 常见问题速查表问题现象可能原因排查方法解决方案生成的类型是 any$ref 未解析或类型映射缺失打印解析后的 schema检查 dereference 是否生效补充 mapType 分支路径参数未替换正则匹配失败检查路由字符串格式调整正则支持{id}和:id两种风格生成代码格式混乱模板缩进不一致对比模板和输出生成后调用 Prettier 格式化重新生成覆盖手动修改全量生成策略检查文件是否有手动改动改用增量生成或标记区域替换参数可选性错误required 字段未正确处理检查 OpenAPI 的 required 数组根据 required 决定是否加?枚举类型生成错误enum 未处理检查 schema 是否有 enum生成 TypeScript 的 union type 或 enum6. 扩展思路t3code 还能怎么玩6.1 生成 React Query Hooks如果你在用 React Query可以在生成请求函数的基础上进一步生成对应的 hooks。比如export function useGetUsers(params: { page?: number }) { return useQuery([getUsers, params], () getUsers(params)); }这样组件里直接调用useGetUsers就行不用再手动写 queryKey 和 queryFn。生成 hooks 的关键是确定 queryKey 的规则我一般用[operationId, params]作为 key这样参数变化时能自动重新请求。另外对于 mutation 类型的接口可以生成useMutation的封装把 invalidateQueries 的逻辑也一并生成。6.2 生成 Mock 数据有了类型声明和接口定义还可以生成 Mock 数据。根据 schema 的类型随机生成符合结构的假数据。比如string类型生成随机字符串integer生成随机数字array生成指定长度的数组。这样前端在后端接口还没 ready 的时候就能先联调。生成 Mock 数据的关键是处理好嵌套结构和引用避免无限递归。我一般会设置一个最大深度超过深度就返回空对象或 null。6.3 生成接口文档把 OpenAPI 定义转成 Markdown 或 HTML 文档也是 t3code 类实践的常见扩展。你可以用模板生成每个接口的说明包括路径、方法、参数、响应示例。这样文档和代码同源不会出现“代码改了文档没改”的情况。如果团队用 Confluence 或语雀还可以把生成的 Markdown 直接推送到对应平台。我见过一些团队把文档生成集成到 CI 里每次合并代码后自动更新文档效果很好。6.4 多语言支持如果你的项目涉及多种语言比如前端用 TypeScript后端用 Java可以基于同一份 OpenAPI 定义生成不同语言的代码。这时候类型映射和模板都需要按语言区分。我建议把语言相关的部分抽成独立的模块比如generators/typescript.js、generators/java.js每个模块负责自己的类型映射和模板。这样新增语言时只需要加一个模块不用改动核心解析逻辑。7. 我个人的一些实操体会做这类代码生成项目最大的体会是不要追求一步到位而是小步快跑。我见过一些团队一开始就想做一个“万能代码生成平台”支持所有数据源、所有输出目标、所有语言结果做了半年还没上线。更好的做法是选一个最痛的点用最简单的方案先跑通让团队看到收益然后再逐步扩展。比如先只生成前端请求函数等大家用习惯了再考虑生成类型、文档、测试。另一个体会是生成代码的质量比生成速度更重要。如果生成的代码格式混乱、类型错误大家用一次就不想再用了。所以在早期宁可少生成一些也要保证生成的代码能直接用。格式化、lint 检查、类型校验这些步骤不能省。我一般会在生成脚本里加一个“自检”环节生成之后跑一遍 TypeScript 编译如果有类型错误就报错避免把问题代码提交到仓库。最后文档和沟通很关键。生成脚本是谁维护的、怎么运行、数据源在哪里、生成的文件能不能手动改这些问题都要在 README 里写清楚。否则过几个月连你自己都忘了当初是怎么设计的。我习惯在生成脚本的头部写一段注释说明用途、用法和注意事项这样即使换了人维护也能快速上手。这个内容后续还可以这样扩展把生成脚本打包成一个 CLI 工具支持配置文件让其他项目也能复用或者集成到编辑器的保存钩子里保存 OpenAPI 文件时自动重新生成代码。这些方向都值得尝试但前提是先把核心流程跑稳。