ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

t3code:一套可复用的全栈项目模板与自动化初始化实践

t3code:一套可复用的全栈项目模板与自动化初始化实践 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 的做法是强制三层依赖规则apps/*可以依赖packages/core和packages/typespackages/core只能依赖packages/types不能反向依赖任何apps/*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之后依次做四件事交互式问询读取项目名、是否要后端、是否要数据库这几个关键选项复制基础模板根据选项组合用fs.cpSync复制对应模板目录到目标位置变量替换把项目名等信息替换进package.json、README.md、环境变量样例等文件安装依赖并初始化 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 Actionsworkflow 文件长这样name: verify on: push: pull_request: jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: pnpm/action-setupv4 with: version: 9 - uses: actions/setup-nodev4 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设置。* textauto eollf *.md text eollf *.ts text eollf这一行配置在团队协作里极其重要但绝大多数初始化模板都没写。每次看到有人因为换行符问题在 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 版本留一条升级路径别让模板变成一堆无法维护的冷文件。
返回列表