ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code:告别手动初始化,自动生成TypeScript全栈项目骨架

t3code:告别手动初始化,自动生成TypeScript全栈项目骨架

如果你也经常被“新项目初始化”这件事折磨,你大概率能理解我为啥会写一个名叫t3code的小工具。大概从去年开始,团队里每次开新前端项目,都要走一遍几乎相同的流程:打开create-t3-app生成基础骨架,删掉默认的示例代码,安装内部组件库,配好 ESLint 规则,再手动把项目名写进各处配置。这些步骤单独看都不难,但叠加在一起,一个项目从零到能在本地跑起来,往往要花掉一个下午。

t3code最终被做成了一个命令行工具,目标是:输入一条命令,回答几个问题,就能生成一份符合团队规范、只包含你勾选功能的 TypeScript 全栈项目骨架。代号里的 t3,我理解成 T3 Stack(Next.js + tRPC + Prisma + Tailwind + TypeScript)那一整套技术选型;code 则代表它真正干的事——代码生成。文章主要面向两类人:一类是厌倦了重复初始化工作的全栈开发者,另一类是打算给团队做内部脚手架、但不知道从哪下手的人。接下来我会完整复盘这个工具的设计取舍、核心实现、翻车记录,以及我怎么让它从“我自己能用”变成“团队敢用”。

1. 为什么我会动手写 t3code:团队里反复出现的三件烦心事

1.1 新项目初始化不是“5 分钟”而是“两小时”

很多人以为现在脚手架很成熟,新项目跑一条命令就有了。实际在团队环境里,事情远没有这么简单。create-t3-app能生成一个标准 T3 Stack 应用,但它生成的是“通用骨架”,不是“团队骨架”。我们内部有统一的目录约定、日志封装、请求层封装、环境变量规范,这些都不会出现在官方模板里。于是每个新项目都要经历下面这一串手工操作:

  • 用官方脚手架生成基础项目
  • 手动删除示例页面和示例 API 路由
  • 把package.json里的 name 改成项目名
  • 安装内部组件库和公共配置包
  • 在next.config.js里补上代理配置
  • 初始化 Git 仓库并推送远端

这套流程走下来,快则 40 分钟,慢则两小时,而且特别容易出错。比如项目名要改的地方不止package.json,还有README、package-lock.json里的 name 字段、Docker 镜像名、docs里的部署说明。漏掉一个,后面 CI 构建的时候就会以莫名其妙的方式报错。踩过几次坑之后我就想:既然流程是固定的,为什么不写个工具把“规则”固化下来?

1.2 模板漂移:官方脚手架升级一次,我们的项目就跟不上一次

另一个让我下决心动手的问题是模板漂移。官方create-t3-app大概每几个月就会更新一轮,Tailwind 升级、Next.js 新版本、tRPC 的 API 变化。我们团队内部如果继续靠“复制上次项目”来初始化,新项目就只能停留在几个月前的依赖版本上。等想升级了,又发现好几处 breaking change,改起来比新建项目还累。

我也考虑过在团队里维护一个“公共模板仓库”,每次官方更新后人工同步。但很快发现,这种模板仓库维护起来极其痛苦:条目多、依赖更新频繁、没有有效的验证手段。人工同步一次模板,很容易把配置写错,而且没有人能凭肉眼发现。折腾了几轮之后,我确认了一件事:模板的更新必须有自动化验证兜底,而普通 Git 仓库加一份 README 的维护方式,撑不起这个要求。

1.3 内部规范没法塞进公共脚手架

第三件事是内部规范很难塞进公共脚手架。每个公司、每个团队都有自己的习惯。我们团队要求 API 请求统一走一个http封装,所有环境变量必须集中放在env模块里做运行时校验,组件目录一律按features/xxx/components组织。这些规则如果写进 README,靠人遵守,大概率会被跳过;如果写成脚本,把规则变成代码生成逻辑的一部分,那每个新项目从一开始就是符合规范的。

所以 t3code 的价值不只是“省时间”,更准确的说法是:它把团队约定从一个“文档问题”变成了“代码问题”。文档可以不看,代码生成的结果却一定会出现在项目里。只要工具的可信度足够高,新项目就天然长成符合规范的样子。

2. t3code 的核心设计:从“复制模板”到“参数化代码生成”

2.1 技术栈选择:为什么是 Node.js + TypeScript,而不是 Shell 或 Python

实现这个工具,我第一个纠结的问题是:用什么语言写。很多人第一时间会想到 Shell 脚本,一条curl加tar就能把模板拉下来。问题是后面要加交互式表单、参数校验、JSON 配置解析,Shell 会变得非常难维护。Python 也能做,但团队成员更熟 JS/TS,而且工具本身要托管到 npm 上,用 Node.js 可以直接npm i -g t3code完成分发,零额外依赖。

最终我选了 Node.js + TypeScript。类型系统在定义模板变量的时候特别有用,后面做快照测试时也省了不少功夫。比如我可以定义一个TemplateConfig类型,把项目名、功能开关、包管理器全部列清楚,在代码生成前先做一次严格的类型校验,很多低级错误在编译期就被拦住了。

export interface TemplateConfig { projectName: string; orgName: string; features: { prisma: boolean; trpc: boolean; tailwind: boolean; docker: boolean; }; packageManager: 'npm' | 'pnpm' | 'yarn'; }

2.2 模板的三种组织方式与我的选择

模板文件放在哪里,是早期设计里一个关键的决策点。我对比过三种方式:

模板组织方式优点缺点
本地目录开发迭代快,改完直接用团队共享困难,别人拿不到最新模板
Git 仓库版本可控,团队可共同维护拉取依赖网络,离线不可用,需要处理仓库嵌套问题
npm 包天然走 registry,支持版本锁定和自动更新发布流程多一道,需要维护 package 元信息

最后我选择把模板打成 npm 包,目录结构类似@team/t3code-templates。工具通过依赖引入模板,这样版本就锁在package.json里了。团队升级模板版本时走正常的依赖升级流程,而不是每个人各拉各的仓库。模板包内部长这样:

templates/ full-stack/ package.json.ejs next.config.js.ejs src/ _app.tsx.ejs pages/ index.tsx.ejs _env.example _gitignore

2.3 配置协议和模板渲染引擎的取舍

模板渲染引擎我选了 EJS。Handlebars 语法更严格、更安全,但写条件分支和循环时特别啰嗦;简单的字符串替换工具虽然上手快,但处理不了“如果选了 Prisma,额外生成schema.prisma和docker-compose.yml,否则跳过”这种逻辑。EJS 的好处是直接在模板里写 JavaScript 逻辑,支持<% if (features.prisma) { %>,而且生态成熟,include局部模板也很方便。

模板变量的“协议”也很重要。我规定所有模板里能用的变量都来自上面那个TemplateConfig,不允许模板里出现未声明的全局变量。这样在渲染之前就能做校验,一旦模板里引用了不存在的字段,立刻报错,而不是等到生成的项目跑不起来才发现。

需要特别提醒的是:EJS 默认的<%= %>会做 HTML 转义,这对生成代码来说往往是灾难。比如把项目名foo-bar插进代码,可能被转义成foo-bar,看起来一样,但一旦项目名里出现引号,生成的代码就直接语法错误。我统一改用了<%- %>做无转义输出,同时用严格的项目名校验来保证安全性。

// src/config.ts.ejs export const PROJECT_NAME = '<%- projectName %>';

2.4 一条代码生成请求的完整流转路径

t3code 不是一次性把文件复制过去就完事,它的核心流程是参数化的,完整链路大致如下:

  1. 解析命令行参数,收集projectName、template、packageManager
  2. 如果参数缺失,进入交互式表单,逐个补齐
  3. 从模板包中定位对应模板目录
  4. 根据features过滤文件,把不需要的模块排除掉
  5. 遍历模板目录,按规则渲染 EJS 文件、拷贝二进制文件
  6. 执行特殊文件重命名,比如_env.example变成.env.example
  7. 在目标目录安装依赖
  8. 初始化 Git 仓库并生成首次提交

这 8 步里,我踩过的坑基本集中在第 4 到第 6 步。后面专门写一节来复盘。

3. 实现细节:命令解析、渲染、依赖安装的完整链路

3.1 子命令设计:init / list / add 各管什么

工具的命令行入口用commander实现。总共三个核心子命令:init负责初始化新项目,list列出可用模板和版本,add给已有项目追加功能模块。设计原则很简单:一个命令只做一件事,参数能省则省。

#!/usr/bin/env node import { Command } from 'commander'; const program = new Command(); program .name('t3code') .description('Generate TypeScript full-stack project scaffolds with team presets') .version('0.5.0'); program .command('init') .argument('[projectName]', 'project directory name') .option('-p, --package-manager <name>', 'npm, pnpm or yarn', 'pnpm') .option('-t, --template <name>', 'template name', 'full-stack') .option('--skip-install', 'skip dependency installation') .option('--skip-git', 'skip git initialization') .option('-y, --yes', 'non-interactive mode, use defaults') .action(async (projectName, opts) => { await runInit(projectName, opts); }); program.parse();

add子命令的核心是模块化生成。比如一个项目要新加 Docker 配置,不需要重新初始化,直接t3code add docker,工具会把docker-compose.yml.ejs渲染后追加到现有项目里。这听起来简单,实际要求模板按模块组织得非常干净,任何模块之间都不能暗地里互相依赖。

3.2 交互式表单:能不让人填的尽量不让人填

交互部分我用了@clack/prompts,而不是自己用readline一行行写。理由很实际:它支持单选、多选、取消操作,终端美化也足够好,省掉大量重复代码。交互式表单的原则是“能不让人填的尽量不让人填”。比如项目名,如果用户已经在命令行里传入t3code init my-app,就不要再弹窗问一次。只有参数确实缺失的时候才进入交互。

import { select, confirm, text, multiselect, isCancel } from '@clack/prompts'; const projectName = await text({ message: '项目目录名', placeholder: 'my-awesome-app', validate: (value) => /^[a-z0-9-]+$/.test(value) ? undefined : '只能包含小写字母、数字和连字符' }); if (isCancel(projectName)) { console.log('已取消'); process.exit(0); }

--yes参数也很重要。团队 CI 里自动化生成项目时,必须能跳过所有交互,直接从命令行参数和默认值生成。这个参数在 4.5 节还会提到,因为它在 CI 场景里救过我好几次。

3.3 递归模板渲染与特殊文件规则

模板目录的遍历逻辑是整个工具的核心。不能简单地用fs.cp递归复制模板目录,原因我会在踩坑节展开。正确的做法是自写遍历函数,按规则决定每个文件是渲染、拷贝还是跳过:

import { readdir, stat, readFile, writeFile, mkdir } from 'fs/promises'; import path from 'node:path'; const RENDER_EXTENSIONS = new Set(['.ejs', '.tsx', '.ts', '.js', '.json', '.css']); const SKIP_DIRS = new Set(['.git', 'node_modules', '.t3code']); async function renderTemplateDir(srcDir: string, destDir: string, config: TemplateConfig) { const entries = await readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith('.') && entry.name !== '.env') continue; if (SKIP_DIRS.has(entry.name)) continue; const srcPath = path.join(srcDir, entry.name); const destPath = path.join(destDir, normalizeEntryName(entry.name)); if (entry.isDirectory()) { await renderTemplateDir(srcPath, destPath, config); } else { const ext = path.extname(entry.name).toLowerCase(); if (RENDER_EXTENSIONS.has(ext)) { const content = await readFile(srcPath, 'utf-8'); const rendered = await renderEjs(content, config); await writeFile(destPath, rendered, 'utf-8'); } else { await copyFileSafe(srcPath, destPath); } } } }

特殊文件规则主要解决 dotfile 问题。模板仓库里不能直接放.env.example和.gitignore,因为 Git 会忽略点开头的文件,GitHub 上的文件预览也很不方便。我的约定是:模板里用_env.example表示.env.example,用_gitignore表示.gitignore,渲染时做一次替换映射。

function normalizeEntryName(name: string): string { if (name.startsWith('_env')) return `.env${name.slice(4)}`; if (name === '_gitignore') return '.gitignore'; return name; }

3.4 依赖安装、Git 初始化与失败回滚

依赖安装部分没有用 npm API,而是直接spawn子进程调用包管理器。原因很简单:包管理器的行为要跟用户在命令行手动执行时保持完全一致,这比任何封装都可靠。不同的包管理器参数略有差异,需要做一层适配。

import { spawn } from 'node:child_process'; function installDependencies(cwd: string, packageManager: string): Promise<void> { const commandMap = { npm: ['install'], pnpm: ['install'], yarn: [], }; const args = commandMap[packageManager] ?? ['install']; return new Promise((resolve, reject) => { const child = spawn(packageManager, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32', }); child.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`${packageManager} install exited with code ${code}`)); }); }); }

有几个细节必须处理:Windows 下pnpm、yarn是.cmd脚本,不能直接spawn,需要shell: true;安装失败时不能把半成品项目留在原地,所以我先把项目渲染到临时目录,全部成功后再移动到最终位置。Git 初始化时如果用户没有配置user.name和user.email,首次提交会报错,工具应该给出明确的提示而不是一堆 Git 内部错误。

4. 踩坑记录:五个真实翻车现场和修复思路

4.1 递归拷贝时把 .git 目录一起带了过去

第一次真正跑通 t3code 时,我生成完项目马上执行git status,结果发现一个大问题:生成的项目里带着模板仓库自己的.git目录。原因很简单,我图省事用了fs.cp递归复制整个模板目录,这个 API 虽然有filter参数,但我当时只过滤了文件名,没处理目录,.git目录里的文件一个个被复制了过去。

这个坑的麻烦程度超出预期。生成的目录里有嵌套 Git 仓库,git init时不会报错,但以后git push的时候,远端会把嵌套仓库当作 submodule 或者干脆报错。修复不复杂:自己遍历目录,在进入子目录前就跳过.git和node_modules。但排查过程让我意识到,脚手架工具在“复制文件”这件事上,绝不能依赖粗粒度的 API,必须精确控制每个文件的去向。

4.2 EJS 把二进制资源文件当文本渲染

t3code 的模板里有logo.png、字体文件,还有一些 favicon 图标。早期版本我用扩展名列表判断是否需要渲染,列表里只有.ts、.tsx、.js、.json、.css,其余全部当普通文件拷贝。结果有一次我图省事,把渲染的判定改成了“所有文件都走一遍 EJS”,生成的图片立刻变得巨大,而且无法打开。

原因是 Node 的readFile默认用了utf-8解码,把 PNG 的二进制字节当作字符串处理后,再以utf-8编码写回,字节已经严重损坏。这个错误不会让工具崩溃,只会静默破坏资源文件,非常隐蔽。最后的修复方案是白名单机制:只有明确文本扩展名才走渲染,和RENDER_EXTENSIONS之外的任何文件一律按二进制直接拷贝。我还额外加了一步校验,渲染完成后统计生成目录里的文件大小,跟模板做对比,防止以后再遇到类似问题。

4.3 Windows 路径分隔符带来的模板重命名错乱

团队里有同事用 Windows,这让我第一次意识到路径处理要区分“文件系统路径”和“代码里的模块路径”。当时模板里有一段逻辑,根据项目配置生成 import 路径,结果在 Windows 上跑出来的代码长这样:

import api from 'src\features\user\index';

模块路径用反斜杠写,构建直接就崩了。正常情况下,Windows 文件系统的路径分隔符是\,但代码里的 import 路径、URL 路径必须用/。很多 Node 的 path 操作方法在不同平台上行为不同,如果图省事直接对模板变量调用path.join,生成出来的代码就是不可移植的。

修复思路是严格区分两类路径:文件系统操作用path.join,代码中出现的路径一律手动用数组配合/拼接:

const modulePath = ['src', 'features', featureName, 'index'].join('/');

这个坑告诉我们:脚手架工具生成的是“代码文本”,而不是“本地文件路径”,模板里体现的是前者,就必须用前者的规则来处理。

4.4 EJS 转义规则与项目代码里的特殊字符冲突

前面提过 EJS 的<%= %>会做 HTML 转义,这个坑我是在实际生成一个包含&字符的配置项时踩到的。模板里写了一行export const apiBase = '<%= baseUrl %>',用户输入的baseUrl是https://api.example.com/?a=1&b=2,生成出来的代码变成了a=1&amp;b=2,运行结果完全不同。

调试了很久才发现是 EJS 默认转义在作怪。解决的方法是统一改用<%- %>做无转义输出,同时在输入侧做校验和清洗。还有一个小坑是模板里的反引号。比如.ejs文件里写了一段const query = \${userId}`,它实际上是合法的 JavaScript 模板字符串,EJS 根本不会解析,因为 EJS 只认<%和%>。这个容易让人误以为写错了,但其实不用管。真正需要担心的是你把 EJS 变量插到了单引号字符串里,生成的代码如果包含引号,整体语法会坏。所以我的规范是:只要涉及把用户输入嵌入代码字符串,统一用JSON.stringify` 处理后输出。

4.5 CI 环境下交互式终端直接挂死

上线到 CI 的自动化流程后,第一次跑就挂了。日志显示在@clack/prompts的text调用处报了一个类似 “No TTY available” 的错误。原因很简单:CI 环境不是交互式终端,process.stdin.isTTY为 false,prompts 库没法读取用户输入,直接抛异常。

最直接的修复是给 t3code 增加--yes参数。当--yes存在,或者环境变量CI=true时,跳过所有交互,直接采用默认配置:

const isCI = process.env.CI === 'true'; if ((process.argv.includes('-y') || process.argv.includes('--yes') || isCI) && !projectName) { throw new Error('非交互模式必须显式指定项目名'); }

这个改动看起来不起眼,实际价值很大。它让 t3code 从“只适合手动初始化”变成了“可以接入自动化发布流程”的工具。现在团队内部的新项目初始化,甚至可以由一个 GitLab CI 任务直接触发,输入分支名就能生成新的项目文档、钉到内部 Wiki 上。

5. 验证与可维护性:让工具从“作者自用”变成“团队可信”

5.1 快照测试:先固化再重构

模板工具最怕的不是写不出来,而是改坏了没人知道。EJS 模板里一个变量的值变了,可能直接影响 100 个新生成项目的某个配置文件,而这种影响在生成时不会报错。为了解决这个问题,我为 t3code 加了一套快照测试。

具体的做法是:在仓库里放一组固定的输入配置和对应的预期输出目录,每次跑测试时用当前代码重新生成一次输出,然后和预期目录逐文件比对。任何一个文件的内容差异都会导致测试失败,这样模板被改坏时,我能立刻看到具体是哪个文件、哪一行变了。

import { describe, it, expect } from 'vitest'; import { generateProject } from '../src/generator'; describe('template snapshot', () => { it('full-stack template with prisma should match snapshot', async () => { const input = { projectName: 'demo-app', features: { prisma: true }, packageManager: 'pnpm' }; const output = await generateProject(input, { writeToDisk: false }); for (const file of Object.keys(output)) { await expect(output[file]).toMatchFileSnapshot(`snapshots/${file}`); } }); });

这套测试最大的价值在于心理层面。以前改模板时总是小心翼翼,现在可以大胆重构,反正跑一遍就知道有没有破坏已有内容。这不是技术上的炫技,是“可维护性”最实在的体现。

5.2 内置示例项目做端到端验证

快照测试只能验证生成结果正确,没法验证生成的项目真的能跑起来。为此我在模板包里放了一个examples/目录,里面是一个由 t3code 生成的示例项目。每次发版前,除了跑快照测试,我还会手动执行一次端到端验证:

  1. 用 t3code 生成一个临时项目
  2. 进入项目目录执行pnpm install
  3. 执行pnpm build,确认生产构建通过
  4. 最小化启动检查一次,确认页面和 API 路由都正常

这个过程我起了一个简单的脚本npm run e2e。虽然它不是最完善的自动化,但已经能拦截掉绝大多数问题。依赖版本变化、模板逻辑错误、配置冲突,都会在构建阶段暴露出来。

5.3 发布前检查清单

工具一旦给团队使用,发布就必须谨慎。我有了一份固定检查清单,每次发新版本都会逐项核对:

检查项说明
npm run typecheck确保 TypeScript 类型检查通过
npm run lint代码风格统一
npm run test跑完所有快照测试
npm run e2e端到端构建验证
npm pack --dry-run检查发布包里是否包含模板目录和点文件
本地npm i -g t3code.tgz用打包产物实测一次 init

npm pack --dry-run是我特别想强调的。npm 默认会忽略.gitignore文件,模板里的_gitignore还好,但如果模板包里包含.npmrc、.env.example,它们很可能在上传时被 npm 排除掉,导致工具装完之后找不到模板文件,生成直接失败。发布前执行一次--dry-run检查基础配置,能省掉很多线上问题。

5.4 下一步:插件化与模板版本锁定

目前 t3code 还远谈不上完美,但它已经不止一次在团队新项目初始化时帮我们省掉半天时间。按我的经验,这类工具最值得投入的下一个方向,不是功能越多越好,而是让模板版本和工具版本都锁定、可复现。现在模板已经作为 npm 包依赖集成,模板版本严格走 semantic versioning,工具发版时模板包版本也被固定,生成结果理论上完全可复现。

插件化是我规划中的下一步。目前的features还是布尔开关,以后可以做成插件注册表:团队内部的公共组件库、日志平台接入、监控 SDK 埋点,都做成独立插件,按需选择。这样 t3code 就不只是一个脚手架,而是团队工程化的入口。

最后分享一个比较个人化的心得:脚手架工具的核心不是“多牛的命令行界面”,而是模板本身。工具再花哨,模板质量不行,生成出来的项目照样返工。我后来把大部分精力从 CLI 交互转移到了模板目录的评审上,每次改动都当一次 code review 来做,快照测试兜底。如果你也想做类似的工具,建议你先忘掉命令行的炫技,把团队模板整理清楚再说。t3code 只是把那份整理好的模板,用代码的方式稳定地重复下去。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进