Vue 2 vs Vue 3 对比
问题
Vue 2 和 Vue 3 有哪些主要区别?升级到 Vue 3 需要注意什么?
答案
Vue 3 于 2020 年 9 月正式发布,带来了许多重大改进。以下是全面的对比分析。
核心差异概览
响应式系统
Vue 2: Object.defineProperty
// Vue 2 响应式原理(简化版)
function defineReactive(obj: Record<string, any>, key: string) {
let value = obj[key];
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
dep.depend(); // 收集依赖
return value;
},
set(newVal) {
if (newVal === value) return;
value = newVal;
dep.notify(); // 通知更新
}
});
}
// ❌ 问题1: 无法检测新增/删除属性
const obj = { a: 1 };
obj.b = 2; // 不会触发响应式
// 必须使用 $set
Vue.set(obj, 'b', 2);
this.$set(obj, 'b', 2);
// ❌ 问题2: 无法检测数组索引变化
const arr = [1, 2, 3];
arr[0] = 100; // 不会触发响应式
// 必须使用变异方法或 $set
this.$set(arr, 0, 100);
Vue 3: Proxy
// Vue 3 响应式原理(简化版)
function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key); // 收集依赖
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // 触发更新
return result;
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key);
trigger(target, key);
return result;
}
});
}
// ✅ 自动检测新增属性
const obj = reactive({ a: 1 });
obj.b = 2; // 自动响应式
// ✅ 自动检测数组变化
const arr = reactive([1, 2, 3]);
arr[0] = 100; // 自动触发更新
arr.length = 1; // 也能触发
对比表格
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 响应式实现 | Object.defineProperty | Proxy |
| 新增属性 | 需要 $set | 自动响应 |
| 删除属性 | 需要 $delete | 自动响应 |
| 数组索引 | 需要 $set | 自动响应 |
| 性能 | 初始化递归遍历 | 懒代理(按需) |
| 浏览器支持 | IE9+ | 不支持 IE |
API 风格
Vue 2: Options API
export default {
data() {
return {
count: 0,
name: ''
};
},
computed: {
double() {
return this.count * 2;
}
},
watch: {
count(newVal, oldVal) {
console.log('count changed');
}
},
methods: {
increment() {
this.count++;
}
},
mounted() {
console.log('mounted');
}
};
Vue 3: Composition API
import { ref, computed, watch, onMounted } from 'vue';
// 选项1: setup 函数
export default {
setup() {
const count = ref(0);
const name = ref('');
const double = computed(() => count.value * 2);
watch(count, (newVal, oldVal) => {
console.log('count changed');
});
function increment() {
count.value++;
}
onMounted(() => {
console.log('mounted');
});
return { count, name, double, increment };
}
};
// 选项2: <script setup>(推荐)
// <script setup lang="ts">
// import { ref, computed, watch, onMounted } from 'vue';
//
// const count = ref(0);
// const double = computed(() => count.value * 2);
// const increment = () => count.value++;
// onMounted(() => console.log('mounted'));
// </script>
逻辑复用对比
// Vue 2: Mixins(有命名冲突风险)
const counterMixin = {
data() {
return { count: 0 };
},
methods: {
increment() {
this.count++;
}
}
};
export default {
mixins: [counterMixin, anotherMixin], // 可能冲突
};
// Vue 3: Composables(清晰、可组合)
function useCounter(initialValue = 0) {
const count = ref(initialValue);
const increment = () => count.value++;
const decrement = () => count.value--;
return { count, increment, decrement };
}
// 使用
const { count: count1, increment: increment1 } = useCounter();
const { count: count2, increment: increment2 } = useCounter(10);
生命周期变化
| Vue 2 | Vue 3 Options API | Vue 3 Composition API |
|---|---|---|
| beforeCreate | beforeCreate | setup() |
| created | created | setup() |
| beforeMount | beforeMount | onBeforeMount |
| mounted | mounted | onMounted |
| beforeUpdate | beforeUpdate | onBeforeUpdate |
| updated | updated | onUpdated |
| beforeDestroy | beforeUnmount | onBeforeUnmount |
| destroyed | unmounted | onUnmounted |
| errorCaptured | errorCaptured | onErrorCaptured |
| - | renderTracked | onRenderTracked |
| - | renderTriggered | onRenderTriggered |
// Vue 3 Composition API 生命周期
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onRenderTracked,
onRenderTriggered
} from 'vue';
// <script setup>
onMounted(() => {
console.log('组件已挂载');
});
onBeforeUnmount(() => {
console.log('组件即将卸载');
});
// 调试用
onRenderTracked((e) => {
console.log('依赖被追踪', e);
});
性能优化
Vue 3 编译时优化
// 静态提升 (Static Hoisting)
// Vue 2: 每次渲染都创建 VNode
// Vue 3: 静态节点只创建一次
// 模板
// <div>
// <span>静态内容</span>
// <span>{{ dynamic }}</span>
// </div>
// Vue 3 编译结果
const _hoisted_1 = /*#__PURE__*/_createElementVNode("span", null, "静态内容");
function render(_ctx) {
return (_openBlock(), _createElementBlock("div", null, [
_hoisted_1, // 复用静态节点
_createElementVNode("span", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
]));
}
// PatchFlags 精确更新
// 1 = TEXT - 动态文本
// 2 = CLASS - 动态 class
// 4 = STYLE - 动态 style
// 8 = PROPS - 动态属性
性能对比
| 优化项 | Vue 2 | Vue 3 |
|---|---|---|
| 初始化速度 | 递归遍历转换 | 按需代理 |
| 内存占用 | 较大 | 更小 |
| 更新速度 | 全量 Diff | 靶向更新 |
| 包体积 | ~23KB | ~10KB (Tree-shaking) |
| SSR 性能 | - | 2-3 倍提升 |
新增特性
Fragment(多根节点)
<!-- Vue 2: 必须有根节点 -->
<template>
<div>
<header>Header</header>
<main>Content</main>
</div>
</template>
<!-- Vue 3: 支持多根节点 -->
<template>
<header>Header</header>
<main>Content</main>
<footer>Footer</footer>
</template>
Teleport
<template>
<button @click="showModal = true">打开弹窗</button>
<!-- 渲染到 body 下 -->
<Teleport to="body">
<div v-if="showModal" class="modal">
<p>弹窗内容</p>
<button @click="showModal = false">关闭</button>
</div>
</Teleport>
</template>
Suspense
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<script setup lang="ts">
// 异步组件
const AsyncComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
);
</script>
多个 v-model
<!-- Vue 2: 只能有一个 v-model -->
<MyInput v-model="value" />
<!-- Vue 3: 支持多个 v-model -->
<UserForm
v-model:name="userName"
v-model:email="userEmail"
v-model:age="userAge"
/>
<!-- 子组件 -->
<script setup lang="ts">
const name = defineModel<string>('name');
const email = defineModel<string>('email');
const age = defineModel<number>('age');
</script>
破坏性变更
移除的特性
| 移除项 | Vue 2 | Vue 3 替代方案 |
|---|---|---|
| 过滤器 | {{ value | filter }} | 方法或 computed |
| off/$once | 事件总线 | mitt/tiny-emitter |
| $children | 访问子组件 | ref 或 provide/inject |
| $listeners | 事件监听器 | $attrs 统一处理 |
| .sync 修饰符 | :prop.sync | v-model:prop |
| inline-template | 内联模板 | 默认插槽 |
v-model 变化
<!-- Vue 2 -->
<MyInput v-model="value" />
<!-- 等价于 -->
<MyInput :value="value" @input="value = $event" />
<!-- Vue 3 -->
<MyInput v-model="value" />
<!-- 等价于 -->
<MyInput :modelValue="value" @update:modelValue="value = $event" />
<!-- Vue 3 子组件 -->
<script setup lang="ts">
// 方式1: defineModel (Vue 3.4+)
const modelValue = defineModel<string>();
// 方式2: defineProps + defineEmits
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
key 使用变化
<!-- Vue 2: v-if/v-else 需要手动加 key -->
<div v-if="condition" key="a">A</div>
<div v-else key="b">B</div>
<!-- Vue 3: 自动生成 key,无需手动添加 -->
<div v-if="condition">A</div>
<div v-else>B</div>
<!-- Vue 2: template 上的 key 放在子元素 -->
<template v-for="item in list">
<div :key="item.id">{{ item.name }}</div>
</template>
<!-- Vue 3: key 可以放在 template 上 -->
<template v-for="item in list" :key="item.id">
<div>{{ item.name }}</div>
</template>
全局 API 变化
// Vue 2
import Vue from 'vue';
Vue.component('MyComponent', MyComponent);
Vue.directive('focus', focusDirective);
Vue.use(VueRouter);
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app');
// Vue 3
import { createApp } from 'vue';
const app = createApp(App);
app.component('MyComponent', MyComponent);
app.directive('focus', focusDirective);
app.use(router);
app.use(pinia);
app.mount('#app');
// 多实例隔离
const app1 = createApp(App1);
const app2 = createApp(App2);
TypeScript 支持
// Vue 2 + TypeScript (需要 vue-class-component)
import Vue from 'vue';
import Component from 'vue-class-component';
@Component
export default class MyComponent extends Vue {
count: number = 0;
get double(): number {
return this.count * 2;
}
increment(): void {
this.count++;
}
}
// Vue 3 + TypeScript (原生支持)
<script setup lang="ts">
import { ref, computed } from 'vue';
interface User {
id: number;
name: string;
}
const props = defineProps<{
user: User;
optional?: string;
}>();
const emit = defineEmits<{
change: [value: string];
update: [id: number, data: User];
}>();
const count = ref<number>(0);
const double = computed<number>(() => count.value * 2);
</script>
迁移策略
渐进式迁移
// 1. 使用 @vue/compat 兼容构建
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue({
template: {
compilerOptions: {
compatConfig: {
MODE: 2 // Vue 2 兼容模式
}
}
}
})],
resolve: {
alias: {
vue: '@vue/compat'
}
}
});
// 2. 逐步迁移,处理警告
// 3. 完成后切换为 Vue 3 正式版
迁移检查清单
- 替换 Vue 2 only 的 API($on、$off、filters 等)
- 更新生命周期钩子名称
- 迁移 Vuex 到 Pinia(可选)
- 更新 v-model 用法
- 检查 v-for 和 v-if 优先级
- 处理全局 API 变化
- 更新第三方库版本
常见面试问题
Q1: Vue 3 相比 Vue 2 有哪些优势?
答案:
| 方面 | 优势 |
|---|---|
| 响应式 | Proxy 替代 defineProperty,支持新增/删除属性、数组索引 |
| 性能 | 编译优化(静态提升、PatchFlags)、更小体积、更快 SSR |
| API | Composition API 更好的逻辑复用和代码组织 |
| TypeScript | 原生支持,类型推断更完善 |
| 新特性 | Fragment、Teleport、Suspense、多 v-model |
Q2: 为什么 Vue 3 不支持 IE?
答案:
Vue 3 使用 Proxy 作为响应式核心,而 Proxy 无法被 polyfill。这是设计决策,为了获得更好的性能和更完善的响应式支持。
如需支持 IE,方案:
- 继续使用 Vue 2(LTS 支持到 2023.12)
- 使用 Vue 2.7(包含 Composition API)
Q3: Composition API 和 Options API 如何选择?
答案:
| 场景 | 推荐 |
|---|---|
| 小型项目 | Options API(简单直观) |
| 大型项目 | Composition API(更好组织) |
| 逻辑复用多 | Composition API(composables) |
| 团队熟悉度低 | Options API(学习成本低) |
| TypeScript 项目 | Composition API(类型支持好) |
两者可以混用,Vue 3 Options API 底层也是 Composition API 实现的。
Q4: Vue 2 项目如何平滑升级到 Vue 3?
答案:
// 步骤1: 使用 Vue 2.7 过渡
// 获得 Composition API、script setup 支持
// 同时保持 Vue 2 兼容性
// 步骤2: 使用迁移构建测试
import { createApp } from '@vue/compat';
import App from './App.vue';
const app = createApp(App);
// 查看控制台警告,逐一修复
// 步骤3: 处理破坏性变更
// - 移除 filters,改用 methods
// - 替换 $on/$off,使用 mitt
// - 更新 v-model 用法
// - 检查第三方库兼容性
// 步骤4: 切换到 Vue 3 正式版
import { createApp } from 'vue';
Q5: Vue 3 的 Teleport 和 Fragments 解决了什么问题?
答案:
Fragments(多根节点)
Vue 2 的问题:每个组件模板必须有一个根节点,导致大量无意义的包裹 <div>,产生冗余 DOM 层级,影响样式(特别是 Flex/Grid 布局)。
<!-- Vue 2: 必须包一层根节点 -->
<template>
<div> <!-- 😩 多余的包裹层 -->
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</div>
</template>
<!-- Vue 3: Fragment 支持多根节点 -->
<template>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</template>
Fragment 的好处
- 减少无意义 DOM 节点,DOM 树更扁平
- 不破坏 CSS 布局,如
<table>内的<tr>组件、Flex 容器内的子组件 - 渲染性能微提升,少创建一个 VNode 和真实 DOM 节点
Fragment 注意事项
多根节点组件在接收 $attrs 时需要显式绑定,否则会报警告:
<!-- 子组件:多根节点 -->
<template>
<!-- 需要指定哪个元素继承 attrs -->
<header v-bind="$attrs">Header</header>
<main>Content</main>
</template>
Teleport(传送门)
Vue 2 的问题:Modal、Toast、Tooltip 等组件在逻辑上属于父组件,但在 DOM 上需要渲染到 <body> 或其他位置。Vue 2 中只能通过 document.body.appendChild 手动操作 DOM,或依赖第三方库(如 portal-vue)。
Vue 3 内置 Teleport:将组件的 DOM 渲染到指定的目标位置,同时保持组件的逻辑层级不变。
<script setup lang="ts">
import { ref } from 'vue';
const showModal = ref(false);
const showToast = ref(false);
</script>
<template>
<div class="app-layout">
<button @click="showModal = true">打开弹窗</button>
<!-- Modal 渲染到 body,避免被 overflow: hidden 裁剪 -->
<Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal-content">
<h2>弹窗标题</h2>
<p>这个 DOM 在 body 下,但逻辑仍属于当前组件</p>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
<!-- Toast 渲染到 #toast-container -->
<Teleport to="#toast-container">
<div v-if="showToast" class="toast">操作成功!</div>
</Teleport>
<!-- 条件性禁用 Teleport -->
<Teleport to="body" :disabled="isMobile">
<Sidebar />
</Teleport>
</div>
</template>
Teleport 解决的核心问题:
| 问题 | 没有 Teleport | 有 Teleport |
|---|---|---|
Modal 被 overflow: hidden 裁剪 | 手动挂载到 body | <Teleport to="body"> |
| z-index 层叠上下文问题 | 复杂的 z-index 管理 | 渲染到独立层级 |
| 组件逻辑与 DOM 位置绑定 | 逻辑和 DOM 耦合 | 逻辑在组件内,DOM 在目标位置 |
| 全局提示/通知 | 手动操作 DOM | 声明式 Teleport |
Teleport 的关键特性
to:CSS 选择器字符串或 DOM 元素引用disabled:动态禁用传送,恢复为原地渲染- 多个 Teleport 共用目标:按顺序追加
- 保持响应式:Teleport 内的组件仍然是父组件的逻辑子组件,
provide/inject正常工作
Q6: 从 Vue 2 迁移到 Vue 3 有哪些破坏性变更需要注意?
答案:
Vue 3 的破坏性变更可分为以下几个大类。面试中通常按影响范围从大到小来回答。
一、全局 API 变化
这是迁移中改动面最广的部分:
// ❌ Vue 2: 全局 API 挂在 Vue 构造函数上
import Vue from 'vue';
Vue.component('MyComp', MyComp);
Vue.directive('focus', focusDirective);
Vue.mixin(globalMixin);
Vue.use(VueRouter);
new Vue({ render: h => h(App) }).$mount('#app');
// ✅ Vue 3: 应用实例 API,支持多实例隔离
import { createApp } from 'vue';
const app = createApp(App);
app.component('MyComp', MyComp);
app.directive('focus', focusDirective);
app.use(router);
app.mount('#app');
重要区别
Vue 2 全局 API 会污染所有实例,Vue 3 每个 createApp() 独立隔离。这对微前端和测试场景非常关键。
二、移除的 API
| 移除的 API | Vue 2 用法 | Vue 3 替代方案 |
|---|---|---|
| 过滤器 filters | {{ msg | capitalize }} | computed 或 method |
| off / $once | 事件总线 bus.$on('event', cb) | mitt 或 provide/inject |
| $children | this.$children[0] | ref 或 provide/inject |
| $listeners | v-on="$listeners" | 合并到 $attrs |
| delete | Vue.set(obj, key, val) | 不再需要,Proxy 自动响应 |
| .sync 修饰符 | :title.sync="val" | v-model:title="val" |
| inline-template | <child inline-template> | 使用默认插槽 |
| $destroy() | 手动销毁实例 | 不再需要 |
三、v-model 变化
<!-- Vue 2 -->
<!-- v-model 默认使用 value prop + input 事件 -->
<MyInput v-model="text" />
<!-- 等价于 -->
<MyInput :value="text" @input="text = $event" />
<!-- Vue 3 -->
<!-- v-model 默认使用 modelValue prop + update:modelValue 事件 -->
<MyInput v-model="text" />
<!-- 等价于 -->
<MyInput :modelValue="text" @update:modelValue="text = $event" />
<!-- Vue 3 子组件适配 -->
<script setup lang="ts">
// 方式 1: defineModel(Vue 3.4+,推荐)
const modelValue = defineModel<string>();
// 方式 2: defineProps + defineEmits
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
四、v-for 和 v-if 优先级变化
<!-- Vue 2: v-for 优先级高于 v-if -->
<!-- 先遍历再判断(性能浪费) -->
<li v-for="item in list" v-if="item.active">{{ item.name }}</li>
<!-- Vue 3: v-if 优先级高于 v-for -->
<!-- v-if 先执行,此时 item 还不存在,会报错! -->
<li v-for="item in list" v-if="item.active">{{ item.name }}</li> <!-- ❌ 报错 -->
<!-- ✅ Vue 3 正确写法 -->
<template v-for="item in list" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
<!-- 或用 computed 过滤 -->
<script setup lang="ts">
import { computed } from 'vue';
const activeItems = computed(() => list.value.filter(item => item.active));
</script>
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
五、生命周期钩子重命名
// Vue 2 → Vue 3 Options API
// beforeDestroy → beforeUnmount
// destroyed → unmounted
// Composition API 中
import { onBeforeUnmount, onUnmounted } from 'vue';
onBeforeUnmount(() => {
// 清理定时器、事件监听等
});
六、其他需注意的变更
| 变更 | 说明 | 迁移方式 |
|---|---|---|
| Transition class 重命名 | v-enter → v-enter-from,v-leave → v-leave-from | 全局搜索替换 |
| key 用法 | v-if/v-else 自动生成 key;<template v-for> 的 key 放在 template 上 | 移除手动 key |
| emits 声明 | 组件需显式声明 emits | 添加 defineEmits |
| render 函数 | h 从全局导入,不再作为参数传入 | import { h } from 'vue' |
| 异步组件 | () => import() → defineAsyncComponent(() => import()) | 包裹一层 |
| 函数式组件 | 不再需要 functional: true,普通函数即可 | 简化写法 |
实用迁移工具
- npm
- Yarn
- pnpm
- Bun
npm install @vue/compat
yarn add @vue/compat
pnpm add @vue/compat
bun add @vue/compat
vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
compatConfig: { MODE: 2 } // 开启 Vue 2 兼容模式
}
}
})
],
resolve: {
alias: {
vue: '@vue/compat' // 使用兼容构建
}
}
});
迁移建议
- 先升级到 Vue 2.7:获得
<script setup>、Composition API 支持,减少后续迁移量 - 使用
@vue/compat:开启兼容模式,逐步修复控制台中的弃用警告 - 优先处理第三方库兼容:Vuex → Pinia、Vue Router 3 → 4、Element UI → Element Plus
- 利用 gogocode 自动化迁移工具批量转换代码