TypeScript 严格模式迁移一批文件一批文件地开启 strict一、tsconfig.json 里 strict: false 是一个定时炸弹接手一个 3 年老项目时tsconfig.json里strict: falsenoImplicitAny: false。any满天飞null不检查undefined当 0 用类型系统形同虚设。跑线上不到一个月连出 3 个Cannot read property of undefined的事故——全是 TS 编译器本可以拦住的问题。但直接strict: true不可能——2000 文件瞬间爆出 8000 个 TS 错误。修复要四周起。老板等不了业务不停。必须设计一套渐进式迁移方案零中断逐文件推进每批文件的改动可控且可回滚。二、渐进式 strict 迁移的策略框架graph TD A[现状: strict: falsebr/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: truebr/移除白名单] 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): PromiseFileReport[] { // 收集所有 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[] ): Mapstring, string[] { const graph new Mapstring, 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): Promisestring { 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 开启了 strictimport 了文件 B未开启 strict导出的any类型A 文件的类型安全仍会被破坏。需要配合依赖优先策略先修复被依赖最多的文件。coerce 类型的隐式转换strictNullChecks开启后很多代码需要显式??或if判空。对业务逻辑的影响可能超出预期。禁用场景项目生命周期只剩 3 个月以内迁移的投入产出比不值得。代码中存在大量动态类型如 JSON.parse 的动态返回值需要先设计类型断言层再开启 strict。五、总结TypeScript 严格模式的渐进式迁移核心策略是逐文件推进 白名单锁定 依赖优先。诊断阶段用自动化工具按文件分析错误分布和依赖关系消化阶段通过子 tsconfig 白名单隔离已修复文件推进阶段将 strict 修复纳入 PR 的准出条件。关键原则先修被依赖最多的文件每次 PR 改少量文件零中断推进。