外观
any 清零 80%:老项目开启 TS strict 四步迁移实录
为什么老项目开启 strict 总是失败
很多维护多年的前端项目都处于一种尴尬状态:已经安装了 TypeScript,也写了不少 .ts 文件,但 tsconfig.json 中要么关闭了 strict,要么只开启了 noImplicitAny 的一半能力。一旦尝试直接打开 strict,编辑器会瞬间冒出上千个报错,团队只能回滚配置,最后 any 继续扩散。
问题不在于团队不会写类型,而在于迁移策略错了。strict 不是一个需要在某个周末一次性切换的开关,它更像一组独立的质量门禁:先让新增代码安全,再逐步偿还历史债务。本文分享一套经过多个 Vue3 + Vite 项目验证的四步法,目标不是消灭 100% 的 any,而是用较低成本先清零 80%,把剩余高风险场景隔离在明确的边界内。
strict 迁移方案对比:全量切换 vs 按文件治理
常见方案有三种。第一种是全量切换,直接把 strict 改为 true,优点是配置简单,缺点是短期内 CI 全红,业务需求无法并行开发。第二种是双 tsconfig 方案,新增文件使用严格配置,历史文件保持宽松,优点是风险低,但长期容易形成两套标准。第三种是本文推荐的渐进门禁方案:主配置开启 strict,通过忽略清单和分阶段规则,把存量问题登记成技术债,同时强制新增代码达标。
第一步:建立 any 基线,别凭感觉迁移
在改任何代码之前,先让问题可量化。使用 TypeScript 编译器输出报告,再用脚本统计显式 any、隐式 any 和 @ts-ignore 的数量。建议把结果写入 scripts/type-baseline.json,作为后续每个迭代的对比基线。
json
// tsconfig.json:先只打开诊断能力,避免业务文件立即报错
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": false,
"noImplicitAny": false,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}javascript
// scripts/count-any.mjs
// 可直接用 node scripts/count-any.mjs 运行,用于统计存量 any 基线
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const root = 'src';
const exts = new Set(['.ts', '.tsx', '.vue']);
const result = { explicitAny: 0, implicitComment: 0, tsIgnore: 0, files: [] };
function walk(dir) {
for (const name of readdirSync(dir)) {
const file = join(dir, name);
const stat = statSync(file);
if (stat.isDirectory()) {
walk(file);
} else if (exts.has(file.slice(file.lastIndexOf('.')))) {
const code = readFileSync(file, 'utf8');
const count = {
file,
explicitAny: (code.match(/:\s*any\b/g) || []).length,
tsIgnore: (code.match(/@ts-ignore|@ts-nocheck/g) || []).length
};
result.explicitAny += count.explicitAny;
result.tsIgnore += count.tsIgnore;
result.files.push(count);
}
}
}
walk(root);
console.log(JSON.stringify(result, null, 2));这一步的关键不是追求数字好看,而是识别高频污染点。通常 src/utils/request.ts、旧版 Vuex store、表单序列化函数和第三方 SDK 封装会贡献一半以上的 any。优先治理这些公共模块,收益最大。
第二步:用双配置建立新增代码门禁
存量代码可以暂时宽松,但新代码必须严格。推荐保留一个基础配置,再让严格配置继承它。CI 中同时执行两条命令:一条保证历史代码可编译,另一条只检查白名单目录或新增文件。
json
// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": [
"src/types/**/*.ts",
"src/api/**/*.ts",
"src/utils/safe/**/*.ts"
]
}json
// package.json 中增加脚本
{
"scripts": {
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"typecheck:strict": "vue-tsc --noEmit -p tsconfig.strict.json",n "count:any": "node scripts/count-any.mjs"
}
}如果团队使用 Git,可以在 CI 中通过 git diff --name-only origin/main 获取新增文件,再动态生成严格检查列表。这样迁移不再阻塞业务,而每个新需求都会自动扩大严格区域。
第三步:用 unknown 和类型守卫替换 any
很多人把 any 清零理解为给每个变量补一个接口,结果在接口不确定时被迫写出更多断言。更安全的做法是:在边界处使用 unknown 接收不确定数据,再通过类型守卫让 TypeScript 逐步收窄。
typescript
// src/utils/safe/type-guard.ts
export interface UserProfile {
id: number;
name: string;
email?: string;
}
// 自定义类型守卫:运行时确认结构,而不是盲目 as
export function isUserProfile(value: unknown): value is UserProfile {
if (typeof value !== 'object' || value === null) {
return false;
}
const user = value as Record<string, unknown>;
return typeof user.id === 'number' && typeof user.name === 'string';
}
// 安全解析接口响应,解析失败时返回明确错误
export function parseUser(input: unknown): UserProfile {
if (isUserProfile(input)) {
return input;
}
throw new Error('接口返回的用户结构不符合预期');
}请求封装是 any 的重灾区。不要把 AxiosResponse<any> 一路透传到页面,而应把泛型提升到调用处,并在拦截器中处理 unknown。
typescript
// src/api/request.ts
import axios from 'axios';
const http = axios.create({ baseURL: '/api', timeout: 8000 });
export async function getJson<T>(url: string, verify: (data: unknown) => data is T): Promise<T> {
const response = await http.get<unknown>(url);
if (verify(response.data)) {
return response.data;
}
throw new Error(`接口 ${url} 返回数据校验失败`);
}typescript
// src/api/user.ts
import { getJson } from './request';
import { isUserProfile, type UserProfile } from '@/utils/safe/type-guard';
export function fetchCurrentUser(): Promise<UserProfile> {
return getJson('/user/current', isUserProfile);
}Vue3 组件中,优先使用 defineProps<T>() 和 defineEmits<T>(),避免运行时声明与类型声明长期不一致。
vue
<!-- src/components/UserCard.vue -->
<script setup lang="ts">
import type { UserProfile } from '@/utils/safe/type-guard';
interface Props {
user: UserProfile;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false
});
const emit = defineEmits<{
refresh: [];
}>();
</script>
<template>
<article class="user-card">
<p>{{ user.name }}</p>
<p v-if="user.email">{{ user.email }}</p>
<button :disabled="loading" @click="emit('refresh')">刷新</button>
</article>
</template>第四步:按模块分批开启 strict 并设置阈值
当公共 API、工具函数和核心组件完成收窄后,就可以按目录扩大 tsconfig.strict.json 的 include 范围。建议顺序是:纯函数工具 → API 类型 → Pinia store → 组合式函数 → 页面组件 → 历史兼容模块。每迁移一个目录,重新运行 any 统计脚本,并把阈值写入 CI,只许下降不许上升。
javascript
// scripts/check-any-quota.mjs
// 简单的 any 配额检查,可接入 CI,阻止显式 any 增长
import { execFileSync } from 'node:child_process';
const quota = 120; // 当前剩余存量,每完成一批就下调
const output = execFileSync('node', ['scripts/count-any.mjs'], { encoding: 'utf8' });
const report = JSON.parse(output);
if (report.explicitAny > quota) {
console.error(`显式 any 数量 ${report.explicitAny},已超过配额 ${quota}`);
process.exit(1);
}
console.log(`any 检查通过:当前 ${report.explicitAny},配额 ${quota}`);当绝大多数目录通过后,可以把根配置切换为 strict: true,再用 exclude 保留少量遗留目录。此时项目已经完成 80% 的治理,剩余部分通常依赖老旧第三方库或复杂运行时对象,适合单独建立适配层,而不是继续污染业务代码。
避坑总结:strict 迁移中最常见的四个问题
报错现象:对象下标访问返回 undefined,模板渲染直接崩溃。 开启
noUncheckedIndexedAccess后,list[index].name会提示对象可能为 undefined。修正方式不是写list[index]!.name,而是先判空:const item = list[index]; return item ? item.name : ''。数组越界和空数组是线上真实存在的状态,非空断言只会掩盖问题。报错现象:
JSON.parse后调用属性,strict 下提示对象类型为 unknown。 这是最典型的边界 any。不要使用as UserProfile硬断言,应编写isUserProfile类型守卫,在运行时确认id、name等关键字段。后端字段缺失、脏数据和 mock 异常都能因此提前暴露。报错现象:Vue3 props 默认值报类型不兼容,尤其是可选对象或数组。
exactOptionalPropertyTypes会区分“不传”和“显式传 undefined”。修正方式是使用withDefaults提供稳定默认值,数组返回() => [],对象返回工厂函数;不要把 prop 写成user: Object as any。报错现象:第三方库类型缺失,团队重新引入大量
declare module 'xxx'。 此时应先确认库版本是否自带类型,再安装对应的@types/*包。确实没有类型时,只在src/types/vendor.d.ts中写最小可用声明,并把调用收敛到一个适配文件。避免为了绕过报错使用@ts-nocheck,否则整个文件的类型保护都会失效。
strict 迁移的核心不是和编译器对抗,而是把不确定性从业务代码中赶回到系统边界。先用基线看清债务,再用门禁阻止新增 any,随后用 unknown 和类型守卫替换公共模块,最后按目录扩大严格范围。按这套四步法推进,大多数老项目都能在几个迭代内安全清零 80% 的 any,并为后续 Vue3 组件重构和工程化升级打下稳定基础。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。