跳到主要内容

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 2Vue 3
响应式实现Object.definePropertyProxy
新增属性需要 $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 2Vue 3 Options APIVue 3 Composition API
beforeCreatebeforeCreatesetup()
createdcreatedsetup()
beforeMountbeforeMountonBeforeMount
mountedmountedonMounted
beforeUpdatebeforeUpdateonBeforeUpdate
updatedupdatedonUpdated
beforeDestroybeforeUnmountonBeforeUnmount
destroyedunmountedonUnmounted
errorCapturederrorCapturedonErrorCaptured
-renderTrackedonRenderTracked
-renderTriggeredonRenderTriggered
// 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 2Vue 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 2Vue 3 替代方案
过滤器{{ value | filter }}方法或 computed
on/on/off/$once事件总线mitt/tiny-emitter
$children访问子组件ref 或 provide/inject
$listeners事件监听器$attrs 统一处理
.sync 修饰符:prop.syncv-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
APIComposition API 更好的逻辑复用和代码组织
TypeScript原生支持,类型推断更完善
新特性Fragment、Teleport、Suspense、多 v-model

Q2: 为什么 Vue 3 不支持 IE?

答案

Vue 3 使用 Proxy 作为响应式核心,而 Proxy 无法被 polyfill。这是设计决策,为了获得更好的性能和更完善的响应式支持。

如需支持 IE,方案:

  1. 继续使用 Vue 2(LTS 支持到 2023.12)
  2. 使用 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 的好处
  1. 减少无意义 DOM 节点,DOM 树更扁平
  2. 不破坏 CSS 布局,如 <table> 内的 <tr> 组件、Flex 容器内的子组件
  3. 渲染性能微提升,少创建一个 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

移除的 APIVue 2 用法Vue 3 替代方案
过滤器 filters{{ msg | capitalize }}computed 或 method
on/on / off / $once事件总线 bus.$on('event', cb)mittprovide/inject
$childrenthis.$children[0]refprovide/inject
$listenersv-on="$listeners"合并到 $attrs
set/set / deleteVue.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-enterv-enter-fromv-leavev-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 install @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' // 使用兼容构建
}
}
});
迁移建议
  1. 先升级到 Vue 2.7:获得 <script setup>、Composition API 支持,减少后续迁移量
  2. 使用 @vue/compat:开启兼容模式,逐步修复控制台中的弃用警告
  3. 优先处理第三方库兼容:Vuex → Pinia、Vue Router 3 → 4、Element UI → Element Plus
  4. 利用 gogocode 自动化迁移工具批量转换代码

相关链接