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 个月的迁移,我们团队收获了:
- 🐛 Bug 减少:类型检查帮我们发现了 15+ 个潜在 bug
- 🚀 重构更快:重命名、修改接口参数更安全
- 📖 文档即代码:类型定义就是最好的文档
- 💡 IDE 提示:自动补全、跳转定义、智能提示
- 👥 新人上手:接口类型一目了然,减少沟通成本
总结
老项目迁移 TypeScript 是一场持久战,不是一蹴而就的。
核心原则:
- ✅ 渐进式迁移,不要追求一步到位
- ✅ 先迁移工具函数和 Store,收益最大
- ✅ 新代码直接写 TS,避免技术债务累积
- ✅ 配置要灵活,初期宽松,后期严格
- ✅ 团队统一规范,避免风格混乱
最重要的建议:
不要等完美,先开始。 哪怕今天只迁移一个文件,也是在向更好的代码质量迈进。
参考资料
如果觉得这篇文章对你有帮助,欢迎点赞、收藏、关注三连支持! 💪
你在 Vue2 迁移 TS 的过程中遇到过什么坑?欢迎在评论区分享!
本文首发于掘金,欢迎交流讨论