跳到主内容

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,通过忽略清单和分阶段规则,把存量问题登记成技术债,同时强制新增代码达标。

1. 基线盘点统计 any 与报错2. 建立门禁新增代码先严格3. 分批收窄unknown 替换 any4. 指标验收any 清零 80%

第一步:建立 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 迁移中最常见的四个问题 ​

  1. 报错现象:对象下标访问返回 undefined,模板渲染直接崩溃。 开启 noUncheckedIndexedAccess 后,list[index].name 会提示对象可能为 undefined。修正方式不是写 list[index]!.name,而是先判空:const item = list[index]; return item ? item.name : ''。数组越界和空数组是线上真实存在的状态,非空断言只会掩盖问题。

  2. 报错现象:JSON.parse 后调用属性,strict 下提示对象类型为 unknown。 这是最典型的边界 any。不要使用 as UserProfile 硬断言,应编写 isUserProfile 类型守卫,在运行时确认 id、name 等关键字段。后端字段缺失、脏数据和 mock 异常都能因此提前暴露。

  3. 报错现象:Vue3 props 默认值报类型不兼容,尤其是可选对象或数组。 exactOptionalPropertyTypes 会区分“不传”和“显式传 undefined”。修正方式是使用 withDefaults 提供稳定默认值,数组返回 () => [],对象返回工厂函数;不要把 prop 写成 user: Object as any。

  4. 报错现象:第三方库类型缺失,团队重新引入大量 declare module 'xxx'。 此时应先确认库版本是否自带类型,再安装对应的 @types/* 包。确实没有类型时,只在 src/types/vendor.d.ts 中写最小可用声明,并把调用收敛到一个适配文件。避免为了绕过报错使用 @ts-nocheck,否则整个文件的类型保护都会失效。

strict 迁移的核心不是和编译器对抗,而是把不确定性从业务代码中赶回到系统边界。先用基线看清债务,再用门禁阻止新增 any,随后用 unknown 和类型守卫替换公共模块,最后按目录扩大严格范围。按这套四步法推进,大多数老项目都能在几个迭代内安全清零 80% 的 any,并为后续 Vue3 组件重构和工程化升级打下稳定基础。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。