资讯详情

自制T3技术栈脚手架:一条命令搞定全栈项目初始化

📅 2026/10/27 7:10:44 | 华诺云谱 👁 阅读
自制T3技术栈脚手架:一条命令搞定全栈项目初始化
一说起t3code如果你第一反应是某个开源库或者某个陌生框架那也正常因为这本就是一个我自己从零搭起来的小项目名字起得随意功能却一点都不含糊。它是一套围绕着 T3 技术栈Next.js TypeScript Tailwind CSS tRPC做快速项目初始化的命令行工具相当于把平时手动重复无数次的那套工程初始化动作收拢成了一个命令。做这个项目不是为了追新纯粹是被千篇一律的初始化流程烦够了。每开一个新项目都要拉模板、改配置、补依赖、调 tsconfig、处理环境变量、把 trpc 客户端和服务端串起来……这一套下来少说二十分钟时间全耗在重复劳动上。t3code 就是用来解决这个痛点的一条命令几个交互选项一个开箱即用的全栈项目骨架直接落在当前目录里TypeScript 严格模式开好、Tailwind 样式体系铺好、tRPC 路由通了、prisma 连库的配置也预留了甚至 Dockerfile 和 CI 的基础文件都一并生成。如果你也在后端和前端之间反复横跳或者团队里经常要新开内部工具项目这篇东西可以作为一份自制脚手架的完整参考。我会把 t3code 的设计思路、技术选型逻辑、核心模块的实现方式、踩过的坑以及后续打算往哪个方向扩展全部摊开来说一遍。中间涉及的命令、代码片段和排查思路你照着走就能复现出一个自己能用的版本。1. t3code 的定位它不是一个知名框架而是一套属于自己的项目生成器1.1 从日常痛点说起为什么我需要一个自有项目生成器先聊聊我为什么会走到自己写脚手架这一步。说实话社区里现成的脚手架一抓一大把create-next-app、create-t3-app 这些我都用过。可实际用下来总会遇到几个尴尬的时刻第一个问题是模板太干净了。官方脚手架默认生成的是演示页面和基础目录结构我需要的东西它都不带。比如每个内部项目都要接公司自建的登录体系、特定的 lint 规则、统一的提交信息规范还有一套团队约定的目录组织方式。这些在官方模板里完全不沾边每次都得手动补一批文件、改一批配置。第二个问题是一次性的尴尬。今天拉一个模板明天再拉一个两个项目的初始化结果可能因为版本缓存不一致而对不上。你根本无法保证每台机器、每个时间点生成出来的工程是同一套基线。对于要批量产出内部工具项目的场景这是个隐患。第三个问题是同步成本。团队的工程规范一旦发生变化比如统一升级 React 版本、换掉 ESLint 配置所有已经生成的项目都得改一遍。这个成本没体现在单个项目里但当一个团队手里攒了几十个内部小项目的时候那种痛感是持续累积的。所以 t3code 本质上就是一个私有化的工程生成器。它的核心逻辑不是去包装 create-next-app而是把团队规范、常用依赖、基础配置全部固化成代码模板然后通过命令行交互决定要生成什么形态的项目。它解决的不仅是初始化速度问题更重要的是让所有从 t3code 出来的项目保持同一套基线后续升级时只需要改模板然后重新生成差异即可。1.2 t3code 的能力边界能做什么刻意不做什么t3code 不等于一个全功能 CLI 框架它刻意保持了一个非常收敛的能力范围能生成基于 T3 技术栈的基本全栈工程包含 Next.js App Router 结构、TypeScript 严格模式、Tailwind 样式层、tRPC 服务端和客户端模板。能通过交互参数选择是否集成 Prisma、是否添加 Dockerfile、是否需要 CI 工作流。能统一注入项目名、包名、作者信息以及环境变量模板。能生成后自动安装依赖并在依赖安装完成后做一次 TypeScript 类型检查确保生成结果直接可跑。它刻意不做的包括不做抽象的业务代码生成、不做平台账号体系的复杂对接、不做 UI 组件库的自动配置。这些内容每个项目差异太大强行放到生成器里只会让模板变得越来越重最后变成一个谁都不愿意改的大泥球。工具的价值在于收敛和重复不在于覆盖所有可能性。这也给其他想做同类工具的人一个建议脚手架别贪大。能解决 80% 的重复劳动就够用了剩下 20% 的定制需求应该留给使用者而不是塞进生成器里。2. 技术选型为什么锁定 T3 组合Next.js、TypeScript、Tailwind、tRPC 的分工逻辑2.1 四件套各自的职责一个比喻讲明白T3 技术栈不是官方标准而是一套社区里被验证过的组合。我选择把它作为 t3code 的默认底座是因为这四个东西放在一起刚好覆盖了一个全栈 WEB 项目从界面到数据库的全部层面且各层之间的衔接非常顺滑。Next.js 负责应用框架层。它同时承担页面渲染、API 路由、服务端组件、构建部署的职责。我把 Next.js 比作整栋楼的骨架承重墙和楼梯是它搭的房间怎么分隔使用者也在这个框架内操作。TypeScript 负责类型系统。它管住的是所有人之间的接口契约——函数参数长什么样、数据库返回的数据长什么样、API 请求响应长什么样。没有它前后端一多联调就成了靠猜谜。Tailwind CSS 负责样式层。它不提供组件只提供原子化的 CSS 类相当于装修时的标准建材你可以自由组合出任何样式而不会被迫接受一套既定的设计语言。tRPC 负责 API 通信层。它跟 REST 或 GraphQL 最大的区别在于后端路由函数的输入输出类型可以直接穿梭到前端调用 API 就像调用本地函数一样天然带着类型提示和编译校验。这四个东西各自专注自己最擅长的领域组合在一起时不会互相打架。它们共同的底座是 TypeScript——只要所有层都用 TypeScript 编写整条链路上类型就能贯通这是这个组合最核心的红利。2.2 类型安全的全链路贯通T3 最值钱的部分单独拿任意一个框架出来类型安全早就不是什么新鲜事。但 T3 组合真正值钱的地方在于类型安全是端到端的而不是局部孤立的。举例来说你写了一个 tRPC 路由后端定义了一个 query返回一个用户列表每个用户包含 id、name、email 三个字段。在 t3code 生成的工程里前端直接引用这个 query得到的结果是被完整推导过的类型你在前端代码里是拿不到不存在的字段的。后端改了返回结构前端的类型检查会立即报错这等于把大量联调阶段才能发现的问题提前到了编译阶段。再往深一点说Prisma 是数据库层的 ORM它的 schema 文件定义数据模型之后会自动产出完整的 TypeScript 类型。tRPC 的路由可以直接使用 Prisma 返回的类型前端的类型又从 tRPC 返回类型中来。这一整条链路下来从数据库表结构到前端 UI 组件类型是同一个来源没有一次手工定义接口类型的过程。我在 t3code 中预设的工程就是把这条链路完整串起来的。新手第一次看会觉得有点绕但只要自己从头到尾走一遍就能理解为什么 T3 组合在小团队和内部工具场景下效率这么高——类型就是最好的文档而且它永远不会过期。3. 搭建 t3code 的核心过程脚手架模块的组织与实现3.1 项目结构设计一个脚手架怎么拆解自己的模块写脚手架之前最忌讳的就是把所有逻辑堆在 index.ts 里。我最初也图省事把询问选项、文件生成、依赖安装全写在一个文件里结果项目一复杂就完全失控。后来重构了一次把 t3code 拆成了几个职责明确的模块t3code/ ├── src/ │ ├── cli.ts # 命令行入口解析参数与交互选项 │ ├── commands/ │ │ └── init.ts # 核心初始化逻辑调度以下所有模块 │ ├── prompts/ │ │ └── questions.ts # 交互问题定义决定用户能选什么 │ ├── templates/ │ │ ├── base/ # 基础工程模板文件 │ │ ├── features/ # 可选功能模板如 prisma、docker、ci │ │ └── index.ts # 模板文件加载与渲染逻辑 │ ├── config/ │ │ └── constants.ts # 全局常量版本号、默认配置 │ ├── utils/ │ │ ├── file.ts # 文件递归复制、目录创建工具 │ │ ├── package.ts # package.json 内容生成逻辑 │ │ └── git.ts # 初始化 git 仓库的逻辑 │ └── index.ts # 包入口 ├── templates/ # 实际存放模板文件的位置 └── package.json这个结构的好处是每个模块都能独立测试后续加新功能不会把主流程搞乱。比如 templates/features 下加一个是否集成 eslint的选项只需要在 prompts 里加问题、在 templates/features 里放模板、在 init.ts 里加一次复制调用三条改动量就能完成新功能接入。3.2 命令行交互层问对问题比实现功能更重要交互设计直接决定工具好不好用。我最初在 t3code 里塞了十几个问题从数据库类型问到 UI 组件库结果自己用的时候都觉得烦。后来砍到只剩四个核心问题// src/prompts/questions.ts import prompts from prompts; export async function askQuestions() { const answers await prompts([ { type: text, name: projectName, message: 项目名称, initial: my-t3-app, }, { type: toggle, name: withPrisma, message: 是否集成 Prisma 作为 ORM, initial: true, active: yes, inactive: no, }, { type: toggle, name: withDocker, message: 是否生成 Dockerfile, initial: false, active: yes, inactive: no, }, { type: toggle, name: withCi, message: 是否生成 GitHub Actions CI 配置, initial: false, active: yes, inactive: no, }, ]); return answers; }这样做是刻意为之只要一个问题没有直接作用于最终生成的文件内容那它就不值得出现在交互里。团队规范、lint 配置、提交信息规范这些我直接硬编码到模板里根本不问。这样做的好处是使用者在初始化阶段不会被一堆选择压垮整个交互过程十秒内就能结束。3.3 模板生成与文件写入的细节不只是简单复制t3code 的模板目录里放的是带占位符的文本文件占位符用简单的字符串替换实现没有引入复杂的模板引擎。我试过 handlebars 和 ejs后来发现对于脚手架这种使用场景一个正则可以解决的占位符替换没必要引入一个新依赖。// src/utils/file.ts import fs from node:fs/promises; import path from node:path; export async function walkAndReplace( sourceDir: string, targetDir: string, replacements: Recordstring, string ) { const entries await fs.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { const sourcePath path.join(sourceDir, entry.name); const targetPath path.join(targetDir, entry.name); if (entry.isDirectory()) { await fs.mkdir(targetPath, { recursive: true }); await walkAndReplace(sourcePath, targetPath, replacements); } else { let content await fs.readFile(sourcePath, utf8); for (const [key, value] of Object.entries(replacements)) { content content.replaceAll({{${key}}}, value); } await fs.writeFile(targetPath, content, utf8); } } }这段代码的工作方式是递归遍历模板目录中的每个文件把文件名不变、内容里的{{projectName}}、{{packageName}}这类占位符替换成用户在命令行输入的选项然后写到目标目录里。整个逻辑简单直接没有任何黑魔法。唯一要注意的是模板文件里如果本身包含类似{{...}}的字符串比如某些文档里的双花括号示例会被误替换。我的处理方式是给真正的占位符前后各加一个特殊标记把占位符写成___projectName___这样冲突概率几乎为零。关于 package.json 的生成我没有简单复制模板文件而是单独维护一个函数来生成对象再序列化输出原因是 package.json 里有几处内容依赖用户选择比如 Prisma 相关的 script 只在集成 Prisma 时才应该存在。对象序列化之后用 JSON.stringify 格式化输出保持统一的缩进风格。4. 真实跑通一个 t3code 生成的项目从数据库到页面的一次完整链路4.1 创建生成项目并完成首屏页面生成项目本身不是终点能跑起来才是。我在 t3code 的 init.ts 里设置了安装依赖后的自动验证步骤跑一遍tsc --noEmit做类型检查。如果类型检查不过说明模板里的代码在某些版本组合下有兼容问题生成器会当场报错提示而不是把问题留到用户使用的时候。一个典型的 t3code 生成项目首次页面渲染的路径是这样的浏览器请求首页Next.js 服务端组件在服务端执行。页面组件调用 tRPC 客户端暴露出来的 server api。tRPC 会在服务端直接调用对应的 router 函数不需要经过 HTTP 往返。router 函数调用 Prisma 查询数据库拿到数据后返回类型全程推导。页面组件拿到数据渲染出 HTML响应返回给浏览器。这个过程听起来简单但如果没有 T3 那一套类型贯通每一个步骤之间都有一个翻译的环节服务端函数返回值要手工定义接口类型前端拿到数据后要再定义一遍相同的类型。用了 T3 之后这些环节全部被自动推导替代。写代码的时候唯一要做的是把握客户端组件和服务端组件的边界。这里有一个很常见的新手误区在 Next.js App Router 模式下所有组件默认是服务端组件tRPC 客户端不能直接在服务端组件中使用。正确的做法是在服务端组件里直接调 tRPC server 的 caller在一层包装后把数据传给客户端组件。模板里我已经提前写好了一个trpc/server的工具函数专门做这个转换。4.2 环境变量和开发体验隐藏的坑t3code 生成项目的过程中环境变量的处理是容易翻车的环节。模板里我会生成一个.env.example文件里面把所有运行需要的环境变量列出来并写上注释说明每个变量的用途# 数据库连接串Prisma 依赖此变量 DATABASE_URLpostgresql://user:passwordlocalhost:5432/mydb # 调试开关开发环境建议设为 true NEXT_PUBLIC_DEBUG_MODEfalse生成器不会自动创建.env而是提示用户执行cp .env.example .env再手动补充真实值。这里有一个经验性问题很多脚手架会在初始化时自动生成.env但这其实很危险因为一旦项目被推到公共仓库.env里的真实密钥就可能泄露。生成器默认只创建.env.example给了用户一个非常安全的起点真实的DATABASE_URL由使用者在本地填写。开发体验方面我在模板里还预设了一个 npm script 集合包括dev、build、start、lint、typecheck和db:generate。其中typecheck是一个容易被忽视但极其重要的命令——它只运行 TypeScript 类型检查不启动任何服务。我在实际使用中养成了一个习惯每次 git commit 之前都先跑一遍npm run typecheck因为它跑得快能拦截掉绝大多数低级错误。5. 我在 t3code 这个项目里踩过的三个坑5.1 版本锁定问题依赖升级导致类型体操失效第一个坑从两行升级记录说起。某天我执行npm install更新依赖之后生成器自带的类型检查开始报错仔细一看是 tRPC 的泛型推断链断了。原因是 Next.js 从 14.1 升到 14.2 后对服务端组件的类型定义做了调整而 tRPC 的 server caller 工具函数内部依赖的某些类型没有同步更新。这个问题让我意识到一个很重要的设计原则脚手架类工具必须做依赖版本锁定。不能简单地在模板里写next: ^14.0.0因为^范围会让用户安装到未来的版本。我的解决办法分两层模板 package.json 里用精确版本号比如next: 14.2.5而不是^范围。在生成器的常量文件里维护一个dependencies映射表统一管理版本号升级时只改这个文件就能整体同步。这样虽然放弃了一些灵活性但从工程可靠性的角度来说值得。对于一个以稳定模板为价值点的脚手架版本漂移是最大的不稳定因素。5.2 模板转义问题生成的文件内容被二次加工了第二个坑非常隐蔽。我把模板文件按原始文本读取后做字符串替换但当模板里包含代码块、比如一个 TypeScript 文件里的泛型写Promise{id: string}某些编辑器或格式化工具会自动处理文件内容导致生成后的文件多出额外的空格或空行。后来排查发现问题的根源在于我自己在本地编辑器里打开了模板文件进行编辑保存时启用了 prettier 格式化。prettier 对代码文件的格式风格有自己的理解比如把单引号改成双引号、调整缩进。这些改动单独看没什么问题但会让模板文件的原始字节内容和设计预期不一致。这个坑的教训是模板文件应该视为生成器的输入资源不应受任何代码格式化工具的影响。解决方式是给模板目录添加.prettierignore文件并且在编辑模板时关闭编辑器自动格式化。此外模板中的特殊字符要特别注意如果模板里确实需要输出{{xxx}}这样的文本我在 walkAndReplace 时会先检查一个keepLiteral的标记数组把这些特定位置排除在替换之外。5.3 本地调试脚手架的权限与缓存问题第三个坑来自开发 t3code 自身时的调试环节。本地执行npm link把脚手架挂到全局命令后频繁改动源码时需要重复执行构建、link、运行。这个流程慢且容易踩权限问题。如果你也自己做工具我建议用tsx直接跑 TypeScript 源码npx tsx src/cli.ts init my-test-app这样跳过了编译环节每次改动源码后立刻生效调试效率大幅提升。至于npm link的权限问题通常表现为全局 node_modules 目录需要管理员权限最简单的规避就是不把脚手架安装到全局而是用npx或直接调用本地脚本的方式来执行。另外还要注意脚手架生成目录的缓存问题如果生成目标目录已经存在同名文件模板复制会直接覆盖。我在 init.ts 里增加了一个检查逻辑如果目标目录已有 package.json 或者.git目录就明确提示用户并退出避免误覆盖已有工程目录。别小看这个小检查我第一次使用就因为没有这个保护把一个刚开始两天项目的目录覆盖掉了一部分好在 git 还有历史记录能救回来。从那以后目标目录非空则拒绝执行就成了 t3code 的铁律。6. 基于 t3code 的进一步扩展方向6.1 生成后的增量更新从一次性脚手架变成可演进基线t3code 目前做的是一次性生成。但我在实际使用中很快发现一次生成解决不了长期演进的问题。比如团队在一段时间后统一切换了日志库或者给每个项目都新增了一个共享的中间件已有的项目不会有任何感知。我正在尝试的解法是给 t3code 增加一个upgrade命令。这个命令做的事情很简单对比当前项目的关键配置文件与模板的最新版本找出差异生成一个差异报告然后由使用者决定是否应用这些变更。实现思路不复杂核心是维护一个 manifest 文件记录模板中每个文件的哈希值升级时比对哈希值就能知道哪些文件被改动过。这个方向做出来后t3code 就能从初始化工具变成一个团队工程基线管理工具价值会提升一截。6.2 插件化的可能性让模板不再依赖个人化的选择还有一个方向是插件化。现在 t3code 的模板是我自己偏好的配置集合这直接导致团队里的其他人不一定完全认同。比如我默认的目录结构里有一个src/server目录专门放服务端代码但可视化开发为主的前端同事更喜欢把所有页面和组件放在一起。这种分歧靠工具强压不可取更和谐的方案是让模板本身支持插件化扩展。具体设想是把模板拆成核心层和插件层。核心层只包含 Next.js TypeScript Tailwind 的最小可运行集插件层负责补充 tRPC、Prisma、Docker、CI 等内容。使用者通过命令组合插件甚至可以发布自己的插件让不同团队在共用一个脚手架的前提下保留各自的工程习惯。这个改动工程量不小但目前看来是值得投入的方向。6.3 给想自己写脚手架的人一条引线如果你看完这篇也想做一套自己的项目生成器我的建议是先别急着写代码。花一个晚上把过去五个项目的工程配置找出来逐项对比哪些是一致的哪些是个性化的。一致的部分就是模板的基础内容个性化的部分要么做成可选功能要么彻底从工具中移除。这个准备工作做好后写生成器的技术实现反而是最简单的部分——按我上面介绍的模块结构一个周末就能把 MVP 跑通。工具是给人用的尤其像 t3code 这种开发者工具它最重要的特性不是功能多而是每次使用都能稳定复现一个已知良好的起点。我个人在坚持维护它之后最明显的感受是新项目从开始初始化到打开浏览器看到可运行页面的时间从以前的二十多分钟压缩到了不到两分钟。这省下来的时间足够多想几个需求、多补几个测试用例或者早点下班。这可能也是做这类基础工具最直接的回报。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑