Vue2 老项目上 TS?这 10 个坑我替你踩了,附完整迁移方案

5 阅读6分钟

Vue2 老项目上 TS?这 10 个坑我替你踩了,附完整迁移方案

历时 2 个月,从 8 万行 JavaScript 到 TypeScript 的"排雷"之旅。不吹不黑,只讲实战中遇到的真实问题和解决方案。


前言

公司有个维护了 3 年的 Vue2 项目,代码量接近 8 万行,全是 JavaScript。最近业务方提了个需求:"新项目必须上 TypeScript,老项目也逐步迁移"。

于是,我们这支 5 人的前端团队开始了这场"排雷"之旅。

最终结果:

  • ✅ 核心模块 100% 迁移完成
  • ✅ 发现并修复了 15+ 个潜在 bug
  • ✅ 代码重构时间减少 40%
  • ❌ 但我们也踩了至少 10 个大坑

今天就把这些坑一一列出,让你避坑成功!


坑 1:vue-template-compiler 版本不一致

症状: 编译报错,模板中的组件无法识别

[Vue warn]: Unknown custom element: <UserCard>

原因: vue-template-compiler 的版本必须和 vue 完全一致

错误配置:

{
  "dependencies": {
    "vue": "^2.6.14"
  },
  "devDependencies": {
    "vue-template-compiler": "^2.7.0"  // ❌ 版本不对
  }
}

正确配置:

{
  "dependencies": {
    "vue": "^2.6.14"
  },
  "devDependencies": {
    "vue-template-compiler": "^2.6.14"  // ✅ 版本必须一致
  }
}

解决方案:

npm uninstall vue-template-compiler
npm install vue-template-compiler@2.6.14 --save-dev

坑 2:装饰器语法需要特殊配置

症状: 使用 @Component@Prop 等装饰器时报错

@Component  // ❌ SyntaxError: Decorators are not enabled
export default class UserList extends Vue {
}

原因: TypeScript 和 Babel 都需要开启装饰器支持

解决方案:

1. tsconfig.json

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

2. babel.config.js

module.exports = {
  presets: [
    '@vue/cli-plugin-babel/preset'
  ],
  plugins: [
    ['@babel/plugin-proposal-decorators', { legacy: true }],
    ['@babel/plugin-proposal-class-properties', { loose: true }]
  ]
}

注意: legacy: true 是必须的,否则和 TypeScript 的装饰器实现不兼容!


坑 3:Mixins 的类型推断失效

症状: Mixins 中的 this 类型丢失,无法访问属性和方法

错误写法:

// mixins/loading-mixin.ts
export const LoadingMixin = {
  data() {
    return {
      loading: false
    }
  },
  methods: {
    showLoading() {
      this.loading = true  // ❌ this 类型是 any
    }
  }
}

正确写法:

import { VueConstructor } from 'vue';

interface LoadingMixinData {
  loading: boolean;
}

interface LoadingMixinMethods {
  showLoading(): void;
  hideLoading(): void;
}

type LoadingMixinInstance = VueConstructor & LoadingMixinData & LoadingMixinMethods;

export const LoadingMixin = {
  data(): LoadingMixinData {
    return {
      loading: false
    };
  },
  methods: {
    showLoading() {
      (this as LoadingMixinInstance).loading = true;
    },
    hideLoading() {
      (this as LoadingMixinInstance).loading = false;
    }
  }
} as const;

使用方式:

import { Vue, Component } from 'vue-property-decorator';
import { LoadingMixin } from '@/mixins/loading-mixin';

@Component
export default class UserList extends Vue.extend(LoadingMixin) {
  // 现在可以正常使用 this.showLoading() 了
}

坑 4:$emit 事件类型定义

症状: $emit 参数没有类型检查,容易传错参数

错误写法:

handleUpdate(data) {
  this.$emit('update', data)  // ❌ 没有类型检查
  this.$emit('updat', data)   // ❌ 拼写错误也发现不了
}

正确写法:

interface UserListEvents {
  (e: 'update', data: UserData): void;
  (e: 'delete', id: number): void;
  (e: 'refresh'): void;
}

@Component
export default class UserList extends Vue {
  $emit!: UserListEvents;  // 类型覆盖

  handleUpdate(data: UserData) {
    this.$emit('update', data);  // ✅ 有类型检查
  }

  handleDelete(id: number) {
    this.$emit('delete', id);    // ✅ 有类型检查
    // this.$emit('dele', id);   // ❌ 编译报错
  }
}

坑 5:Vuex Module 的 namespaced 问题

症状: 使用 @Action@Getter 时找不到对应的 action 或 getter

错误配置:

// store/modules/user.ts
export const userModule = {
  namespaced: true,  // ✅ 开启了命名空间
  state: { ... },
  actions: { ... }
};

错误使用:

@Component
export default class UserPage extends Vue {
  @Action('login')  // ❌ 找不到 action
  declare login: any;

  @Getter('userInfo')  // ❌ 找不到 getter
  declare userInfo: any;
}

正确写法:

@Component
export default class UserPage extends Vue {
  @Action('user/login')  // ✅ 加上模块名前缀
  declare login: (params: LoginParams) => Promise<void>;

  @Getter('user/userInfo')  // ✅ 加上模块名前缀
  declare userInfo: UserInfo | null;
}

坑 6:第三方库缺少类型定义

症状: 导入第三方库时报错 Cannot find module

场景 1:有 @types 包

npm install --save-dev @types/lodash
npm install --save-dev @types/axios
npm install --save-dev @types/moment

场景 2:没有 @types 包(如老版本的 element-ui)

方案 1:安装社区维护的类型定义

npm install --save-dev @types/element-ui

方案 2:自己写声明文件

创建 src/types/element-ui.d.ts

declare module 'element-ui' {
  import { Component } from 'vue';
  
  export const Button: Component;
  export const Input: Component;
  export const Table: Component;
  export const TableColumn: Component;
  export const Dialog: Component;
  // ... 其他组件
}

方案 3:临时使用 any 类型

// src/shims-custom.d.ts
declare module 'some-old-library' {
  const value: any;
  export default value;
}

坑 7:async/await 在生命周期中的 this 指向

症状:async mounted() 中使用 this 时报错

错误写法:

async mounted() {
  const data = await fetchUserList();
  this.userList = data;  // ❌ this 类型丢失
}

正确写法 1:箭头函数绑定

mounted = async () => {
  const data = await fetchUserList();
  this.userList = data;  // ✅ this 指向正确
}

正确写法 2:类型断言

async mounted() {
  const data = await fetchUserList();
  (this as any).userList = data;  // ✅ 临时解决方案
}

正确写法 3:定义明确类型(推荐)

class UserListComponent extends Vue {
  userList: User[] = [];

  async mounted() {
    const data = await fetchUserList();
    this.userList = data;  // ✅ 类型明确
  }
}

坑 8:动态导入的类型推断

症状: 使用 import() 动态加载组件时类型丢失

错误写法:

const Component = () => import('@/components/UserCard');
// Component 的类型是 Promise<any>,无法获取组件实例类型

正确写法:

import { Component as VueComponent } from 'vue';

const Component = () => 
  import('@/components/UserCard') as Promise<{ default: VueComponent }>;

// 使用
const component = await Component();
this.$set(this.dynamicComponent, component.default);

坑 9:模板中的空值检查

症状: 模板中访问可能为 null 的属性时没有警告

错误写法:

<template>
  <div>{{ user.name }}</div>  // ❌ user 可能是 null
</template>

<script lang="ts">
export default class UserPage extends Vue {
  user: UserInfo | null = null;
}
</script>

正确写法 1:可选链操作符

<template>
  <div>{{ user?.name }}</div>  // ✅ 安全访问
</template>

正确写法 2:计算属性

<template>
  <div>{{ userName }}</div>  // ✅ 已经处理了 null 情况
</template>

<script lang="ts">
export default class UserPage extends Vue {
  user: UserInfo | null = null;

  @Computed
  get userName(): string {
    return this.user?.name || '未知用户';
  }
}
</script>

正确写法 3:v-if 判断

<template>
  <div v-if="user">{{ user.name }}</div>
  <div v-else>加载中...</div>
</template>

坑 10:渐进式迁移的配置策略

症状: 一次性开启所有严格检查,导致全项目报错无法编译

错误配置:

{
  "compilerOptions": {
    "strict": true,              // ❌ 初期不要开启
    "noImplicitAny": true,       // ❌ 老代码全是 any
    "strictNullChecks": true,    // ❌ null/undefined 检查太严格
  }
}

正确配置(分阶段):

阶段 1:迁移初期

{
  "compilerOptions": {
    "allowJs": true,             // ✅ 允许 JS 和 TS 共存
    "noImplicitAny": false,      // ✅ 暂时关闭 any 检查
    "strictNullChecks": false,   // ✅ 暂时关闭空值检查
    "skipLibCheck": true,        // ✅ 跳过第三方库检查
    "experimentalDecorators": true
  }
}

阶段 2:迁移中期(核心模块完成后)

{
  "compilerOptions": {
    "noImplicitAny": true,       // ✅ 开启 any 检查
    "strictNullChecks": true,    // ✅ 开启空值检查
  },
  "exclude": [
    "node_modules",
    "dist",
    "src/legacy"                 // ✅ 排除未迁移的老代码
  ]
}

阶段 3:迁移完成后

{
  "compilerOptions": {
    "strict": true,              // ✅ 开启所有严格检查
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true
  }
}

迁移策略建议

1. 迁移顺序

1. 工具函数 (utils/)          → 无状态,最容易
2. 类型定义 (types/)          → 为后续迁移打基础
3. Vuex Store                 → 类型复杂但收益大
4. 新组件                     → 直接写 TS
5. 核心组件                   → 按业务优先级
6. 工具组件                   → 最后迁移

2. 文件命名规范

src/
├── components/
│   ├── UserList.vue      # Vue 文件不改后缀
│   ├── utils/
│   │   ├── request.ts    # 新文件用 .ts
│   │   └── request.js    # 老文件保持 .js(可逐步删除)
└── types/
    └── index.d.ts        # 类型定义文件

3. 必备依赖

{
  "dependencies": {
    "vue": "^2.6.14",
    "vuex": "^3.6.2"
  },
  "devDependencies": {
    "typescript": "^4.9.5",
    "vue-class-component": "^7.2.6",
    "vue-property-decorator": "^9.1.2",
    "vue-template-compiler": "^2.6.14",
    "@types/node": "^18.0.0",
    "@types/lodash": "^4.14.0"
  }
}

迁移后的收益

经过 2 个月的迁移,我们团队收获了:

  1. 🐛 Bug 减少:类型检查帮我们发现了 15+ 个潜在 bug
  2. 🚀 重构更快:重命名、修改接口参数更安全
  3. 📖 文档即代码:类型定义就是最好的文档
  4. 💡 IDE 提示:自动补全、跳转定义、智能提示
  5. 👥 新人上手:接口类型一目了然,减少沟通成本

总结

老项目迁移 TypeScript 是一场持久战,不是一蹴而就的。

核心原则:

  • ✅ 渐进式迁移,不要追求一步到位
  • ✅ 先迁移工具函数和 Store,收益最大
  • ✅ 新代码直接写 TS,避免技术债务累积
  • ✅ 配置要灵活,初期宽松,后期严格
  • ✅ 团队统一规范,避免风格混乱

最重要的建议:

不要等完美,先开始。 哪怕今天只迁移一个文件,也是在向更好的代码质量迈进。


参考资料


如果觉得这篇文章对你有帮助,欢迎点赞、收藏、关注三连支持! 💪

你在 Vue2 迁移 TS 的过程中遇到过什么坑?欢迎在评论区分享!


本文首发于掘金,欢迎交流讨论