tsconfig 工程化配置与严格模式调优
tsconfig 工程化核心:strict 全家桶、bundler 解析、verbatimModuleSyntax、路径别名与项目引用,以及老项目渐进开启严格模式。
一句话概括
tsconfig.json 是 TypeScript 项目的”宪法”:它决定类型检查多严、模块怎么解析、输出什么、项目怎么拆分。面试里问 tsconfig,通常不是考你背字段,而是考你知不知道每个关键字段在解决什么工程问题——为什么 strict 要全开、为什么前端用 moduleResolution: bundler、为什么 verbatimModuleSyntax 和 isolatedModules 要成对出现、paths 别名为什么运行时还会报错。这篇文章把生产可用的配置和背后的取舍讲透。记住主线:strict 全开是底线,前端 bundler 解析,类型检查(tsc)和代码产出(打包器)分离,paths 只管类型不管运行时。
核心知识点
1. strict 全家桶:生产项目的底线
"strict": true 不是开关一个检查,而是一次性打开一整组严格检查。面试常考”strict 到底开了哪些”,标准清单(TypeScript 5.6+):
| 子选项 | 作用 | 不放行会怎样 |
|---|---|---|
noImplicitAny | 隐含 any 报错 | 参数没标类型就 any,类型形同虚设 |
strictNullChecks | null/undefined 是独立类型 | let x: string = null 直接报错,杜绝空值崩溃 |
strictFunctionTypes | 函数参数逆变检查 | 把 (n: string\|number) => void 赋给 (s: string) => void 会拦下 |
strictBindCallApply | call/bind/apply 参数签名校验 | fn.call(undefined, false) 传错类型不再静默 |
strictPropertyInitialization | 类属性必须在构造函数赋值 | name: string; 没初始化直接报错 |
noImplicitThis | this 为 any 时报错 | 回调里 this 跑偏能提前发现 |
useUnknownInCatchVariables | catch 变量是 unknown | catch(e){ e.message } 必须先 instanceof 收窄 |
alwaysStrict | 输出带 "use strict" | 模块本就严格,顺手保证 |
strictBuiltinIteratorReturn | 内置迭代器 TReturn 为 undefined | TS 5.6 加入,避免迭代器返回值被推断成 any |
重点:strict 开启后,未来 TS 升级可能引入更严的检查、冒出新的类型错误,这是预期的。生产项目无脑 strict: true,没有商量余地。
2. module / moduleResolution:前端与 Node 的分水岭
这是最容易配错的一对。module 是”输出什么模块格式”,moduleResolution 是”TS 怎么找模块”。
1
2
3
4
5
// 前端(Vite / webpack / esbuild):TS 5.0+ 推荐这套
{ "module": "ESNext", "moduleResolution": "bundler" }
// Node.js(ESM/CJS):用这套,匹配 Node 真实行为
{ "module": "NodeNext", "moduleResolution": "NodeNext" }
1
2
3
// ❌ 新项目别再用 "module": "CommonJS" + "moduleResolution": "node"
// 那是历史默认值,会把 ESM 产物降级成 CJS,和 Node 现代行为对不上
// ✅ 前端交给打包器,Node 用 NodeNext,二者都不能乱配
关键提醒:moduleResolution: bundler 只在”有打包器”时成立;如果你的代码要在 Node 直接跑(没有打包器),用了 bundler 会解析失败,必须用 NodeNext。另外 bundler 允许”不带扩展名导入”“支持 package.json 的 exports 字段”,正是为现代打包器量身定做。
3. verbatimModuleSyntax 与 isolatedModules:和打包器对齐
这两个常被搞混,但解决的是不同问题,现代项目建议同时开。
1
2
3
4
5
6
7
8
9
10
// verbatimModuleSyntax(TS 5.0+):类型导入必须显式写 import type
import type { User } from './types'; // ✅ 类型
import { fetchUser } from './api'; // ✅ 值
// ❌ 下面这种在 verbatimModuleSyntax 下报错:User 只当类型用却没标 type
import { User } from './types';
function f(u: User) {}
// isolatedModules:要求每个文件都能独立编译(swc/esbuild 是单文件转译器)
// 开了之后不能用跨文件推导的"只类型 re-export",const enum 也会被禁
verbatimModuleSyntax:强制import type,转译器(swc/esbuild)才能安全地把类型导入整个擦掉,避免”运行时 import 了一个不存在的东西”。它替代了老字段importsNotUsedAsValues/preserveValueImports。isolatedModules:保证每个文件独立可编译,是 swc/esbuild/babel 这类单文件工具的硬性要求。
一句话:isolatedModules 管”能不能独立编译”,verbatimModuleSyntax 管”类型导入写得够不够明确”。
4. 类型检查与代码产出分离:tsc 只查类型
现代工程的一个核心认知:tsc 慢在类型检查,不在转译。所以大家都把”产出代码”交给 swc/esbuild(秒级),把”类型检查”留给 tsc --noEmit。
1
2
3
4
5
6
{
"compilerOptions": {
"noEmit": true, // 业务项目:tsc 不产出 JS,交给 Vite/webpack
"skipLibCheck": true // 跳过 node_modules 里 .d.ts 检查,大项目能省一半时间
}
}
skipLibCheck 强烈建议开——它能跳过第三方 .d.ts 的类型检查大幅提速,代价是”可能掩盖依赖自身的类型错误”,但对业务项目利远大于弊。
5. paths 别名:只管类型,不管运行时
paths 是面试高频坑——很多人以为配了 @/* 就能全局用,结果打包后运行时报错。
1
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }
1
2
import { Button } from '@/components/Button'; // ✅ 类型检查能找到
// ❌ 但打包器不认!运行时还是相对路径,除非你在 Vite/webpack 里也配了 resolve.alias
结论:paths 只解决”TS 类型解析”,运行时要生效必须在打包器里配 resolve.alias(或借助 tsc-alias 改写产物)。别以为配了 tsconfig 就万事大吉。
6. Project References:Monorepo 增量编译
大型 Monorepo 里几十个包互相引用,每次全量编译慢得离谱。references + composite 是官方答案:把大项目拆成子项目,显式声明依赖图,tsc --build 按依赖顺序增量编译。
1
2
3
4
5
6
7
8
9
10
// 根 tsconfig.json:自己不编译,只串依赖
{ "files": [], "references": [{ "path": "./packages/shared" }, { "path": "./packages/web" }] }
// packages/shared/tsconfig.json
{
"compilerOptions": { "composite": true, "declaration": true, "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
// packages/web/tsconfig.json:声明依赖 shared
{ "compilerOptions": { "composite": true }, "references": [{ "path": "../shared" }] }
注意:composite: true 会隐式开启 declaration: true(要生成 .d.ts 给上游引用),所以 composite 项目里不能再设 declaration: false,否则报错。用 tsc --build 触发,只重编变更的子项目。
7. 老项目渐进开启严格模式(3 阶段)
老项目从宽松 TS 一次性开 strict 会爆几千个错。正确做法是分阶段:
1
2
3
4
5
6
7
// 阶段一:先开 strict,把最难的两项临时关掉跑通
{ "strict": true, "strictNullChecks": false, "noImplicitAny": false }
// 阶段二:逐个打开——每开一个就 tsc --noEmit 修一波
{ "strict": true, "strictNullChecks": true, "noImplicitAny": false }
// 阶段三:全严格 + 额外检查
{ "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true,
"noUnusedLocals": true, "noUnusedParameters": true }
如果项目太大,还能按文件分批:宽松的 tsconfig.json 配 include: ["src"],严格的 tsconfig.strict.json 用 extends 继承、只 include 已迁移完的目录,CI 跑两遍。
其实你每天都在用
strictNullChecks拦住空值崩溃:users.find(...)返回T | undefined,逼你在用之前判空,少一堆线上Cannot read properties of undefinednoUncheckedIndexedAccess防数组越界假设:开了之后arr[0]是T | undefined,强制处理”下标可能不存在”- Vite 项目里的
moduleResolution: bundler:让你不带扩展名import './mod'就能跑,正是打包器的解析规则 import type配合verbatimModuleSyntax:让 esbuild 安全地擦掉类型导入,避免打包后多出无意义的运行时 importextends继承基础配置:团队用@tsconfig/node20、@tsconfig/next等预设,或自己的tsconfig.base.json,各包只写差异项- Monorepo 里的
tsc --build:改一个底层包,只重编它和依赖它的包,不用全量编译
常见误解(FAQ)
❌ 误区一:”strict 太严了,老项目先关着,以后再说”
strict 是 TS 价值最大的部分,关掉等于花钱买了类型系统却当摆设。正确做法是渐进开启(见上方 3 阶段),而不是永远关着。新项目直接 strict: true,没有例外。
❌ 误区二:”配了 paths 别名,项目里就能全局 @/ 导入了”
paths 只影响 TS 类型解析,不影响运行时。打包器读的是你写的相对路径(或它自己的 alias 配置),没在 Vite/webpack 配 resolve.alias,产物里 @/ 还是找不到模块。这是最高频的”本地类型过了、打包报错”根因。
❌ 误区三:”verbatimModuleSyntax 和 isolatedModules 是一个东西,开一个就行”
完全不同:isolatedModules 管”每个文件能不能被单文件转译器独立编译”,verbatimModuleSyntax 管”类型导入有没有显式标 import type“。前者防止跨文件类型 re-export、禁用 const enum;后者让转译器能安全擦掉类型导入。现代项目两者都开,别二选一。
❌ 误区四:”moduleResolution 用 bundler 最通用,Node 项目也能用”
bundler 是为”有打包器”的场景设计的,允许不带扩展名导入、认 package.json 的 exports。Node 直接运行时没有打包器,用了 bundler 会解析失败;Node 项目必须用 NodeNext。反过来,前端项目用 NodeNext 也会因扩展名要求而处处报错。按”谁运行产物”选,不要图省事统一用 bundler。
❌ 误区五:”skipLibCheck 关掉更严格、更靠谱”
skipLibCheck 跳过的是 node_modules 里第三方 .d.ts 的检查,不是你自己的代码。开着它能把大项目的类型检查提速数倍,代价仅是”可能掩盖依赖库自身的类型问题”——对业务项目利远大于弊。除非你在维护一个 TS 库、需要严格验证依赖类型,否则无脑开。
一句话总结
tsconfig 工程化的核心是”按角色配字段”:strict 全家桶全开是底线,moduleResolution: bundler(前端)/ NodeNext(Node)按运行环境选,verbatimModuleSyntax + isolatedModules 对齐单文件转译器,noEmit + skipLibCheck 让 tsc 只管类型检查、产出交给打包器,paths 只管类型、运行时还得配打包器别名,Monorepo 用 composite + references 做增量编译——记住”类型检查和代码产出分离、paths 不解决运行时”这两条,tsconfig 就不再是抄来的黑盒。