☰
从模板到规范:t3code 团队代码生成器的架构设计与实践
2026/10/27 16:29:12 网站建设 项目流程

t3code这个名字,最早是我在带前端小组时给自己写的一个内部脚手架工具。起因特别简单:新项目一多,每个仓库里新建模块、新建页面、新建接口服务,都是同一套样板代码。不同的人写出来的样板千奇百怪——有人用 2 空格,有人用 4 空格,有人喜欢const到底,有人习惯function声明,更别说目录命名、路由注册、状态管理初始化这些"坑位"了。代码 review 的时间全花在纠结构格式上,业务逻辑反而没时间看。所以我就花了一个周末,把一个重复生成代码的流程固化成了命令行工具,代号 t3code,T 取的是 Team、Template、Type 三个词的共同首字母,意思是"基于类型与模板的团队代码生成器"。

这篇文章想把 t3code 从设计思路、核心架构到真实落地的细节全部讲清楚。内容适合想在团队里统一代码规范、减少重复劳动的开发者,也适合那些打算自己写一个代码生成器但不知道从哪下手的同学。你不需要用到它,跟着思路走一遍,也能避开我踩过的那些坑。

1. 为什么要做 t3code:从样板代码泥潭到生成器思维

1.1 团队协作中代码风格漂移的真实痛点

先说个数据。我的小组当时维护 12 个前端仓库,平均每个仓库有 30 个左右页面或模块级文件。算一下,新功能一个月要新增 80 到 120 个模块文件,其中 60% 是路由配置、API 请求函数、CRUD 页面骨架、表单初始状态这些高度重复的东西。

这些样板代码有两大问题。第一是风格不一致,同一个团队里会出现两种状态管理写法和三种接口请求封装;第二是低级错误反复出现,比如忘记在useEffect里清理定时器、API 错误处理只写了console.log、表单校验漏了必填项。这种问题 lint 不一定能全查出来,因为它本质上是"结构缺失"而不是"语法错误"。

我一开始尝试过复制粘贴旧模块再修改的方案,但复制粘贴有个很严重的问题——你复制的是旧代码,同时也复制了旧代码里的坏味道。一个老模块里可能残留着调试用的debugger、被注释掉的旧逻辑、写死的 mock 数据,新模块把这些问题原封不动带了过去。

代码生成器的思路就不一样了。它不是复制现有代码,而是把"代码应该是长什么样"这个约定固化成模板。生成出来的代码天然符合团队规范,多人协作时不用再互相猜对方的格式习惯。

1.2 为什么不用现成脚手架,而要自己写

肯定有同学会说,社区里不是有 Plop、Hygen、Yeoman 这些现成工具吗?何必自己造轮子。这里我说说我自己试下来的感受。

Plop 和 Hygen 都很轻量,插件机制和模板引擎也都够用,适合小规模场景。但真放在一个多项目中后期维护的时候,有几个问题绕不开:

  • 团队里同时有 React 和 Vue 项目,模板的目录结构、文件命名规则、路由注册方式差异很大,现成工具的配置写法比较分散,管理起来不够直观。
  • 生成逻辑里需要耦合一些团队内部的依赖(统一的请求库、统一的日志库、统一的常量定义),这些依赖在模板片段里到处都是,用现成工具的时候很难做系统化管理。
  • 生成后的自动化动作,比如自动格式化、自动 lint、自动生成依赖树,这些在现成工具里需要额外写一堆生命周期钩子,还不如自己掌控全程来得直接。

所以我倾向于自己写一个轻量 CLI,把模板、配置、生成管线三种东西放在同一个目录下面,用 git 管理,所有的规则变更都走 PR review。t3code 一开始就是干这个的。

1.3 t3code 的核心定位:模板即规范

t3code 的核心理念是"模板即规范"(Template as Specification)。什么意思呢?就是你在代码里写下的规范和约束,不应该散落在 README、ESLint 规则、团队知识库里,而应该直接用代码模板表达出来。

代码模板是最具操作性的规范。比如"所有 API 服务文件必须导出namespace对象,并且每个方法都要带错误处理"——这句话写进 README 没人看,写进模板就一定会被执行,因为生成的代码已经包含了对的东西。规范的落地不再依赖人的自觉,而是依赖生成器的产出。

2. 核心架构与关键设计决策:模板、配置与生成管线

2.1 配置驱动的设计思路:一份配置搞定多类型项目

t3code 的顶层配置长这样,我用t3code.json管理:

{ "projectType": "web", "templateDir": "./templates", "generators": { "page": { "description": "create a page module", "prompts": [ { "name": "moduleName", "message": "module name?", "required": true }, { "name": "routePath", "message": "route path?", "default": "/{{moduleName}}/" } ], "preset": "module", "actions": [ { "type": "add", "template": "page/index.tsx.tpl", "target": "src/pages/{{pathCase moduleName}}/index.tsx" }, { "type": "add", "template": "page/api.ts.tpl", "target": "src/pages/{{pathCase moduleName}}/api.ts" }, { "type": "add", "template": "page/type.ts.tpl", "target": "src/pages/{{pathCase moduleName}}/type.ts" }, { "type": "modify", "template": "page/route.ts.tpl", "target": "src/router/routes.ts", "pattern": "// {{ROUTE_ANCHOR}}" } ] } } }

这个配置文件解决的是"生成什么"的问题。generators里每个条目对应一种生成任务,prompts定义交互式问答的参数,actions定义要生成或修改哪些文件。

我特意把配置做成了"声明式"而不是"命令式"。意思是,你只需要描述生成的目标文件是什么、模板是什么、放在哪里,t3code 负责执行,而不是在代码里写一堆 if else 处理文件写入逻辑。这样一来,团队里不会写 Node.js 的同事也能通过改配置加新模板。

2.2 模板引擎选型:EJS 的取舍与替代方案

模板引擎我最终选了 EJS。原因有三个:

  1. EJS 语法接近原生 JavaScript,团队成员几乎零学习成本。
  2. 渲染逻辑和数据夹在 HTML 或 TS 代码里也不突兀,比如<% if (isNeedAuth) { %>这种控制流看起来直白。
  3. 模板可以引用函数辅助进行字符串处理,比如pathCase、upperFirst、camelCase。

对比过几个备选方案:

  • Handlebars 逻辑弱,写条件判断必须注册 helper,不然模板里全是{{#if}}嵌套,可读性差。
  • Nunjucks 功能强但是重,语法也更复杂。
  • Mustache 是无逻辑模板,直接 pass。

EJS 也不是没有坑。最典型的是<%和<%=的区别,一个是执行不输出,一个是输出 HTML 转义后的结果。生成代码的时候需要的是"原样输出",所以我全程只用<%=,并且关闭了 autoescape。模板里像这样写:

export interface <%= upperFirst(moduleName) %>Data { id: string; name: string; status: <%= statusEnum %>; }

注意 EJS 默认escape是 true,生成的字符串里如果有<、>、&符号会被转义成实体字符,这在生成代码时是灾难(你会得到一个&lt;div&gt;的页面)。所以我在渲染函数里显式设置了{ escape: false },这个细节是很多教程不会写的,但你不设置的话,模板里凡是泛型Type<T>都会被转义,非常难受。

2.3 生成管线的四个阶段:引导问答、参数校验、渲染、落盘

t3code 的生成流程我设计成四个阶段,每个阶段都可以插入自定义逻辑:

阶段一:引导问答(Prompts)

CLI 启动后,读取配置中对应 generator 的prompts,通过终端交互收集参数。比如moduleName必须是非空的,routePath没填就用默认值。这里我用的是prompts这个库,比inquirer更轻,支持必填校验和默认值。

阶段二:参数校验与派生

收集到的原始参数,通常还需要加工。比如moduleName传进来是userManagement,生成页面路径时需要转成user-management,生成组件名时需要转成UserManagement。这类派生逻辑我放到一个helpers.js文件里:

// scripts/helpers.js function pathCase(name) { return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); } function upperFirst(name) { return name.charAt(0).toUpperCase() + name.slice(1); } function camelCase(name) { return name.replace(/[-_]([a-z])/g, (_, c) => c.toUpperCase()); } module.exports = { pathCase, upperFirst, camelCase };

模板里能用这些函数,配置文件里的target路径也能用这些函数,因为我在解析配置时把 helper 函数注入进去了。

阶段三:渲染(Render)

渲染不是简单的 read + write。每个文件模板会得到一个独立的渲染上下文,包含prompt收集来的参数、辅助函数、全局基础信息(项目名、作者、时间戳)。渲染逻辑非常直接:

const ejs = require('ejs'); const fs = require('fs-extra'); const path = require('path'); async function renderTemplate(templatePath, context) { const content = await fs.readFile(templatePath, 'utf-8'); return ejs.render(content, context, { escape: false }); }

阶段四:落盘(Write)

最后一步是写文件。这里要考虑的不只是"把字符串写到文件里",还有文件已存在、目录不存在、路径非法这三种情况。我单独为这个写了一个模块,后面会详细说。

3. 手把手把最小可用版本跑起来:CLI 入口到第一个生成结果

3.1 初始化项目与命令行入口设计

t3code 本身是一个 Node.js CLI,技术栈是 Node.js + TypeScript + Commander + fs-extra。项目结构如下:

t3code/ ├── package.json ├── tsconfig.json ├── t3code.json # 生成器配置 ├── templates/ # 所有模板文件 │ ├── page/ │ │ ├── index.tsx.tpl │ │ ├── api.ts.tpl │ │ └── type.ts.tpl ├── scripts/ │ ├── cli.ts # CLI 入口 │ ├── prompts.ts # 问答逻辑 │ ├── render.ts # 渲染逻辑 │ ├── write.ts # 文件写入逻辑 │ └── helpers.ts # 辅助函数

package.json 里做了这样几个配置:

{ "name": "t3code", "version": "0.1.0", "bin": { "t3code": "./dist/cli.js" }, "scripts": { "build": "tsc -p tsconfig.json", "dev": "ts-node ./scripts/cli.ts" } }

CLI 入口我用 Commander 注册了create子命令,它接收一个 generator 名字作为参数,然后加载配置、跑问答、执行生成。

#!/usr/bin/env node import { Command } from 'commander'; import { runGenerator } from './runGenerator'; const program = new Command(); program .name('t3code') .description('t3code - team template code generator') .version('0.1.0'); program .command('create') .argument('<generator>', 'generator name from t3code.json') .option('-f, --force', 'overwrite existing files') .option('-i, --interactive', 'enable interactive prompt mode') .action(async (generator, options) => { await runGenerator(generator, options); }); program.parse(process.argv);

注意我在 CLI 入口最开始写了 shebang#!/usr/bin/env node,这是让命令可以直接在终端执行的关键。

3.2 参数解析与交互式问答的实现

下面这段代码是prompts.ts的核心逻辑。它会遍历配置里generators[generatorName].prompts数组,依次向用户提问,收集结果。注意我对required和default的处理方式:

import prompts from 'prompts'; interface PromptItem { name: string; message: string; required?: boolean; default?: string; choices?: string[]; } export async function collectPrompts(items: PromptItem[]) { const answers: Record<string, string> = {}; for (const item of items) { let result: string | null = null; if (item.choices && item.choices.length > 0) { const { value } = await prompts({ type: 'select', name: 'value', message: item.message, choices: item.choices.map((choice) => ({ title: choice, value: choice })) }); result = value; } else { const { value } = await prompts({ type: 'text', name: 'value', message: item.message, initial: item.default }); result = value; } if (!result && item.required) { console.error(`[t3code] error: "${item.name}" is required.`); process.exit(1); } answers[item.name] = result || ''; } return answers; }

这里有一个值得注意的点,我用了for...of循环而不是Promise.all。因为问题之间有依赖关系,比如routePath的默认值是{{moduleName}},只有先拿到moduleName才能计算默认值。prompts 库的问答本身是异步的,并行的话依赖关系就全乱了。

另一个细节是initial字段,相当于默认值。比如routePath的默认值如果是一个带模板占位符的字符串,在注入上下文时要先用moduleName替换占位符。我是在runGenerator.ts里统一做模板占位符解析的。

3.3 模板渲染与文件写入:核心逻辑就这么几行

runGenerator.ts是生成器的总控流程。代码如下:

import fs from 'fs-extra'; import path from 'path'; import { collectPrompts } from './prompts'; import { renderFile } from './render'; import { writeFileSafe } from './writer'; import helpers from './helpers'; interface ActionConfig { type: 'add' | 'modify'; template: string; target: string; pattern?: string; } export async function runGenerator(generatorName: string, options: { force?: boolean }) { const config = await fs.readJson(path.resolve(process.cwd(), 't3code.json')); const generator = config.generators[generatorName]; if (!generator) { console.error(`[t3code] generator "${generatorName}" not found.`); process.exit(1); } const answers = await collectPrompts(generator.prompts); const context = { ...answers, ...helpers, projectName: config.projectName || path.basename(process.cwd()), timestamp: new Date().toISOString() }; for (const action of generator.actions) { // 解析 target 中的模板字符串,例如 "src/pages/{{pathCase moduleName}}/index.tsx" const targetPath = parseTemplateString(action.target, context); if (action.type === 'add') { const templatePath = path.resolve(process.cwd(), config.templateDir, action.template); const content = await renderFile(templatePath, context); await writeFileSafe(targetPath, content, { force: options.force }); } else if (action.type === 'modify') { await modifyTargetFile(action, context); } } console.log(`\n[t3code] "${generatorName}" generated successfully at ${path.resolve(process.cwd())}`); }

parseTemplateString是个小函数,负责把配置 target 里的{{pathCase moduleName}}这种占位符解析成实际的值,实现方式就是遍历 helper 函数并对应用参数替换:

function parseTemplateString(str: string, context: Record<string, any>): string { return str.replace(/\{\{\s*(\w+)\s*(.*?)\s*\}\}/g, (_, helperName, argName) => { if (helpers[helperName]) { return helpers[helperName](context[argName.trim()]); } return context[helperName] || ''; }); }

模板渲染结束后,writeFileSafe负责把字符串写到指定目录。基本逻辑是确定绝对路径、检查文件存在性与 force 参数、递归创建目录、写文件,最后用 prettier 格式化一次。

4. 真正用起来才发现的坑:覆盖策略、路径安全与编码问题

4.1 文件已存在时的覆盖策略:--force 与询问模式的取舍

生成代码时最容易出现的情况是文件已经存在。比如你往src/router/routes.ts里追加路由,这个文件一定存在,不能直接覆盖,否则会把老路由全清掉。另外,如果新生成的index.tsx覆盖了旧页面,团队里某个同事两小时的改动就白写了。

我的策略是分三级:

  1. 文件不存在:直接生成。
  2. 文件存在且内容与模板渲染结果一致:跳过,不打扰用户。
  3. 文件存在且内容不一致:进入交互确认,问用户是要覆盖、跳过,还是保留两边。

这个逻辑在writeFileSafe里实现:

async function writeFileSafe(targetPath: string, content: string, options: { force?: boolean }) { const absPath = path.resolve(process.cwd(), targetPath); if (fs.existsSync(absPath)) { const oldContent = await fs.readFile(absPath, 'utf-8'); if (oldContent === content) { console.log(`[t3code] skip: ${targetPath} is already latest.`); return; } if (options.force) { // 强制覆盖,不询问 } else { const { action } = await prompts({ type: 'select', name: 'action', message: `${targetPath} already exists. What to do?`, choices: [ { title: 'Overwrite', value: 'overwrite' }, { title: 'Skip', value: 'skip' }, { title: 'Keep both', value: 'keep' } ] }); if (action === 'skip') return; if (action === 'keep') { const ext = path.extname(absPath); const base = absPath.replace(ext, ''); const newPath = `${base}.copy${ext}`; await fs.ensureDir(path.dirname(newPath)); await fs.writeFile(newPath, content, 'utf-8'); console.log(`[t3code] keep both: generated ${path.basename(newPath)}`); return; } } } await fs.ensureDir(path.dirname(absPath)); await fs.writeFile(absPath, content, 'utf-8'); }

这个设计我后来觉得不错的地方是:"保留两边"这个选项。新人试用工具的时候不敢覆盖老文件,有了.copy文件,核对了没问题再手动替换,心理负担小很多。

4.2 路径穿越校验:模板文件名里藏着的安全风险

这个坑是我在内部测试时偶然发现的。配置里 action 的target是文件路径,理论上完全由配置文件决定,本地工具问题不大。但如果模板文件本身不可信(开源仓库贡献者改了模板路径),或者有人恶意构造了 target 为../../../../somepath/file.ts,生成器就会写到预期之外的目录。

我在writeFileSafe里加了一层路径校验:

function assertSafeTarget(target: string) { const resolved = path.resolve(process.cwd(), target); const rootDir = path.resolve(process.cwd()); if (!resolved.startsWith(rootDir)) { throw new Error(`[t3code] unsafe target path: ${target}`); } return resolved; }

把它加到writeFileSafe的最前面,这样任何试图逃出项目根目录的写入都会被拦截。对于这种开发工具,安全边界不是"防黑客",而是防止配置错误导致的意外行为——比如某个模板文件路径写错,直接改掉了项目外的系统文件,这种错误极难排查。

4.3 模板文件编码与换行符的那些事:CRLF 和 BOM 的兼容处理

模板中经常会有"空行" - 特别是多个模板文件之间通过 append 方式拼接时。我踩过的坑有几个:

第一个是 BOM(字节顺序标记)。曾有一次我用 Windows 下的编辑器创建了一个模板文件,文件开头带上了 BOM 字符(\uFEFF),渲染出来的代码第一行就是\uFEFFexport interface ...,ESLint 直接报 "Unexpected BOM" 错误。后来我在渲染管线里加了一步清洗逻辑,统一用stripBom处理模板内容:

const stripBom = (s: string) => s.replace(/^\uFEFF/, '');

第二个是 CRLF 换行符。Windows 下创建的模板文件如果是 CRLF,生成出来的文件就是 CRLF,而项目里可能配置了 ESLint 的linebreak-style: ["error", "unix"],结果 lint 直接挂掉。我在渲染后统一把\r\n替换成\n:

content = content.replace(/\r\n/g, '\n');

这个处理放在模板渲染之后、写文件之前。注意不要全局替换模板源文件,因为你可能同时需要保留某些模板里的 CRLF(比如生成 Windows 批处理脚本时)。

第三个是关于模板里的缩进。EJS 控制标签<%本身不产生输出,但如果控制标签后面跟着一行空行,EJS 默认会保留这个空行,导致生成的文件里到处是多余空行。解决办法是配置{ rmWhitespace: true },但这样会把模板里有意保留的空行也删了。更稳妥的方式是在模板里这样写:

<% if (isNeedAuth) { %>import { useAuth } from './auth'; <% } %>

注意%>不要直接换行,下一行紧接着写内容。这个细节直接决定生成文件的行数是否干净。我后来干脆写了个后处理函数,把连续超过一个空行的情况压缩成单个空行:

content = content.replace(/\n{3,}/g, '\n\n');

5. 扩展成真正可用的团队工具:多项目配置、hooks 与后续演进

5.1 通过 projectType 实现多项目模板切换

单一项目的 t3code 很好写,但团队里往往同时有 Web、管理后台、Node 服务几种项目。每个类型的目录结构不一样,生成的样板也不一样。我的方案是给配置增加一个projectType字段,并让templateDir支持按类型切换:

{ "projectType": "node-server", "templateDir": "./templates/node-server", "generators": { "route": { "description": "create a new REST route", "actions": [ { "type": "add", "template": "route/index.ts.tpl", "target": "src/routes/{{pathCase moduleName}}.ts" } ] } } }

运行时读取projectType,然后动态拼接模板目录的路径。这样同一个 t3code 命令,在 Web 项目里跑生成的是 React 组件,在 Node 项目里跑生成的是 Express 路由。

还有个细节是,模板目录本身放在 t3code 的 git 仓库里,通过 monorepo 或者 git submodule 方式引入到各个项目中。这样模板的更新只改一处,所有项目通过升级依赖就能同步。

5.2 生成后自动格式化与 lint:把惊喜控制在可预期范围内

最早的版本生成完代码就完事了,结果生成出来的代码经常因为格式问题带偏团队规范。后来我在生成管线末尾加了一个"后处理"阶段:

  1. 用 prettier 对生成的文件做一次格式化。
  2. 如果项目有 ESLint 配置,对生成文件跑一次eslint --fix。
  3. 如果项目有 TypeScript,执行一次tsc --noEmit做类型检查。

格式化这一步是必须的,因为模板里往往为了可读性会加一些缩进和换行,渲染出来的代码很可能不是最终的 prettier 风格。强行保持一致性的办法就是生成后再跑一次格式化,成本很低但收益很高:

import { execSync } from 'child_process'; function postProcessFiles(filePaths: string[]) { for (const file of filePaths) { execSync(`npx prettier --write "${file}"`, { stdio: 'inherit' }); execSync(`npx eslint --fix "${file}"`, { stdio: 'inherit' }); } }

有一个平衡要注意:后处理太重会影响效率。比如一次生成 5 个文件,每个文件都调一次 prettier,耗时可能 5 秒以上。我的做法是先批量收集生成的文件列表,然后批量调用一次 prettier 和 eslint,而不是按文件循环调用。

5.3 生成后自动创建分支和 commit 的思考

团队里有人建议过,生成完代码后自动 git add 和 commit,省得手工操作。我试过但后来取消了。

原因是自动 commit 太危险。你生成代码时可能在别人的分支上,自动 commit 容易把别人的改动卷进同一次提交,到时候回滚都分不开。而且生成器产生的代码不一定完整,页面生成出来还要改样式、调接口,半成品不该进 git 记录。

折中方案是生成结束后打印建议操作:

[t3code] "page" generated successfully. [t3code] next steps: cd src/pages/user-management open index.tsx to review and adjust business logic npm run lint:fix

这种"建议但不强制执行"的思路,比直接干预 git 流程稳妥得多。维护工具一段时间后我发现,工具要学会克制,用户需要的是减少机械劳动,而不是替他们做决策。

6. 从开发到推广:让团队真正用起来的几个关键动作

6.1 给工具加上"生成报告"而不是裸输出

CLI 工具最忌讳的就是闷头执行完,用户不知道刚才发生了什么。我最初版本生成完就一句话带过,后来开了两场分享会,大家反馈最多的是"刚生成了哪些文件、改了哪些文件,能不能清楚列出来"。

所以我在输出上做了三处改进:

  1. 每次生成的每个 action,输出一行前缀为[add]、[modify]、[skip]的日志。
  2. 生成结束后列出一个文件清单,分三类:新增、修改、跳过。
  3. 如果某个文件被修改了,展示统一的 diff 统计信息(比如+10 -3),方便 review 时快速定位。

输出看起来像这样:

[t3code] generator: page [add] src/pages/user-management/index.tsx [add] src/pages/user-management/api.ts [add] src/pages/user-management/type.ts [modify] src/router/routes.ts (+1 -0) [t3code] done. 3 files added, 1 file modified, 0 skipped.

这些信息是团队用起来最直观的反馈。不要小看这段日志,它决定了新人敢不敢信任这个工具。

6.2 建立模板 review 制度:模板是代码,也是规范

模板本身也要走 code review。很多团队一开始用代码生成器,只管业务代码的 review,模板被当成了"工具的一部分"忽略掉了,这是错的做法。因为模板一旦合入,影响范围是全局的——它决定了每个新页面长什么样。一次错误的模板更新,可能导致接下来一个月内生成的所有代码都有一个隐藏 bug。

我在团队里定了一条规则:任何模板文件的改动必须走 PR 评审,并且在说明里附一个"生成样例"链接,评审人可以看到模板渲染前后的对比,而不是只看模板语法。这个做法直接降低了模板改动的返工率。

6.3 与编辑器集成:从终端命令到 IDE 右键菜单

纯命令行体验还是有点门槛,新人记不住t3code create page这种命令。我后来写了一个非常简单的 VS Code 扩展,在资源管理器右键菜单里加了"t3code: create page"这类动作。它本质上是调用同一个 CLI,只不过参数通过 IDE 的输入框来收集,不需要记忆命令名称。

不过这属于锦上添花的活,如果你只有一个人维护工具时间有限,建议先保证 CLI 稳定,再考虑编辑器插件。

7. 我踩过的三个印象最深的坑(可能对你也有用)

7.1 EJS 模板转义导致泛型全部变形

前面提过别忘设置escape: false,这里展开说下实际场景。生成 TypeScript 接口文件时,模板里有泛型:

export interface PagedResponse<T> { list: T[]; total: number; }

默认escape: true的情况下,EJS 会把<T>渲染成&lt;T&gt;,生成的代码变成:

export interface PagedResponse&lt;T&gt; { list: T[]; total: number; }

ESLint 根本不会报错,因为这不是语法错误,只是字符串,TypeScript 编译器会报 "Cannot find name 'PagedResponse<T>'" 这种莫名其妙的错误。排查这个问题的代码是 Unicde 还是什么并不重要,重要的是你要知道 EJS 的 escape 默认行为在代码生成场景下是反的。

7.2 模板里路径分隔符用反斜杠导致 Windows 环境路径损坏

在target配置里写路径时,我最初的写法是src\pages\{{pathCase moduleName}}\index.tsx,在 Windows 上没问题,但在 macOS 上运行就会生成一个文件名带反斜杠的文件。

正确做法是永远在配置文件、模板、代码里使用正斜杠/,在最后用path.resolve转换。写工具的人要记住一条原则:项目路径在配置里永远是/分隔,只在 Node.js 边界处做系统相关的转换。

7.3 生成速度过快,肉眼跟不上,以为工具卡死

别笑,这个"坑"是真被用户反馈过。生成 3 个文件只用了 100ms,在终端里一闪而过,用户以为命令没执行。最后我在命令结束时打印一段带生成时间和文件统计的总结,才解决了这个"心理卡顿"问题。

工具的快是好事,但要给用户"确实发生了"的确认反馈,否则体验反而是负的。

8. 写在最后:一个代码生成器该有的自我修养

维护 t3code 两年多,我最大的体会是:代码生成器的核心价值不在于"帮你写代码",而在于把团队的隐性约定显性化。代码规范写在模板里,比写在文档里管用一百倍,因为前者被强制执行,后者只被部分遵守。

现在我的团队里,新同学入职第一周不需要啃几十页开发规范,跑一遍 t3code 生成几个模块,基本就熟悉了项目的结构约定。review 代码的时候,讨论的焦点也从"这里怎么用了一个奇怪的缩进"转移到了"这个业务逻辑是否需要拆出独立 hook"。

如果你也想做一个类似的工具,我的建议是从最小场景开始——选定一个高频重复的模块类型,写好三个模板文件,做成一个只能生成这一个模块的 CLI,先让团队用起来。等大家感受到响应式生成代码的便利,再逐步扩展配置和 hooks,路径就清晰了。

工具是为人服务的,别为了炫技把工具做得比手写还复杂。能用简单方案解决的事情,绝不引入新依赖。这大概就是 t3code 带给我的最好习惯。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询