☰
t3code:一套可复用的全栈项目模板与自动化初始化实践
2026/10/8 3:12:09 网站建设 项目流程

t3code 这名字听起来像个产品代号,实际上它只是我本地项目仓库里的一个内部前缀。前阵子清理硬盘,发现过去一年里我新建了二十多个项目,每个项目都在重复同一套配置:ESLint、格式化、类型检查、目录结构、提交规范……版本还不一致,有的项目用 ESLint 8、有的用 9,有的缩进两格、有的四格,改起来极其痛苦。后来我把手头最顺手的模板整理成了一个自动化初始化工具,代号就叫 t3code。这篇文章就把这套东西的整体设计和实现过程拆开讲一讲,包括我踩过的几个坑,以及为什么某些地方最终选择了看起来不那么“炫酷”的做法。

如果你是一个经常需要从零搭建项目的全栈开发者,或者正在纠结怎么管理自己的多套模板,这篇内容应该能给你省下不少时间。t3code 不是一个 npm 包,也不是需要推广的产品,它就是一个完全私人的工程化实验——但实验过程中总结出来的思路,我认为无论对个人项目还是团队协作,都有参考价值。

1. t3code 的由来:为什么我用这个代号重新整理全栈模板

1.1 代号里那个“t3”到底指什么

提到 t3,懂行的人可能会想到 T3 Stack,一个把 TypeScript、Tailwind、tRPC 组合起来的前后端一体技术栈。我不否认当时起名确实受它启发,但 t3code 落到我手里,含义被改成了另外三件事:Three-tier(三层结构)、Testable(可测试)、Tidy(整洁)。

这不是咬文嚼字,而是我在整理旧项目时真真切切感受到的三个痛点。

  • Three-tier:我不希望业务代码和配置代码混在一个大目录里,前端、后端、共享逻辑必须从目录结构上就分清楚;
  • Testable:每个项目初始化之后,必须能立刻跑通 lint、类型检查、单测这三件事,不用我再手动装插件、配环境;
  • Tidy:不管新项目还是半年没碰的老项目,打开任何一个文件的代码风格必须一致,配置文件也不能各写各的。

说白了,我希望 t3code 生成的每一个项目,不管业务怎么变,骨架和基建是完全统一的。这跟“代码洁净”不是一个概念——前者是流程治理问题,后者是编码审美问题,我管的是前者。

1.2 旧模板留下的那笔乱账

在决定写 t3code 之前,我统计了一下自己所有个人项目的共同文件。结果很有意思:几乎每个项目都有.eslintrc或eslint.config.js,但里面的规则各不相同;几乎每个项目都有.prettierrc,但有的用双引号、有的用单引号;几乎每个项目都有tsconfig.json,但有的继承了@tsconfig/node20,有的从零声明的。

这不是我懒,而是每次建项目时都是“从 GitHub 上某个旧仓库复制一份,再按需改一改”。复制三次以后,模板自身就发生了漂移,从“一个模板”变成了“二十个互不相同的模板”。

更要命的是,这些旧模板里还混了些临时解决方案,比如某个项目里为了绕过类型报错写的any大法,被复制到新项目后完全没有发挥应有的限制作用。等到出了问题再回头查,根本说不清哪段代码是刻意为之,哪段代码是历史垃圾。

所以我决定做一次彻底收敛:只保留一个唯一的模板源,所有新项目都由它通过脚本生成,不允许手动复制旧项目当模板。t3code 就是为这个流程服务的工具。

2. t3code 的目录骨架与模块边界设计

2.1 按“能力”而不是按“技术栈”拆模块

很多项目喜欢按技术栈分目录,比如frontend/、backend/、database/。表面上看很清楚,但项目一大就出问题:后端目录里可能夹杂着被前端直接引用的类型定义,前端目录里可能藏着模拟数据的工具函数。两个模块之间的依赖关系没有规则,全靠自觉。

t3code 采用的是按“能力边界”划分的 monorepo 结构。初始化完成后,项目目录长这样:

my-project/ ├── apps/ │ ├── api/ │ │ ├── src/ │ │ │ ├── routes/ │ │ │ ├── services/ │ │ │ └── middleware/ │ │ └── package.json │ └── web/ │ ├── src/ │ │ ├── app/ │ │ ├── components/ │ │ └── lib/ │ └── package.json ├── packages/ │ ├── core/ │ │ └── src/ # 纯业务逻辑、工具函数、领域模型 │ ├── types/ │ │ └── src/ # 跨端共享的 TypeScript 类型定义 │ └── config/ │ └── src/ # lint、格式化、构建相关的共享配置 ├── docs/ │ └── decisions.md ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json └── turbo.json

这里的关键区别在于:apps/api和apps/web只负责“接入层”的工作——接受请求、渲染页面、调用 packages 里的业务能力。所有实际逻辑放在packages/core,所有跨端契约放在packages/types。这样改前端的时候不用担心把后端逻辑打成死结,测试也只需要盯住 core 包。

2.2 类型与共享逻辑的放置纪律

有人会问:为什么不直接建一个src/shared目录,把公共类型放进去?我最初也这么干过,但很快发现两个问题:

第一,src/shared和apps/web/src、apps/api/src处于不同的物理路径层级,引用路径写得很长,而且一旦某个模块同时被多个 app 依赖,Webpack 或 Vite 的构建缓存就很难管理;第二,如果把 shared 放在某个 app 的内部,另一个 app 引用它时会有种“侵入别人地盘”的感觉,容易导致互相依赖。

t3code 的做法是强制三层依赖规则:

  1. apps/*可以依赖packages/core和packages/types;
  2. packages/core只能依赖packages/types,不能反向依赖任何apps/*;
  3. packages/types不依赖任何运行时库,只包含纯类型声明。

这条规则我直接用 ESLint 的import/no-restricted-paths写进配置里,违反规则会在pnpm lint阶段直接报错。一开始会觉得麻烦,但跑过两三个项目后就会发现,这种约束省掉的可不只是依赖顺序问题,它等于给项目的依赖图画了一条清晰的边界线,让重构时心里有底。

2.3 配置文件的收敛:一处改动,全仓生效

旧模板时代,每个子项目都有自己的tsconfig.json、eslint.config.js、.prettierrc。它们看起来差不多,但又永远差一点:根目录include的路径不一致、lint 忽略文件列表不一致、prettier 的endOfLine设置不一致。这些“不一致”平时不疼不痒,一旦有人提交了跨平台的换行符修改,整个 diff 就会变得没法看。

t3code 把三份最核心的配置抽成了共享包:

  • packages/config/tsconfig.base.json—— 定义严格模式、路径别名、目标版本等基础编译选项;
  • packages/config/eslint.config.js—— 统一的 lint 规则,子项目只负责添加自己的文件范围;
  • packages/config/prettier.config.js—— 统一引号、缩进、行尾符。

每个子项目的tsconfig.json只需要写三行:

{ "extends": "@t3code/config/tsconfig.base.json", "compilerOptions": { "outDir": "./dist" } }

这份收敛带来的最直接感受是:改一次共享配置,所有 apps 和 packages 全部生效。不用再因为某个子项目忘了同步配置,导致 CI 里出现一处诡异报错。

3. 核心实现:初始化工具的选型与编码思路

3.1 为什么没有写成独立的 npm 包

很多人会理所当然地认为,这种项目初始化工具应该发布成 npm 包,再起一个响亮的名字。但我最终没有这么做,原因是:它服务的对象只有我自己和我的工作流,发布和版本管理的成本远大于收益。

我需要的不是一个对外的 CLI,而是一个“在大约一分钟内,把基础模板复制到当前目录,替换项目名,安装依赖”的本地脚本。用 npm 包的方式意味着要处理发版、版本兼容、变更日志、多环境测试……这些开销对一个个人模板工具来说完全是负担。

所以 t3code 最终只是一个放在~/t3code/下的仓库,包含一个templates/目录(作为唯一的模板源)和一个scripts/init.js(负责交互问询和文件生成)。这给它带来了两个额外好处:模板和脚本本身也纳入了版本管理,改坏了能用 git diff 看到改动;想更新模板逻辑时,在本地改完直接跑测试就行,不用发布任何东西。

3.2 初始化流程的完整拆解

整个初始化流程我设计成一条线性管道。跑node scripts/init.js之后,依次做四件事:

  1. 交互式问询:读取项目名、是否要后端、是否要数据库这几个关键选项;
  2. 复制基础模板:根据选项组合,用fs.cpSync复制对应模板目录到目标位置;
  3. 变量替换:把项目名等信息替换进package.json、README.md、环境变量样例等文件;
  4. 安装依赖并初始化 git:自动执行pnpm install、git init、首次 commit。

交互环节用到的代码大致是这个样子:

// scripts/init.js const fs = require('node:fs'); const path = require('node:path'); const { execSync } = require('node:child_process'); const readline = require('node:readline/promises'); async function askQuestions() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const projectName = await rl.question('项目名(my-project): '); const includeApi = await rl.question('是否需要后端 API?(y/N): '); rl.close(); return { projectName: projectName.trim() || 'my-project', includeApi: includeApi.toLowerCase().startsWith('y'), }; } async function main() { const answers = await askQuestions(); const templateDir = path.join(__dirname, '..', 'templates', 'base'); const targetDir = path.resolve(process.cwd(), answers.projectName); fs.cpSync(templateDir, targetDir, { recursive: true }); const vars = { name: answers.projectName, includeApi: answers.includeApi ? 'true' : 'false', }; renderTemplate(targetDir, vars); execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' }); execSync('git init', { cwd: targetDir, stdio: 'ignore' }); } main().catch((err) => { console.error(err); process.exit(1); });

你没看错,核心逻辑就是这么朴素,不到一百行。真正花时间的不是脚本本身,而是模板里每份配置的编写和验证。

3.3 变量替换为什么要用独特的分隔符

变量替换是这类工具最容易出问题的地方。一开始我图省事,直接在模板里用${name}这种常见的插值写法,结果第一次跑就翻车了——模板里的README.md包含了一段 shell 示例代码,里面正好有${VAR}这种占位符,被我的替换脚本全给吃掉了。

这个问题的根源是:${name}太常见,它在 shell、模板字符串、dotenv 解析里都有语义,很容易和用户原始内容冲突。

最终我采用了一个折中方案:使用__t3code_name__这种双下划线包裹的专属占位符,并在模板仓库里全局搜索这种模式,确保它不会出现在正常文档中。替换脚本的写法是:

function renderTemplate(dir, vars) { const files = walk(dir); for (const file of files) { const text = fs.readFileSync(file, 'utf8'); const updated = text.replace(/__t3code_(\w+)__/g, (_, key) => { return vars[key] !== undefined ? vars[key] : `__t3code_${key}__`; }); if (updated !== text) { fs.writeFileSync(file, updated); } } }

注意正则里那个回退逻辑:如果遇到了未知的占位符,保留原样,不要擅自清空。这能防止模板写错时把内容静默销毁,至少会暴露问题。

4. 自动化:从拉模板到一条命令跑完所有检查

4.1 统一入口脚本带来的流程变化

模板搭好了,初始化工具也跑通了,但还有一个更重要的问题:怎么保证生成出来的项目,在本地装的依赖和 CI 上跑的依赖完全一致?

t3code 的答案是把所有检查集中到三个顶级 script 上,并且强制在 CI 里用--frozen-lockfile安装依赖。以生成的package.json为例:

{ "scripts": { "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", "check": "pnpm lint && pnpm typecheck && pnpm test" } }

为什么用一个check集中调用而不是让 CI 分别调三个命令?原因很简单:当lint失败时,check会立刻中止,不会浪费时间跑后面的测试;当命令数量多的时候,CI 日志会变得很难读,一个check就能把输出压缩成一条可读的结果。

对应的 CI 配置,我用的是 GitHub Actions,workflow 文件长这样:

name: verify on: push: pull_request: jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm check

这里最容易被忽略的一行是cache: pnpm。如果不启用依赖缓存,每次 CI 都要重新下载全部依赖,一个中型项目的 install 时间可能从 20 秒变成两分钟。启用缓存之后,绝大多数 commit 的验证流程可以在一分钟内跑完,这让“每次提交都跑全量检查”变得可行。

4.2 模板版本管理的漂移问题

模板本身也会演化,比如需要调整 lint 规则、更新依赖版本。但如果模板源目录里的代码被直接改动,而已经生成出来的项目还在用老版本,两者之间就会慢慢产生“模板漂移”。

t3code 应对漂移的办法很朴素:给模板源打 tag,在生成项目时把来源版本写进项目根目录的.t3code-version文件。

.t3code-version的内容只有一行:

v1.2.3

这个版本号在 init 脚本里通过读取模板仓库的git describe --tags自动填入。等到新项目跑了几个月后,如果我想知道它的初始骨架是哪一版,直接 cat 这个文件就知道了。想升级骨架时,也可以在项目里跑一条node ~/t3code/scripts/upgrade.js,它会用当前模板和旧模板做一次 diff,把变化部分合并进项目,同时保留项目里已经修改过的业务代码。

这个 upgrade 脚本的逻辑不复杂,核心就是三次 diff:项目当前状态 vs 旧模板版本 → 旧模板版本 vs 新模板版本 → 把变化应用回来。它不能做到完美自动合并,但配合 git 的三方 merge 工具使用,已经能覆盖百分之九十的场景。

5. 实测里踩过的坑,以及我给未来项目留下的几条规则

5.1 占位符冲突只是第一课,换行符紧跟其后

第一个坑前面说过,是${}占位符冲突。第二个坑则有时代感:换行符不一致。我在 mac 上写模板时,文件默认是LF;但有的模板文件是从 Windows 上复制来的,混进了CRLF。生成出来的项目在git diff时会出现整块整块的“假改动”,因为它们只改了行尾符。

解决方法是:在.gitattributes里固定文本文件的换行符行为,并让 lint 规则检查endOfLine设置。

* text=auto eol=lf *.md text eol=lf *.ts text eol=lf

这一行配置在团队协作里极其重要,但绝大多数初始化模板都没写。每次看到有人因为换行符问题在 PR 里争论,我都想让他先补一份.gitattributes。

5.2 lockfile 到底应不应该提交

关于 lockfile 是否提交到 git,业内讨论很多。我的态度非常明确:个人项目也必须提交 lockfile。t3code 生成的项目默认提交pnpm-lock.yaml。

原因很简单:如果不提交 lockfile,那么“今天能跑”和“明天能跑”完全是两回事——依赖的次版本更新可能引入破坏性变更,哪怕它在语义化版本里看起来是兼容的。提交 lockfile 以后,pnpm install --frozen-lockfile能保证任何人在任何时间点安装出来的依赖完全一致。

有人担心提交 lockfile 会导致依赖安全更新不及时,我的应对是:新项目依赖不多,安全更新用pnpm audit单独跑,而不是删掉 lockfile 去赌运气。两者并不冲突。

5.3 给未来自己的备注:把“为什么”写进文档

模板里提供了一套docs/目录,其中decisions.md专门记录配置取舍的原因。比如:

  • 为什么用 pnpm 而不是 npm?因为 workspace 和磁盘复用机制更适合 monorepo;
  • 为什么测试框架选了 Vitest 而不是 Jest?因为对 ESM 和 TypeScript 的支持更通透;
  • 为什么 lint 规则里关掉no-explicit-any而不是开启?因为有些第三方库的类型定义本身就带 any,硬开只会逼着大家写@ts-expect-error。

这些决策记录不需要很详细,每条三五行就够了。真正的作用是:未来某个凌晨两点,你盯着一条 ESLint 报错想“这个规则到底为什么要开”的时候,翻翻这个文件就能立刻回忆起当时的上下文,而不是靠猜。

实际上,我后来把 t3code 的 docs 目录也整理成了我日常项目文档的模板。无论项目大小,我都习惯了留一份 decision records,这个习惯本身带来的收益,比我前面写的整套配置框架还要大。如果你也想做一套自己的初始化模板,我建议从复制这个基本结构开始,然后按你的业务习惯慢慢加东西——但无论如何,请给 future 版本留一条升级路径,别让模板变成一堆无法维护的冷文件。

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

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

立即咨询