TypeScript 严格模式迁移:一批文件一批文件地开启 strict
一、tsconfig.json 里 strict: false 是一个定时炸弹
接手一个 3 年老项目时,tsconfig.json里strict: false,noImplicitAny: false。any满天飞,null不检查,undefined当 0 用,类型系统形同虚设。跑线上不到一个月,连出 3 个Cannot read property of undefined的事故——全是 TS 编译器本可以拦住的问题。
但直接strict: true不可能——2000+ 文件瞬间爆出 8000+ 个 TS 错误。修复要四周起。老板等不了,业务不停。必须设计一套渐进式迁移方案:零中断,逐文件推进,每批文件的改动可控且可回滚。
二、渐进式 strict 迁移的策略框架
graph TD A["现状: strict: false<br/>2000+ 文件"] --> B["Step 1: 增量编译检查<br/>ts-strictify 脚本<br/>标记 strict: true 文件"] B --> C{"CI 全绿?"} C -->|"否"| D["仅报错不阻断<br/>生成迁移报告"] C -->|"是"| E["Step 2: 锁定文件<br/>strictFiles 白名单<br/>更新 tsconfig"] E --> F["Step 3: 迭代推进<br/>每次 PR 改 N 个文件"] F --> G{"比例 > 95%?"} G -->|"否"| F G -->|"是"| H["Step 4: 全局开启<br/>broad strict: true<br/>移除白名单"] D --> I["按文件分配<br/>分配给各模块 owner"] I --> F style A fill:#FF6B6B,color:#fff style H fill:#50B86C,color:#fff style C fill:#F5A623,color:#000四个迁移阶段:
- 诊断期:运行
tsc --strict --noEmit,收集所有错误,按文件分组,生成迁移报告。 - 消化期:配置文件白名单,已通过 strict 检查的文件加入白名单,未通过的文件保持
strict: false。 - 推进期:每次 PR 附带 5-10 个文件的 strict 修复,作为 PR 的硬性要求。
- 收尾期:strict 覆盖率 > 95% 后,全局开启
strict: true,移除白名单机制。
三、迁移工具链与生产级实现
诊断脚本:ts-strictify
#!/usr/bin/env npx tsx /** * ts-strictify: TypeScript 严格模式迁移诊断工具 * * 功能: * 1. 扫描项目中所有 TS 文件 * 2. 对每个文件单独运行 tsc --strict * 3. 生成按文件分组的错误报告 * 4. 输出迁移优先级排序 */ import * as fs from 'fs'; import * as path from 'path'; import { execSync } from 'child_process'; import { glob } from 'glob'; interface FileReport { filePath: string; errorCount: number; errors: StrictError[]; // 文件被其他文件依赖的次数(import 引用数) dependencyCount: number; // 优先级:高依赖 + 低错误的文件优先迁移 // 原理:被引用越多的文件,提升严格检查的收益越大 priority: number; } interface StrictError { line: number; column: number; code: string; // 错误码,如 TS2345 message: string; } class StrictifyAnalyzer { /** * 扫描项目并生成迁移报告 */ async analyze(projectRoot: string): Promise<FileReport[]> { // 收集所有 TypeScript 文件 const tsFiles = await glob('src/**/*.{ts,tsx}', { cwd: projectRoot, ignore: [ '**/*.d.ts', // 忽略声明文件 '**/*.test.{ts,tsx}', // 忽略测试文件(通常单独处理) '**/*.spec.{ts,tsx}', ], }); // 构建依赖关系图:每个文件被谁 import const depGraph = this.buildDependencyGraph(projectRoot, tsFiles); const reports: FileReport[] = []; for (const file of tsFiles) { const fullPath = path.join(projectRoot, file); const errors = this.checkFileStrict(fullPath, projectRoot); reports.push({ filePath: file, errorCount: errors.length, errors, dependencyCount: depGraph.get(file)?.length ?? 0, // 优先级公式:依赖数 / (错误数 + 1) // 依赖多且错误少的文件最先迁移,收益最大 priority: (depGraph.get(file)?.length ?? 0) / (errors.length + 1), }); } // 按优先级降序排列 reports.sort((a, b) => b.priority - a.priority); return reports; } /** * 对单个文件运行 tsc --strict 检查 * * 为什么逐文件检查而非项目级别: * 项目级 --strict 会报所有文件的错, * 我们只需要知道"这个文件改动后是否有遗留错误" */ private checkFileStrict(filePath: string, projectRoot: string): StrictError[] { try { // 构造临时 tsconfig,仅包含当前文件 const tempConfig = { extends: './tsconfig.json', compilerOptions: { strict: true, noEmit: true, }, include: [path.relative(projectRoot, filePath)], }; const configPath = path.join(projectRoot, '.ts-strict-temp.json'); fs.writeFileSync(configPath, JSON.stringify(tempConfig, null, 2)); try { execSync(`npx tsc -p ${configPath} --noEmit`, { cwd: projectRoot, stdio: 'pipe', timeout: 30000, // 单文件检查,30s 足够了 }); return []; // 无错误 } catch (err: any) { // tsc 报错后 exit code != 0,通过 stderr 获取错误信息 return this.parseTscErrors(err.stdout?.toString() ?? ''); } finally { // 清理临时配置 fs.unlinkSync(configPath); } } catch (error) { console.error(`检查文件 ${filePath} 失败:`, error); return []; } } /** * 解析 tsc 错误输出为标准错误对象 * * tsc 输出格式: * src/foo.ts(12,5): error TS2345: Argument of type 'string' ... */ private parseTscErrors(output: string): StrictError[] { const errors: StrictError[] = []; const regex = /(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)/g; let match; while ((match = regex.exec(output)) !== null) { errors.push({ line: parseInt(match[2]), column: parseInt(match[3]), code: match[4], message: match[5].trim(), }); } return errors; } /** * 构建依赖关系图 * 分析每个文件被哪些其他文件 import */ private buildDependencyGraph( projectRoot: string, tsFiles: string[] ): Map<string, string[]> { const graph = new Map<string, string[]>(); for (const file of tsFiles) { const fullPath = path.join(projectRoot, file); const content = fs.readFileSync(fullPath, 'utf-8'); // 匹配 import 语句 const importRegex = /from\s+['"]([^'"]+)['"]/g; let match; while ((match = importRegex.exec(content)) !== null) { const importPath = match[1]; // 解析相对路径 const resolved = this.resolveImportPath(file, importPath, tsFiles); if (resolved) { if (!graph.has(resolved)) { graph.set(resolved, []); } graph.get(resolved)!.push(file); } } } return graph; } private resolveImportPath( currentFile: string, importPath: string, tsFiles: string[] ): string | null { // 简化实现:只处理相对路径 if (!importPath.startsWith('.')) return null; const dir = path.dirname(currentFile); const resolved = path.normalize(path.join(dir, importPath)); // 尝试匹配文件(处理 .ts/.tsx 省略扩展名的情况) const candidates = [ resolved + '.ts', resolved + '.tsx', resolved + '/index.ts', resolved + '/index.tsx', ]; for (const candidate of candidates) { if (tsFiles.includes(candidate)) return candidate; } return null; } } // 生成 Markdown 报告 async function generateReport(projectRoot: string): Promise<string> { const analyzer = new StrictifyAnalyzer(); const reports = await analyzer.analyze(projectRoot); const totalErrors = reports.reduce((sum, r) => sum + r.errorCount, 0); const cleanFiles = reports.filter(r => r.errorCount === 0).length; let md = `# TypeScript Strict 模式迁移报告\n\n`; md += `- 总文件数: ${reports.length}\n`; md += `- 已通过 strict 检查: ${cleanFiles} (${(cleanFiles / reports.length * 100).toFixed(1)}%)\n`; md += `- 总错误数: ${totalErrors}\n`; md += `- 预计修复时间: ${Math.ceil(totalErrors / 50)} 人天 (按 50 错误/人天)\n\n`; md += `## 优先修复列表 (Top 20)\n\n`; md += `| 序号 | 文件 | 错误数 | 被依赖数 | 优先级 |\n`; md += `|------|------|--------|----------|--------|\n`; reports.slice(0, 20).forEach((r, i) => { md += `| ${i + 1} | ${r.filePath} | ${r.errorCount} | ${r.dependencyCount} | ${r.priority.toFixed(2)} |\n`; }); return md; } // 运行 (async () => { const report = await generateReport(process.cwd()); fs.writeFileSync('STRICT_MIGRATION_REPORT.md', report); console.log('报告已生成: STRICT_MIGRATION_REPORT.md'); })();白名单 tsconfig 配置
{ "extends": "./tsconfig.json", "compilerOptions": { "strict": true, "strictNullChecks": true, "noImplicitAny": true, "noImplicitReturns": true, "noUncheckedIndexedAccess": true }, "include": [ "src/strict/**/*.ts" ], "files": [] }迁移策略:已通过检查的文件移到src/strict/目录下,该目录下的tsconfig开启 strict。主tsconfig保持strict: false,用references关联子工程。
四、渐进式迁移的边界
缺点:
- 白名单维护成本:随着 strict 文件的增多,需要持续更新白名单。自动化 CI 检查可以减少维护成本,但首次配置复杂。
- 跨文件类型问题:文件 A 开启了 strict,import 了文件 B(未开启 strict)导出的
any类型,A 文件的类型安全仍会被破坏。需要配合"依赖优先"策略,先修复被依赖最多的文件。 - coerce 类型的隐式转换:
strictNullChecks开启后,很多代码需要显式??或if判空。对业务逻辑的影响可能超出预期。
禁用场景:
- 项目生命周期只剩 3 个月以内:迁移的投入产出比不值得。
- 代码中存在大量动态类型(如 JSON.parse 的动态返回值):需要先设计类型断言层,再开启 strict。
五、总结
TypeScript 严格模式的渐进式迁移,核心策略是"逐文件推进 + 白名单锁定 + 依赖优先"。诊断阶段用自动化工具按文件分析错误分布和依赖关系,消化阶段通过子 tsconfig 白名单隔离已修复文件,推进阶段将 strict 修复纳入 PR 的准出条件。关键原则:先修被依赖最多的文件,每次 PR 改少量文件,零中断推进。