相信不少用 TypeScript 做全栈开发的朋友都有同感:T3 Stack 这套组合(Next.js + TypeScript + Tailwind CSS + tRPC)第一次用的时候是真香,类型从数据库一路通到前端组件,整个开发链路几乎不需要 any。但香归香,新项目每开一次,问题就重复一次——create-t3-app 生成的骨架是标准的,目录结构、错误处理、数据库连接、API 统一返回格式这些"项目级基础设施"却不在它管的范围里。你只能复制上一个项目的代码,再手动改一堆名字。次数多了,人总会烦躁,所以我干脆抽了一个周末,写了 t3code 这个脚手架工具。
t3code 的目标很直接:在 create-t3-app 的基础上,把常用的项目模板、依赖和约定化代码一键落进新项目里,省掉每次开局两三个小时的重复劳动。这篇文章会把 t3code 的设计动机、核心实现和踩坑过程完整拆开来讲,适合正在纠结"要不要自己搞一套脚手架"或者"模板怎么维护才不烂尾"的朋友。
1. 从"每次手动配一遍"到"一条命令生成项目":t3code 的诞生背景
1.1 T3 Stack 项目开局时最耗时间的三个环节
先说我自己的真实经历。用 create-t3-app 初始化完一个新项目之后,它给你的是干净的骨架:Next.js 目录、trpc router、prisma schema、tailwind 配置。这些都对,但离"能直接开发业务"还很远。我每次至少要补三块东西。
第一块,数据库接入的完整链路。create-t3-app 会问你用不用 Prisma,但不会帮你把连接池、重试逻辑、开发环境数据库的启动脚本安排好。我习惯本地用 Docker 跑 PostgreSQL,所以每次都要新建 docker-compose.yml,写 .env 里的 DATABASE_URL,再在 package.json 里加两个 script:db:up和db:init。这个环节至少二十分钟,而且每次都是复制粘贴,稍微改个端口就得查半天文档。
第二块,API 层的约定。不管是 tRPC 还是 REST,真实项目里都需要统一返回结构、错误码、日志中间件。create-t3-app 给的 createTRPCContext 和 errorFormatter 只是最基础的空壳,我需要自己往里面塞 requestId、统一异常映射、慢查询告警之类的逻辑。这部分代码一旦写成,基本所有项目都不会再改,但它就是没法被 create-t3-app 覆盖到。
第三块,前端基础设施。比如 fetch 封装、react-query 的全局配置、Toast 组件、布局组件、权限路由守卫。这些在项目之间高度相似,却又是纯体力活。
三块加起来,一个新项目"能跑业务"之前,差不多要额外花两三个小时。对我这种频繁开原型项目的人来说,这个时间实在不划算。t3code 的出发点就是用一条命令把这些体力活自动化。
1.2 t3code 的定位:不是替代 create-t3-app,而是它的"增强层"
在动手之前我明确了一个原则:不重复造 create-t3-app 的轮子。原因很简单,create-t3-app 背后有一整个社区在维护,Next.js、tRPC、Prisma 升级的时候,它会及时跟进。我如果自己重新实现一套完整的项目初始化逻辑,光是跟着上游版本跑就得累死。
所以 t3code 的定位是"增强层":它仍然依赖 create-t3-app 的产出,生成基础骨架之后,再把自己维护的那套增强模板叠加进去。你可以把它理解成装修公司里铺地板和刷墙的活,地基还是开发商打的。这样一来,我的维护成本集中在"增量部分":统一的错误处理、数据库脚本、目录约定、CI 配置。这些内容变化频率低,我可以完全掌控。
这个定位决定了整个项目的架构走向:t3code 不直接生成项目,它做的是"拉取基础模板 + 渲染增强层 + 执行后置脚本"三件事,后面所有设计都围绕这个流程展开。
1.3 设计目标与范围边界
定完定位,我给 t3code 列了几个硬性目标,写在了项目 README 最上面:
- 一条命令完成"基础脚手架 + 增强模板 + 依赖安装 + 环境初始化",全程交互式问答不超过 6 个问题。
- 生成的项目必须能直接
pnpm dev跑起来,不需要再手改任何配置。 - 模板内容用独立的 Git 仓库维护,t3code 本体与模板内容解耦,避免发版耦合。
- 支持非交互模式(--yes),方便脚本化和 CI 测试。
- 跨平台可用:Windows、macOS、Linux 都要没问题。
同时我明确划掉了几个不做的范围:不做 monorepo 管理,不做后端服务端的脚手架,不做云平台一键部署。这些不是不重要,是贪多必失,先把单项目脚手架做到极致再说。
2. 架构选型与项目布局:CLI 本体、模板仓库、生成器三分离
2.1 为什么单独维护模板仓库,而不是把模板打进 CLI 包
这是 t3code 架构上最重要的一个决定。我最早的想法特别朴素:把模板文件直接放进 CLI 项目的 templates 目录,打包发布到 npm,用户装完 CLI 本地就有模板了。听起来很顺,但实际操作一周后就发现不行。
问题出在版本耦合上。CLI 版本是 0.x 快速迭代的,但模板内容的更新频率比 CLI 高得多——比如我加了一个新的 API 错误处理中间件,或者把 Prisma 连接池参数调了一下。如果每次改模板都要发一个 CLI 新版本,npm 包版本号会涨得飞快,而且用户得主动升级 CLI 才能拿到新模板,太反直觉。
后来我改成双仓库结构:t3code 本体仓库只放 CLI 代码,模板仓库 templates 单独维护。CLI 运行时直接从模板仓库拉取代码,默认拉最新的,也可以指定--template-version v1.2.0锁定版本。这样模板更新只动模板仓库,CLI 不用每次发版,用户拿到新模板也只需要重新跑一下命令。实测维护成本至少降了一半。
除了解耦,单独模板仓库还有一个附带好处:我可以在模板仓库里放自己的测试项目,直接跑 CI 验证模板在任何 Node 版本下都能构建。CLI 仓库和模板仓库各自有独立的 CI,互不干扰。
2.2 CLI 技术栈选型:Commander、Inquirer、Handlebars
选 CLI 技术栈这件事,我纠结了几天,最后定下来三个库,都很成熟:
- commander:处理命令行参数和子命令。t3code 目前只有
new、list、doctor三个子命令,commander 完全够用。 - inquirer:做交互式问答。它支持单选、多选、输入框、confirm 四种基础交互,满足我的 6 个问题绰绰有余。
- handlebars:模板渲染。选它是因为支持条件块、循环、自定义 helper,而且语法简洁,模板文件写起来不太费劲。
这三个库加起来不到 5MB 依赖,CLI 本身用 TypeScript 写,编译成 CommonJS 发布。启动速度我测过,交互模式从敲命令到出现第一个问题大概 300ms 左右,体感算很快了。
有一个小细节值得提:inquirer 的跨平台交互依赖 readline,在 Windows 上有时会出现箭头键错乱的问题。如果读者碰到,先更新 inquirer 到 v9+,新版对 Windows Terminal 的支持好很多。我自己的开发机有 macOS 也有 Windows,实测 v9 之后没再出过问题。
2.3 仓库结构与模板组织方式
CLI 仓库的 src 目录结构是这样的:
src/ commands/ new.ts # 新建项目主流程 list.ts # 列出可用模板分支 doctor.ts # 检查本地环境(node 版本、pnpm、docker) core/ prompter.ts # 交互问答定义 renderer.ts # 模板渲染核心 installer.ts # 依赖安装与后置脚本 gitHelper.ts # 拉取模板仓库 utils/ nameCase.ts # 项目名转各种命名风格 fsWalk.ts # 目录遍历 index.ts # CLI 入口模板仓库这边的组织方式,我按"版本分支 + 功能切片"来管理。主干分支是"最干净的基础增强模板",只包含错误处理、目录约定、数据库脚本这些通用部分;prisma、next-auth、eslint 增强这些属于可选切片,每个切片是一个独立的目录,生成时按用户的选择决定要不要复制进去。
为什么不用 Git 分支来区分功能?因为功能之间会组合。比如用户要"prisma + next-auth",如果用分支,就得维护 prisma-auth 这个组合分支;如果将来再加一个 redis 切片,组合数量会爆炸。用目录切片 + 复制逻辑,组合关系由 CLI 代码控制,模板仓库只需要维护互不影响的切片即可。这个设计让我后期加功能时轻松很多。
3. 核心功能拆解:交互问答、模板渲染、依赖安装的完整流程
3.1 交互项设计:哪些问题该问,哪些不该问
t3code 设计之初我给自己定了一条规矩:交互问题不超过 6 个。问得越少,越容易用脚本自动化;问得越多,用户越烦。我最后保留的 6 个问题分别是:
- 项目名称(必填,非交互模式下用参数指定)
- 包管理器:npm / pnpm / yarn
- 是否启用 Prisma + PostgreSQL 基础设施
- 是否启用 NextAuth 集成
- 是否生成 CI 工作流(GitHub Actions)
- 是否需要安装开发调试工具(如 trpc playground、swagger-ui)
这些问题背后有一条默认逻辑:凡是能在配置文件里写的,绝对不问用户;凡是能给合理默认值的,先给默认值。比如"是否需要 Docker 环境"这个问题,我直接默认需要,因为本地开发用 Docker 跑数据库是绝大多数项目的配置,真有人不需要,他可以在生成后手动删掉 docker-compose.yml,成本很低。
这里有个反直觉的经验:问题越少,用户对你工具的信任感越强。我第一版问过 12 个问题,后来自己用的时候都觉得烦,果断砍到 6 个。现在每次生成项目,从输入命令到项目跑起来,基本稳定在 3-5 分钟。
3.2 模板渲染机制:占位符、条件块与命名转换
模板渲染是 t3code 的核心。我的做法是:模板仓库里的文件分两类,一类是纯文本直接复制(比如 .gitignore、.prettierrc),另一类是含 Handlebars 语法的模板文件,扩展名统一用 .hbs。
举个例子,docker-compose.yml.hbs 的内容长这样:
version: "3.8" services: db: image: postgres:16-alpine environment: POSTGRES_USER: {{dbUser}} POSTGRES_PASSWORD: {{dbPassword}} POSTGRES_DB: {{dbName}} ports: - "{{dbPort}}:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:渲染的时候,renderer 会扫描整个目标目录,遇到 .hbs 文件就用 handlebars 渲染,然后把输出文件名的 .hbs 后缀去掉;遇到普通文件就直接拷贝。这里有个重要的细节:自动拷贝目录时,一定要跳过 .git、node_modules、dist 这类目录,否则会把模板仓库的 Git 历史带进用户项目里,产生一堆垃圾文件。
命名转换我单独写了一个 nameCase 模块。因为用户输入的项目名通常是 kebab-case,比如 my-blog-app,但代码里需要几种不同的命名:
- 目录名、package.json 的 name:保持 kebab-case
- TS 变量名、常量名:转成 camelCase(myBlogApp)
- React 组件名、类名:转成 PascalCase(MyBlogApp)
- 环境变量前缀:转成 SCREAMING_SNAKE(MY_BLOG_APP)
这套转换逻辑在渲染时通过 handlebars helper 暴露给模板,比如模板里写{{pascalCase projectName}},renderer 在注册 helper 时把 projectName 变量的值传进去,就能在任意文件里自动换名字。模板里需要用到项目名的地方,统一走这套 helper,绝不用手写拼接。
3.3 生成后的自动化流程:装依赖、初始化数据库、提交 Git
模板渲染完,项目目录已经存在了,接下来是"收官三步"。
第一步,安装依赖。这一步不用 npm,而是根据用户选的包管理器执行pnpm install或npm install。安装过程用 spawn 子进程执行,把 stdio 直接继承给用户终端,这样用户能看到真实的安装进度,而不是干等。选 pnpm 的原因除了快,还有一个硬理由:pnpm 的 symlink 结构对本地开发调试更友好,生成的 lockfile 也更稳定。
第二步,初始化数据库。如果用户选了 Prisma,我会在生成结束后依次执行prisma generate和docker compose up -d,等数据库起来后用prisma db push把 schema 推进去。这一步最容易出问题,所以我在代码里加了健康检查:轮询 PostgreSQL 的 5432 端口,最多等 30 秒,起不来的话直接报错,并提示用户检查 Docker 是否运行。
第三步,初始化 Git 仓库并做首次提交。很多人会忽略这一步,但新项目没有 Git 历史,后续开发体验会很难受。t3code 会在项目根目录执行git init && git add -A && git commit -m "chore: init from t3code",用户拿到手就是一个干净、有初始提交的仓库。
这三步全部成功之后,CLI 会打印一段简短的启动提示,告诉你pnpm dev就可以开了。
4. 落地过程中踩过的坑:版本漂移、跨平台、命名规范
4.1 模板仓库与脚手架版本漂移:试过三种方案才找到平衡点
第一个坑是版本漂移。create-t3-app 差不多每两三个月就会升一次主版本,Next.js 一升级,App Router 的写法可能变,tRPC 的初始化代码也可能变。如果模板仓库里的基础骨架是三个月前拉的,生成的代码在新的 create-t3-app 版本下可能直接编译失败。
我一开始的方案是"不锁版本,每次生成时实时拉最新 create-t3-app 模板"。听起来很灵活,但实际用下来问题更大:生成的结果不可复现。同一个 t3code 版本,周一生成的项目和周五生成的项目,底层骨架可能差了一个大版本,后续排错非常头疼。
现在的方案是在模板仓库里做"版本快照 + 分支管理"。每次 create-t3-app 大版本更新,我在模板仓库开一个新的版本分支,比如base-next-15、base-next-14,并把对应的基础模板固定进去。t3code 默认取最新的稳定分支,但用户可以在命令里用--base-version next-14指定旧版本。
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 实时拉最新模板 | 永远最新 | 结果不可复现,排错困难 | 放弃 |
| 锁死一个版本 | 最稳定 | 升级成本高,跟不上上游 | 不推荐 |
| 版本快照 + 分支管理 | 兼顾可复现与可升级 | 需要维护分支 | 采用 |
这个坑给我的教训是:任何脚手架工具,可复现性比"最新版本"重要得多。用户要的是一个能稳定工作的项目,不是你帮他追新版本的义务。
4.2 Windows 与 macOS 的路径和脚本差异:一个小细节能省一天
我主要开发环境在 macOS,最早写 fsWalk 时直接用了 Node 的path.join,大部分逻辑没问题。但坑出现在两个地方。
第一个是模板仓库里的脚本。最开始我在模板里写了几个 shell 脚本,比如scripts/dev-db.sh。这个脚本在 macOS 上跑得很欢,Windows 上直接报错——因为 Windows 没有 bash,也没有 /dev/null。后来我把所有 shell 脚本替换成 Node 脚本(比如scripts/dev-db.mjs),用 Node 的 child_process 去执行 docker compose,跨平台立刻稳定了。如果你也在维护脚手架,记住这个经验:能在 Node 里做的事,尽量不要依赖 shell 脚本。
第二个是文件路径分隔符。我在渲染时需要把一些"相对路径"字符串写进 tsconfig 或者 import 语句,最早直接用了 path.join 的结果,在 Windows 上产出src\lib\trpc这种带反斜杠的路径,放进 import 语句里直接报废。正确做法是路径统一转成正斜杠再写进代码文件,比如path.join(...).split(path.sep).join('/'),或者直接手动拼接正斜杠。这个坑很隐蔽,不跨平台测一遍根本发现不了。
4.3 包名、目录名与组件名的命名规范统一
第三个坑是命名。用户输入的项目名五花八门,有的带下划线,有的带点,有的甚至首字母大写。如果直接把My.Project塞进 package.json 的 name 字段,npm 虽然能接受,但有些工具链会炸。
我的 nameCase 模块里加了一层"规范化管道":
- 输入先转成 kebab-case:所有非法字符转成连字符,连续连字符压缩成一个,首尾连字符去掉。
- 校验合法性:如果规范化后是空字符串,直接提示用户重新输入。
- 基于规范化的 kebab-case 再派生出 camelCase、PascalCase、SCREAMING_SNAKE 三种命名。
这套管道处理之后,之前遇到的诡异情况基本都消失了。唯一还留的一个已知限制:camelCase 派生时,如果项目名里含有连续两个以上的连字符,转换结果可能不符合直觉,比如my--app会变成myApp。所以我在生成时会顺带提醒用户:项目名尽量用标准的 kebab-case,别整花活。后来干脆在前端校验里直接拒绝包含连续连字符的输入,省得后面解释。
5. 验证与回归:我如何保证 t3code 生成的项目开箱即用
5.1 用临时目录批量生成的自动化测试
脚手架工具最怕的就是"你改了 A 功能,B 功能悄悄挂了"。我的做法是写一套批量生成测试脚本,每次 CLI 代码有改动,自动跑一遍。
脚本的逻辑很简单:在系统临时目录里创建 N 个测试目录,每个目录用非交互模式生成一个不同配置组合的项目(比如一组 pnpm + prisma + no-auth、一组 pnpm + no-prisma + auth),然后对每个项目依次执行三条命令:
pnpm install pnpm exec tsc --noEmit pnpm exec next build三行命令全部通过,这个配置组合才算合格。如果任何一个项目出现类型错误、构建失败,CI 就标红。这套脚本救了我不下五次,最典型的是有一次我改 renderer 的正则表达式,普通文件复制路径偶尔会带上 .hbs 后缀,导致next.config.hbs直接被当成配置文件,构建直接崩了。没有批量测试,这种问题很难被发现。
另外我还在 CI 里加了 Node 版本矩阵:Node 18、20、22 各跑一轮。因为模板里有些依赖对 Node 版本有要求,提前在矩阵里抓到问题,总比用户在自己机器上报错再找我来得强。
5.2 人工验收清单:生成项目后我还盯着看什么
自动化测试只能覆盖"能不能编译",覆盖不了"体验顺不顺"。我自己每次给模板加一个新切片时,还会按一份人工验收清单过一遍:
- 生成后的项目目录里有没有多余文件(比如不小心拷进来的 .git、README 里残留的旧项目名)。
pnpm dev启动后,首页能否正常渲染,tRPC 的 health check 接口能否返回 200。- 数据库容器是否自动起来了,Prisma 的 seed 脚本能否跑通。
- 如果启用了 NextAuth,登录页能否正常跳转,session 能否写入数据库。
- 错误处理中间件是否能正确返回统一错误结构,而不是默认的堆栈。
这份清单看起来很基础,但真的能拦住很多自动化测不出来的体验问题。有一次我在模板里加了国际化配置,自动化测试全绿,但人工验收时发现浏览器语言切换之后布局错乱——因为那个组件里有个硬编码的宽度。这种问题只有人眼点开页面才看得出来。
5.3 后续可以扩展的方向:monorepo、插件机制、模板搜索
t3code 目前的版本能用、够用,但离理想中的状态还有距离。我列了三个最想做的方向。
第一个是 monorepo 支持。很多产品项目不止一个应用,会有 admin、web、api 分包。如果能用 t3code 一次生成 monorepo 结构(比如用 pnpm workspace + turbo),会比单项目脚手架再手动拆分省事很多。
第二个是插件机制。现在加新切片的方式是直接在模板仓库里加目录 + 在 CLI 代码里写复制逻辑,本质上还是硬编码。如果做成插件机制,让每个切片自带"目录 + 渲染规则 + 后置脚本",用户以后想贡献自己的模板就不用改 CLI 内核了。
第三个是模板搜索与发现。类似 create-t3-app 的社区模板列表,t3code 可以提供一个交互式"浏览社区模板"的命令,让用户直接选择别人的项目模板来生成。这会把 t3code 从个人工具变成一个平台,不过对维护和内容审核的要求也会高很多,我倾向于先做好单项目体验再说。
最后聊一点个人体会。t3code 这个项目让我最意外的收获,不是省了多少时间,而是它逼着我把"项目基础设施"这件事彻底想清楚了。以前每次手动配 Docker、错误处理、CI,其实都是一种无意识的重复,从来没有系统思考过"我到底需要什么、为什么需要"。当我必须把这些问题变成模板、变成代码、变成测试时,我才意识到,很多所谓的习惯,其实只是懒得优化的默认路径。
如果你也在纠结要不要自己搞一套脚手架,我的建议是:先别想着做通用平台,先把自己的痛点写成脚本,跑通两三个真实项目再决定要不要产品化。t3code 就是从一个小脚本长成现在这个样子的。工具不一定要多复杂,能让你自己每周省下三小时,就已经值回票价了。