
t3code这个项目最开始只是我给自己整理的一套TypeScript开发基线后来在团队里推广之后还真把好多人从“配置地狱”里捞了出来。如果你也经历过从零搭项目时tsconfig、ESLint、Prettier、husky一坨配置来回折腾的日子这个项目应该能给你一些直接的参考。它不是某个脚手架全家桶而是把TypeScript从语言、工具链到工程化流程重新梳理了一遍的实践方案核心目标就一句话让类型安全贯穿开发全流程让代码规范变成自动化的习惯。在动手之前我先把项目拆成了四层来看语言层的编译配置、规范层的lint与格式化、体验层的开发与测试工具、流程层的提交与CI校验。每个阶段都有明确的对象和方法论这样无论你拿到的是全新空目录还是历史遗留乱项目都能照着这套思路自上而下理顺。尤其适合前端、Node端和全栈方向的朋友拿来做基线。1. 整体设计与思路拆解1.1 t3code要解决的真实痛点我见过太多项目倒在“配置”这一步上同事拉下仓库跑不起来原来是tsconfig里路径别名压根没配新同学写了个接口调用ESLint报了几十个红色波浪线却不知道怎么修两三个项目之间复制粘贴的ESLint规则互相冲突谁都不敢动更常见的是没有pre-commit钩子格式化全靠人肉代码风格随缘。这些问题表面上是“工具不好用”本质上是没有把配置当成工程的一部分来设计。t3code一开始的目标就是消灭这些“隐性成本”用一套统一的、可解释的配置把技术基础设施踩平。具体来说就是编译阶段管住类型静态检查管住代码质量格式化管住风格Git钩子和CI管住流程四个环节各司其职、互相补充。1.2 设计原则Type-first、约定大于配置t3code这个名字浓缩了三个关键词Type-safe、Tooling、Template。也就是“以类型安全为核心、以工具链为支撑、以模板化交付为手段”。我不喜欢把项目命名搞得很玄但确实想通过这个名字提醒自己也提醒使用者在引入任何框架、任何运行时特性之前先回答好“类型这件事做扎实了没有”。这正是t3code和市面上一些全家桶脚手架最大的区别。很多脚手架把重点放在“一键生成页面”和“内置状态管理”上配置只是附带品t3code反过来配置才是主角。它走的是“约定优于配置”的路子约定统一的目录结构、统一的命名方式、统一的脚本入口然后把约定固化到配置和自动检查里减少团队讨论成本。另外有个思路值得分享t3code采用“渐进式增强”而非“全量标配”。最小可用集只包含TypeScript、ESLint、Prettier和基础的Git钩子当项目确实需要测试、Mock、路径别名、CI时再逐层叠加。这样做的好处很明显——小型脚本项目不会被一堆工具拖慢速度大型项目又能向上兼容不会回头重构。2. 核心细节解析与实操要点2.1 TypeScript配置逐项解读tsconfig.json是整个项目的“地基”这里面的每一个选项都不是凭空加上的。我在t3code里最看重三个地方。第一个是strict。很多人问“为什么一定要开严格模式”我的回答是TypeScript最大的价值就是静态类型检查不开strict等于把汽车买回来但只骑自行车。strict模式下null、undefined、隐式any都会被严肃对待前期确实会有阵痛但救回来的都是上线前的低级错误。团队规模越大这个选项越不能关。第二个是moduleResolution。新一代工具链比如Vite、tsx、Vitest都基于ESM所以t3code里我直接使用moduleResolution: Bundler配合module: ESNext让TypeScript理解“通过打包器解析模块”的语义。这比传统的Node10或Node16要省心而且对路径别名、JSON导入的支持也更好。第三个是路径别名paths。项目稍微一复杂到处都是../../../utils/format这种串看着就头疼。t3code统一把/*指向src/*同时会在ESLint、Vitest里做对应的解析配置保证编译、检查、测试三套工具都认识同一个别名。下面这份是我目前生产级别项目在用的精简版本{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, lib: [ES2022, DOM, DOM.Iterable], strict: true, esModuleInterop: true, allowSyntheticDefaultImports: true, resolveJsonModule: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, isolatedModules: true, noUncheckedIndexedAccess: true, baseUrl: ., paths: { /*: [src/*] }, outDir: dist, sourceMap: true, declaration: true }, include: [src, tests, configs, eslint.config.js] }这里特别注意两个容易被忽略的选项isolatedModules关系到单文件转译工具比如esbuild、tsx能否正常工作不开的话遇到const enum这类语法会在转译层炸掉noUncheckedIndexedAccess则会让数组下标和对象取值变成可空类型倒逼你处理边界情况。这段配置里的每一项踩过坑后面会展开讲。2.2 ESLint 9.x flat config从rc时代到扁平化ESLint在9.x正式切换到了扁平化配置flat configt3code当然跟进。旧版的.eslintrc依赖层层继承和插件注册新项目用一个eslint.config.js就能说清楚全部规则这不仅是写法变化更解决了两个长期问题一是原生支持ESM二是去掉了--ext和ignore的困扰忽略文件直接在配置里用ignores字段声明。t3code的ESLint配置核心围绕typescript-eslint这个生态展开。新版中我建议直接用项目服务模式projectService而不是手动指定tsconfig.json路径。这是从旧版parserOptions.project演进过来的能力能自动用最接近当前文件的tsconfig做类型感知规则检查既省心又规避了“多个tsconfig解析串台”的老毛病。import eslint from eslint/js import tseslint from typescript-eslint import prettier from eslint-config-prettier export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, prettier, { files: [**/*.ts], languageOptions: { parserOptions: { projectService: true } }, rules: { typescript-eslint/consistent-type-imports: error, typescript-eslint/no-floating-promises: error, typescript-eslint/no-unused-vars: [error, { argsIgnorePattern: ^_ }] } }, { ignores: [dist/, node_modules/, coverage/] } )这里有个设计考量为什么不把prettier直接装成ESLint插件因为格式化规则和代码质量规则本质是两类问题合并在一起只会让ESLint越来越臃肿、越来越慢。t3code里把eslint-config-prettier放在末尾把所有可能与Prettier冲突的格式类规则关掉让Prettier专心做排版。2.3 格式化与编辑器体验的一致性代码风格统一这件事光靠人的意志力绝对不行必须靠工具。t3code里Prettier的配置遵循一组相对稳定的默认值但我会显式声明endOfLine: lf。你可能以为这不重要直到有人在Windows上提交了一堆CRLF换行diff里满屏红色才追悔莫及。export default { semi: false, singleQuote: true, trailingComma: all, printWidth: 100, tabWidth: 2, endOfLine: lf }有了Prettier还需要在编辑器层面配合。t3code会在项目根目录放一个.editorconfig和.vscode/settings.json前者让任何编辑器都保持同样的缩进和字符集后者强制VS Code在保存时自动执行格式化。两者组合下来本地开发几乎不会再有“我这边格式化完推上去就冲突”的情况。这里还要单独说一句Prettier的trailingComma: all会让ESM时代的导入语句更整洁也降低后续增加字段时diff的噪声。很多团队纠结这个要不要开我的建议是直接开配合import/order整理排序提交记录会好看很多。2.4 开发与测试基础设施开发期跑TypeScriptt3code默认推荐tsx而不是ts-node。原因很现实ts-node在ESM项目里配置繁琐还需要额外处理loader兼容tsx基于esbuild启动快对ESM支持直接实测下来几乎没有需要额外调的场景。配合tsx watch开发体验完全够用。测试方面选Vitest而不是Jest。Vitest不仅能直接读tsconfig里的路径别名还天然支持ESM和TS不需要额外配置babel-jest或者ts-jest那套折腾。t3code里的测试配置非常薄一个vitest.config.ts就搞定import { defineConfig } from vitest/config import { resolve } from node:path export default defineConfig({ test: { include: [tests/**/*.test.ts], environment: node, globals: true }, resolve: { alias: { : resolve(__dirname, src) } } })这套组合对新人也很友好不需要了解一堆历史包袱装完就能写测试。而且Vitest的断言风格和Jest几乎一致迁移成本也低。3. 实操过程与核心环节实现3.1 从空目录搭建一套骨架有了前面的设计思路现在落实到实际搭建。t3code的目录结构并不复杂但每一层的归属都说得清楚my-project/ ├── configs/ │ ├── tsconfig.base.json │ └── vitest.config.ts ├── src/ │ ├── index.ts │ ├── utils/ │ └── types/ ├── tests/ │ └── index.test.ts ├── .husky/ │ └── pre-commit ├── .vscode/ │ └── settings.json ├── .editorconfig ├── .gitignore ├── eslint.config.js ├── package.json ├── prettier.config.js └── tsconfig.json为什么要单独搞一个configs/目录因为很多项目实际存在“多份tsconfig”的情况构建用一份精简配置类型检查用一份严格配置。把它们集中起来避免根目录被配置文件占满同时可以借助extends让子配置继承公共基线。例如tsconfig.base.json里放绝对通用的选项tsconfig.json只做路径映射和包含目录的组装。{ extends: ./configs/tsconfig.base.json, compilerOptions: { paths: { /*: [src/*] } }, include: [src, tests] }这样的模式非常适合中大型项目基础配置只改一处全项目同步生效。3.2 安装依赖与脚本体系设计依赖管理我默认用pnpm。不是因为追新而是pnpm对ESM和monorepo支持得最稳磁盘占用也小。初始化命令就三板斧pnpm init pnpm add -D typescript eslint eslint/js typescript-eslint prettier eslint-config-prettier tsx vitest husky lint-staged依赖装好之后package.json的scripts才是日常开发真正接触的入口。t3code里的脚本要求做到“语义自解释”让任何人不用看文档都大概知道怎么运行。{ scripts: { dev: tsx watch src/index.ts, build: tsc -p tsconfig.json, type-check: tsc --noEmit -p tsconfig.json, lint: eslint ., format: prettier --write ., format:check: prettier --check ., test: vitest run, test:watch: vitest } }你可能会问type-check和build都调用了tsc区别在哪build会产出dist目录用来发布type-check只做类型检查、不产出任何文件适合在CI和pre-commit中快速跑一遍几秒钟就能反馈。实际项目中我把它们放在同一个流程里先type-check再build任何类型错误都不会进入产物。3.3 配置文件逐段落地接下来是t3code的精髓所有配置必须可解释、可维护。ESLint配置上面已经给过这里重点说下Prettier、EditorConfig与Husky之间的交接。.editorconfig内容很简单却能在Vim、Sublime、VS Code之间保持一致root true [*] charset utf-8 indent_style space indent_size 2 end_of_line lf trim_trailing_whitespace true insert_final_newline true.vscode/settings.json主要是触发保存格式化同时让ESLint接管自动修复{ editor.formatOnSave: true, editor.defaultFormatter: esbenp.prettier-vscode, editor.codeActionsOnSave: { source.fixAll.eslint: explicit }, typescript.tsdk: node_modules/typescript/lib }其中typescript.tsdk这个设置容易被忽略它让VS Code使用项目本地安装的TypeScript版本而不是编辑器内置的全局版本。如果你在项目里用了某个TS新特性本地版本和全局版本不一致时这一步能省掉一堆“明明代码没错编辑器却报错”的乌龙。Husky部分新版Husky直接在项目根目录放.husky/pre-commit文件npx lint-staged然后在package.json里配置lint-staged的匹配规则。我只在钩子里格式化并检查“本次改动的文件”而不是全量检查这能把pre-commit的耗时控制在几百毫秒内。{ lint-staged: { *.{ts,tsx}: [ eslint --fix, prettier --write ] } }顺序上先ESLint再Prettier比较合理因为ESLint的--fix可能改完代码风格后产生新的格式差异Prettier再兜底一次。3.4 用CI把规范固化成硬门槛本地钩子总归可能被--no-verify跳过所以t3code最后一步是把同样的检查复制到CI里形成硬门槛。GitHub Actions的配置写起来不复杂name: CI on: push: branches: [main] pull_request: jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Setup pnpm uses: pnpm/action-setupv4 with: version: 9 - name: Setup Node.js uses: actions/setup-nodev4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint run: pnpm lint - name: Type check run: pnpm type-check - name: Test run: pnpm test - name: Build run: pnpm build这里有个容易被忽略的细节--frozen-lockfile。如果没有它CI可能会悄悄更新依赖版本造成本地能过、CI却失败的情况。锁定lockfile后每次依赖变化都走commit流程依赖变更有了可追溯性。克隆仓库、提交分支、PR检查整套流程就是开发者的日常高速公路。4. 常见问题与排查技巧实录4.1 类型系统的三座大山第一座大山是第三方库缺少类型声明。现在的TS生态比以前好太多但总有年代久远的包没带类型也没人补DefinitelyTyped。t3code的处理方式是在src/types目录放一个global.d.ts针对性地做本地声明而不是无脑写any。declare module some-untyped-package { export function doSomething(input: string): Promisevoid export const VERSION: string }这样既让TypeScript不再报错也让调用方看到明确的函数签名。比declare module some-untyped-package直接裸露一个any要严谨得多。第二座大山是泛型推导卡壳。尤其是封装API请求、事件总线这类高复用模块时泛型套多了容易把自己绕晕。我的经验是宁可拉开代码写明确类型也不要硬凑绝妙的单行泛型。代码是给人读的其次才是让机器满意。第三座大山是null和undefined的地狱。开了strict之后maybeNull?.value这种链式访问会逼着你处理每一个可能的空值。说实话这正是strict的意义所在但这些检查在长期演进的代码里会逐渐膨胀。t3code的实践是数据入口做收窄内部业务逻辑尽量保证非空语义不要在每一层都重复判空。4.2 工具链之间的隐形冲突最常见的一个冲突就是ESLint和Prettier对同一段代码给出相反意见。其实解决方式很简单把eslint-config-prettier放在规则的末尾让所有格式类规则闭嘴。但很多人没注意顺序配置一长串插件之后随便追加结果冲突依旧。记住prettier的配置在所有“规则/插件”之后才有效。另一个冲突发生在“路径别名”上。代码里写了/utilsTypeScript编译没问题ESLint跑也没问题结果Vitest一执行就报模块找不到。原因很简单——三套工具各认各的路径必须在ESLint配置里配置import/resolver在Vitest里配置resolve.alias。t3code把这些配置集中在一页文档里目的就是防止这类“三缺一”的隐患。还有一类问题来自Node版本不统一。比如TS 5.5正式支持了const类型参数但CI镜像里的Node还停留在16构建时语法错误一堆。t3code的经验是统一用.nvmrc或CI里的node-version固定大版本同时把tsconfig里的target调到与Node版本匹配的ES版本。4.3 高频异常排查速查表下面这张表是我在实际使用t3code过程中遇到的问题集锦几乎每周都能派上用场异常现象可能原因快速处理Cannot find module /xxx运行时没有别名映射检查tsconfig paths、打包器/测试工具alias、ESLint resolverESLint报parserOptions.project警告旧版typescript-eslint配置过时改成projectService: true并升级依赖提交时pre-commit没触发Husky未安装或钩子无执行权限重新执行npx husky init检查.husky/pre-commit是否有x权限Windows上代码全部CRLF缺少endOfLine配置或git autocrlfPrettier加endOfLine: lf.gitattributes统一Vitest能跑但不识别TS类型未配置resolve.alias或environment按上文vitest.config.ts补齐isolatedModules报const enum错误单文件转译不支持将const enum改为普通enum或as const对象CI安装依赖报错锁文件冲突lockfile与package.json不一致本地pnpm install后提交新的lockfile这张表的素材都很朴素但每一条都对应我真实踩过的坑尤其是Husky权限问题。新版的Husky在Windows和Linux上表现不太一样如果发现钩子没生效先别急着搜配置跑一下ls -l .husky/pre-commit看有没有执行权限十有八九是这里。4.4 几个值得坚持的“笨办法”最后聊几句方法论上的体会。t3code这套东西能跑起来并长期复用靠的不是某个天才设计而是几个“笨办法”所有配置必须能解释为什么存在不用的插件不要装临时绕过问题的代码必须打上TODO标记。我见过太多项目在ESLint配置里躺着一推废弃规则在tsconfig里开着互相矛盾的特性。这种“僵尸配置”比没有配置更危险因为后来者根本不敢动。t3code里我坚持做配置即文档每个文件开头用注释写明用途、适用场景和变更记录。看起来多花了几分钟但在半年后回来看自己的项目时省下的是成倍的“考古时间”。另外建议每一个使用t3code的团队都至少指定一位“配置责任人”负责依赖升级、规则调整和配置评审。这不是权力集中而是防止每个人各改一处最后配置之间彼此打架。这样一个简单的角色分工反而能让工具链长期保持可用而不是半年后敌我不分。我个人在实际操作中还有一个习惯每次跑通一个新人项目的初始化流程都会顺手把“从零搭建”这个过程记下来形成一篇速查笔记。因为这能倒逼你把所有手动步骤不断精简直到任何人在文档指导下都能十分钟内复制整个开发环境。这才是我做t3code真正想看到的局面——工具不该是门槛而应该是每个开发者脚下扎实的地基。