☰
t3code 开发复盘:从一条命令到全栈TypeScript脚手架
2026/10/27 7:27:06 网站建设 项目流程

前阵子一个朋友找我吐槽,说公司新项目从 create-next-app 起步,加上 tRPC、Tailwind、Prisma,光是把环境配通就花了一个下午。我听完顺手把 t3code 的仓库链接甩给了他。这个东西不是什么惊天动地的框架,而是一个面向全栈 TypeScript 项目的脚手架工具——做的事情很简单:一条命令,从一个空目录生成一套可运行的 monorepo,前端、后端、共享类型、数据库访问层全部就位。最近技术社区的热门讨论里经常能看到 t3code 这个名字,可能正是因为它解决的是大多数团队都绕不过去的初始化成本问题。

这篇文章就是 t3code 的开发复盘。我会从为什么写这个工具讲起,到选型时的取舍,再到核心实现流程和踩过的坑,最后用一组实测数据说明它到底值不值得用。适合正在维护内部脚手架、或者对 T3 Stack 感兴趣但不想每次手动拼装的人参考。

1. 从“用脚手架”到“写脚手架”:t3code 的起点

1.1 T3 Stack 为什么流行,以及它的隐藏门槛

T3 Stack 严格来说不是一个框架,而是一套组合拳:TypeScript 负责全链路类型基础,Tailwind 负责样式,Next.js 负责渲染和路由,tRPC 负责前后端接口调用。它的核心理念一句话就能概括——尽量少做抽象,让类型安全从数据库一路贯穿到浏览器。

这套组合对中小团队极其友好。传统前后端分离项目里,前端调接口要自己维护一份请求 schema,后端改了字段,前端往往要等联调才发现问题。tRPC 把 Router 定义放在共享包中,前端直接import type { AppRouter }就能获得完整的类型推断,服务端改了返回结构,客户端立刻报错。这个体验一旦用过就很难回头。

但它的隐藏门槛在于初始化。create-t3-app确实能生成一个单仓库项目,可实际工作中,团队往往需要 monorepo 结构、需要把数据访问层单独抽包、需要预设统一的 lint 规则和提交规范。拿官方脚手架生成完,还得自己折腾 pnpm workspace、Turborepo、多包之间的依赖关系。这些一次性工作每个项目都要重复一遍,而它恰恰最不该由人来重复。

1.2 t3code 给自己定的三条产品边界

做 t3code 的时候,我给这个工具定了三条硬性边界,后面所有设计决定都围绕这三条展开:

  • 10 分钟内从空目录跑到本地开发服务,包括依赖安装时间。
  • 类型安全默认贯穿:API 路由、数据库客户端、前端调用,任何一环断了类型都会在编辑器里直接报错。
  • 生成的代码必须是“可解释的”——不使用黑魔法式代码生成,团队成员打开任意文件都能读懂。

第三点尤其关键。很多脚手架生成器喜欢把代码做得高度抽象,生成结果像天书,出了问题没人敢改。t3code 的原则是“产物要像资深工程师手写的一样”。

同时我明确区分了“脚手架”和“框架”的边界:t3code 只负责生成初始结构,不注入任何运行时依赖。项目生成完之后,t3code 就彻底退场,你删掉这个工具也不影响已有项目继续开发。这一点后面会专门展开讲。

1.3 生成后的目录长什么样

默认生成的 monorepo 结构如下:

my-app/ ├── apps/ │ └── web/ │ ├── src/ │ │ ├── app/ # Next.js App Router 页面 │ │ ├── server/ # tRPC router 定义 │ │ └── styles/ │ ├── package.json │ └── next.config.mjs ├── packages/ │ ├── db/ # Prisma schema 与客户端 │ ├── auth/ # 会话与鉴权封装 │ └── ui/ # 共享 UI 组件 ├── pnpm-workspace.yaml ├── turbo.json ├── tsconfig.base.json └── package.json

这个结构不是拍脑袋定的。把db和auth抽成独立包,是为了让 API 服务和未来可能新增的 Worker 或后台任务共享同一套数据访问层;把ui单独拆出来,是为了避免页面组件和业务组件混在一起。真正的业务代码留在apps/web里,保持“薄模板、厚约定”的哲学。

2. 技术选型背后的权衡:不是所有“流行”都要照搬

2.1 pnpm workspace + Turborepo:monorepo 关卡的选择

在 monorepo 解决方案上,我对比过 npm workspaces、Yarn 与 pnpm。最终选了 pnpm,最主要的理由是它的严格依赖管理。

npm 和 Yarn 在安装依赖时会做依赖提升(hoisting),把某个子包的间接依赖提升到顶层 node_modules。开发时你以为你 import 了一个“全局可用的包”,实际上它只是恰好存在于同一目录的某个角落。换个包版本、加一个新依赖,这个“隐形的全局包”可能就消失了,报错极其难排查。pnpm 用符号链接把每个包的真实依赖关系显式挂在各自目录下,用不到的包就是不可见的。

Turborepo 则是任务编排层。它有两大价值:一是并行执行各包的 build、lint、typecheck,二是基于内容哈希做缓存——相同输入直接跳过任务,本地和 CI 都能命中缓存,速度提升非常明显。下图不是流程示意图,是 turbo.json 里最基础的缓存配置:

{ "$schema": "https://turbo.build/schema.json", "globalDependencies": [".env"], "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "lint": { "outputs": [] }, "typecheck": { "outputs": [] } } }

dependsOn: ["^build"]的意思是:构建某个包之前,先构建它依赖的所有包。这个配置在之后的实战中救了我很多次,稍后会讲它埋下的第一个坑。

2.2 生成器内核:文件复制优先,AST 改造靠后

脚手架生成器的核心问题只有一个:怎么把模板文件变成目标文件。

主流的方案有三条路。第一条是纯文件复制加简单字符串替换,最朴素,但遇到复杂场景会显得笨拙;第二条是基于 EJS、Handlebars 这类模板引擎做插值渲染,灵活度中等;第三条是引入 AST 改造,直接操纵代码语法树,理论上最精确,但实现复杂、产物难以预期,普通用户根本看不懂生成结果。

我的选择是“文件直拷 + 插值变量”的混合方案。模板文件保持普通 TypeScript/TSX 源文件的样子,只把需要动态替换的位置写成双大括号占位符,例如:

export const projectName = "{{ projectName }}"; export const databaseProvider = "{{ databaseProvider }}";

之所以没用<%= name %>这种模板语法,是因为模板文件里大量 JSX 的大括号语法很容易和模板引擎的标记产生冲突,调试时人容易懵。双大括号在普通源码里几乎不会出现,安全得多。

渲染阶段的核心函数很简单,但处理了不少边界情况:

import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import path from 'node:path'; async function renderDirectory(srcDir: string, destDir: string, vars: Record<string, string>) { const entries = await readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcDir, entry.name); const destPath = path.join(destDir, entry.name.replace(/^_/, '.')); if (entry.isDirectory()) { await mkdir(destPath, { recursive: true }); await renderDirectory(srcPath, destPath, vars); } else if (entry.isFile()) { let content = await readFile(srcPath, 'utf8'); content = content.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => vars[key] ?? ''); await writeFile(destPath, content, 'utf8'); } } }

这个函数里有一个用心的小细节:模板目录里以_开头的文件名在输出时会替换成.开头。因为真实的 dotfile(比如.gitignore、.npmrc)直接放在模板目录里会被很多工具和 Git 忽略,所以模板里叫_gitignore,渲染后变成.gitignore。

AST 改造不是完全不能用,只是在这个场景里性价比过低。生成器要处理的是样板代码,不需要读懂业务逻辑,字符串级别的替换就够用了。

2.3 交互层:在 commander 和 prompts 之间怎么分工

CLI 的参数解析和交互问答是两件事,我用了两个库分别处理。

参数解析用commander,它提供了成熟的子命令、flag 解析和帮助信息生成。交互问答用prompts,没有选inquirer是因为 prompts 更轻量、API 更现代,对单/多选、确认、文本输入的支持完全够用,且依赖树更干净。

两者的分工逻辑是:凡是可以在命令行参数里指定的选项,都优先走参数优先;只有当你什么都没给、直接裸跑命令时,才启动交互式问答。这样设计最符合 CLI 工具的使用直觉——有经验的人一条命令搞定,新手靠交互引导走完流程。

import { Command } from 'commander'; import prompts from 'prompts'; const program = new Command(); program .argument('[project-name]') .option('--pm <manager>', 'pnpm | npm | yarn', 'pnpm') .option('--db <provider>', 'prisma provider', 'sqlite') .option('--no-auth', 'skip auth package'); program.parse(); const options = program.opts(); const projectName = program.args[0]; const questions = []; if (!projectName) { questions.push({ type: 'text', name: 'projectName', message: '项目名称?', initial: 'my-t3-app' }); } if (!options.auth) { questions.push({ type: 'confirm', name: 'auth', message: '启用鉴权模块?', initial: true }); } const answers = await prompts(questions); const finalName = projectName ?? answers.projectName;

这段代码把“能确定的事都给你确定掉”,只有当信息不足时才问你,整个过程尽量不停顿。

3. 核心流程实现:一条命令从空目录到全栈应用

3.1 交互问题的顺序为什么这么设计

交互问答的顺序直接影响使用体验。t3code 的提问顺序经过几轮调整,最终固定成:

询问项选项示例影响范围
项目名称my-app目录名、package name、生成后的所有标识符
包管理器pnpm / npm / yarn依赖安装命令、lockfile 生成、后续所有脚本
数据库sqlite / postgresqlPrisma schema、docker-compose 文件、环境变量
是否启用鉴权是 / 否packages/auth 是否生成、前端是否挂载 session provider
是否生成 UI 包是 / 否packages/ui 是否存在

顺序的设计原则很简单:先决定“最贵”的决策。项目名称会影响所有文件的目录结构,所以最先问;包管理器影响安装命令,但只影响操作层面,排第二;数据库影响 schema 和基础设施,排第三。越靠后的选择,越不影响之前生成的内容——这样如果用户突然反悔,回退成本也最低。

这里有个容易忽略的细节:项目名不是直接用原样生成的,要做一次包名净化。用户输入My T3 App,package.json 里的 name 必须是合法 npm 包名my-t3-app,不能带大写和空格。净化的实现就几行正则:

function toPackageName(input: string): string { return input.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); }

3.2 模板渲染与文件生成的关键处理

文件生成环节有几个容易踩的坑,都是在真实跑过之后才补上的。

第一个是 package.json 的脚本替换。模板里的 package.json 是{{ projectName }}占位符,但里面还有pnpm dev、pnpm build这些原样保留的脚本字符串。用正则全局替换占位符时,要注意不能误伤包名以外的内容。这里的解决方案是只在特定 key 上做替换,而不是整文件扫一遍。

第二个是文件权限。对于可能在 Unix 环境直接执行的脚本文件,模板里需要保留可执行权限。但在 Windows 上开发的人往往不会意识到这个问题。我后来加了一个权限标记逻辑:模板文件里凡是描述文件名的注释写明EXEC,渲染后视环境chmod +x。当然,如果 Windows 用户拿到的产物同样有权限位保留,这是 Git 的core.filemode在起作用。

第三个是 CRLF / LF 换行符。Windows 上克隆下来的仓库会被 Git 自动转成 CRLF,渲染时如果不做归一化,生成的 shell 脚本会在运行时报错\r: command not found。解决方式是在写文件时统一用\n,并且在函数末尾保持utf8编码,不做额外转换。

第四个是目录非空处理。如果目标目录已存在且不为空,直接写入会把文件“丢”进别人的项目里。t3code 的处理是:先扫描目录是否非空,非空则抛错,提示用户加--force参数才会继续。--force也不是无脑覆盖,而是只写入不存在的文件,冲突文件列出来让用户手动确认。

3.3 依赖安装与首次运行检查

文件生成完成不等于流程走完,还要自动安装依赖并做健康检查。这部分在实现时被我一开始严重低估了复杂度。

安装依赖用的是spawn而不是exec,原因很简单:exec有最大缓冲区限制,pnpm install的输出量很大,一旦超过就会把子进程杀掉。spawn 可以流式处理输出,还能逐行显示进度给用户看。

import { spawn } from 'node:child_process'; function runCommand(cmd: string, args: string[], cwd: string): Promise<number> { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' }); child.on('exit', (code) => (code === 0 ? resolve(code) : reject(new Error(`exit code ${code}`)))); child.on('error', reject); }); }

安装完成之后,t3code 会自动执行三个验证命令:pnpm typecheck、pnpm lint、pnpm build。这一步很多人觉得多余,但我坚持保留它的原因很现实:如果不能保证“生成的代码天然通过检查”,那么工具生成的项目第一次跑检查就飘红,用户对工具的信心瞬间就没了。

启动环节还有一个端口冲突检测。Next.js 默认跑在 3000,如果用户本地已经有一个服务占着,生成的项目跑起来会直接报EADDRINUSE。t3code 会先检测 3000 端口是否被占用,占用时自动尝试-p 3001并且在最终提示里告诉用户。

4. 实测中踩过的三个坑:依赖提升、缓存与内容扫描

4.1 tRPC 的类型在 monorepo 里“失联”了

第一次完整跑通流程后,我发现生成的项目里前端 import tRPC Router 类型时报TS2307: Cannot find module '../server/router'。当时第一反应是 tsconfig 的 paths 配置写错了。我把 apps/web 的 tsconfig.json 打开检查,paths确实指向了正确的目录,文件也真实存在,但 TypeScript 就是找不到。

接着我去看 pnpm 生成的虚拟依赖目录结构,发现apps/web/node_modules下面根本没有packages/api的链接。这就指向了真正的问题:apps/web 的 package.json 里没有声明对 packages/api 的依赖。

这在 npm 时代完全不会暴露——npm 会把所有包的依赖“拍平”到顶层 node_modules,apps/web 想 import 什么都没人拦得住。用 pnpm 之后,严格的依赖隔离把这个隐藏问题彻底揪出来了。修复方式是一行声明:

{ "dependencies": { "@repo/api": "workspace:*" } }

这个坑让我很确定之前的选型是对的。依赖提升掩盖的不规范问题,晚暴露不如早暴露,越早期暴露修复成本越低。当我开始调用自己写的东西时,其实也是在做产品验证。

4.2 Turborepo 缓存让“改了共享包却不见生效”

第二次踩坑是另一个方向。运行了 t3code 生成的 monorepo,我在packages/api里改了一个 router 的返回值类型,回到apps/web想等等看新类型推断,结果编辑器怎么刷新都是旧类型。浏览器里跑 dev server,接口返回的确实已经是新结构,但前端的 TS 类型一直不变。

这个现象很迷惑。我先去清了.next缓存,没效果;又重启 TypeScript Server,也没效果。最后注意到运行pnpm build时 Turborepo 输出了一行cache hit, restoring from ...。

问题找到了:turbo 默认缓存了对packages/api的编译结果,因为它的输入(源码内容)没有变化——我改的是apps/web,它并不会重新构建packages/api。然后在 turbo.json 里补上依赖关系配置:

"build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] }

加上dependsOn: ["^build"]之后,构建 apps/web 前会先重新构建 packages/api,并且如果 packages/api 的内容哈希变了,缓存自动失效。这个配置是 Turborepo 使用里最常见的坑之一,修复之后一切回归正常。

4.3 Tailwind 内容扫描把 monorepo 里的共享组件样式丢了

第三个坑是样式层面的。启用 packages/ui 之后,我在共享组件里写了一个bg-blue-500的类名,生成项目里跑起来,浏览器里完全没有这个样式。

排查链路大概是这样的:打开浏览器看元素的计算样式,发现 Tailwind 工具类根本没出现在最终 CSS 里。然后我在 monorepo 根目录跑npx tailwindcss -p -c apps/web/tailwind.config.ts,手工检查生成的 CSS,发现bg-blue-500缺失。最终定位到根因:apps/web 的 Tailwind 配置里 content 路径只覆盖了./src/**/*.{ts,tsx},并没有包含../../packages/ui/src/**/*.{ts,tsx}。

Tailwind 的类别扫描器不看 Node 依赖关系,只看文件路径。共享包的源码不在 web 包的 content glob 范围内,它自然完全不知道这个类名存在。修复如预期:

// apps/web/tailwind.config.ts export default { content: [ './src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}', ], theme: { extend: {} }, plugins: [], };

这个坑再次提醒我 monorepo 里的配置文件都不只是“自己这一个包的事”,所有涉及路径扫描的工具,在 monorepo 里都要把依赖包的源码目录显式加进去。

5. 生成项目的实际效果:一个 9 分钟跑通的示例

5.1 实测命令与耗时

为了写这篇文章,我在一台没有缓存的全新机器上完整跑了一遍 t3code 的初始化流程。机器配置是 M1 MacBook Pro,网络环境是普通家用宽带。

步骤命令耗时
生成项目骨架pnpm create t3code my-app2.1s
安装依赖pnpm install(约 600 个包)3m12s
类型检查pnpm typecheck8.4s
Lintpnpm lint3.2s
构建pnpm build51s
启动 dev serverpnpm dev1.8s 到可访问页面

从执行命令到浏览器出现页面,总计约 4 分 20 秒。之所以标题里说“9 分钟”,是因为我加上了阅读生成代码和简单修改一个页面文案的时间。真实场景里,一个团队拿到这个脚手架后,当天就能开始写业务代码,这个启动速度是符合我对“脚手架”的预期的。

这个耗时的关键瓶颈在网络依赖下载,不是生成器本身。如果你是第一次跑,建议先执行一次pnpm store预热,或者准备一份 Curated Lockfile 提交到仓库,后续初始化的速度会大幅提升。

5.2 类型安全“断链检测”实测

生成之后,我故意做了一个“破坏性测试”:把packages/api里的一个 router 返回类型从{ name: string }改成{ username: string },不告知前端,观察问题什么时候暴露。

在传统 REST 架构下,这个改动通常要等到联调时才被发现——除非你们团队有严格的 OpenAPI 代码生成流程。但在 t3code 生成的体系里,前端调用的代码几乎立刻在编辑器里出现了红色波浪线,提示Property 'name' does not exist on type ...。整个过程不需要跑任何命令,不需要刷新页面,完全靠 tRPC 的类型推导链完成。

这背后的原理值得说清楚:tRPC 的 AppRouter 类型导出之后,前端通过import type { AppRouter }获得的是一个递归的、完整的类型结构,并且它是编译期的,不依赖运行时反射。只要你后端 Router 定义有变化,前端类型必然同步变化。这就是“类型安全贯穿全栈”的实际含义。

5.3 没有外网 npm 时怎么离线初始化

不是所有环境都能直连 npm 官方源。我在内网环境测试时总结了三条降级路径。

第一条是配置内部 npm 镜像源。在项目根目录写一个.npmrc,指向团队内部的 registry 地址,之后 pnpm install 就会走内部源。

第二条是使用 pnpm 的离线缓存机制。先在能联网的机器上执行pnpm install --frozen-lockfile完整下载一次,然后把pnpm store目录和 lockfile 一起拷到离线机器上。离线环境执行 install 时,pnpm 会在 store 里找包,命中则不会发起网络请求。

第三条是锁定版本。t3code 生成的pnpm-lock.yaml会锁死所有依赖的具体版本,这保证了内网镜像即使内容有偏移,依赖解析也不会出现跨版本的意外。

三种方案按成本从低到高排序,绝大多数团队用第一种就够了。如果连镜像都没有,那就只能用第二种物理带包的方式了。

6. 从 v0.1 到 v0.3:哪些设计被证明是对的

6.1 保持模板“薄”,约定“厚”

第一版 t3code 的模板是“大全式”的,几乎把一切可能的样板代码都塞了进去:完整的用户 CRUD、多角色权限、一堆配置文件。结果实测反馈并不好,用户改一个需求要在生成的十几层结构里翻找,上手成本反而更高。

v0.2 之后我砍掉了大量默认生成的业务代码,保留的只有:鉴权占位、tRPC 示例、数据库 schema、基础 UI 组件。最终设计原则是“模板提供最小可运行骨架,约定提供扩展路径”。比如数据库 schema 里只放一个示例模型,注释里写清楚新增模型的标准写法,项目代码里大量用注释说明“应该放在哪里、下一步做什么”。生成器给用户的不是答案,而是起点。

6.2 错误提示要能“直接抄答案”

CLI 工具最容易犯的毛病是错误信息写得像给机器看的调试日志。早期 t3code 出现依赖缺失时,错误信息是:

Error: spawn pnpm ENOENT

用户看到这种输出根本不知道下一步该干什么。后来我改成了:

检测到未安装 pnpm。请先执行: npm install -g pnpm

这句话把“发生了什么”“为什么发生”“怎么解决”三件事一次说完。类似的改进遍布整个工具的异常分支。经验是:CLI 里的每个错误提示都默认读者是你自己团队里最不熟悉命令行的人,直接把修复命令写出来,比解释一百字都有用。

6.3 工具要能被“卸载”

这句话是我在 v0.1 完成时就想明白的原则,后来所有功能迭代都以它为底线。

所谓“能被卸载”,是指 t3code 生成的项目里不存在任何“离开 t3code 就活不下去”的东西。模板渲染完成后就变成了普通的手写项目,没有特殊的运行时依赖,没有隐藏钩子,没有需要持续环境下才能跑的逻辑。这意味着你可以保留 t3code 生成的产物,但彻底删除 t3code 本体,后续开发不受任何影响。

为此我专门做过一次“自杀测试”:用 t3code 生成一个项目,然后关闭 t3code 的 npm 包,仅保留 node_modules 和源代码,重新安装依赖后完整运行一遍 build、dev、test。如果失败,说明生成器往代码里塞了不该塞的东西。这个测试从 v0.1 到 v0.3 每次都通过。

这也是我想说的最后一条体会:脚手架工具最核心的价值不是炫技,而是让使用者拿到一个“可以被理解和接管”的起点。t3code 目前还在持续迭代,下一步计划是加入可插拔的模板扩展机制,让不同团队可以把自己的内部约定打包成模板预制件。如果你也在维护类似的工具,或者对生成器的某些设计有自己的见解,欢迎在评论区聊聊,踩坑经验互相交换总是最有价值的。

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

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

立即咨询