Skip to content

Vue3 与 Vue2 的差异

Vue3 不是 Vue2 的"升级版",是重写。虽然 API 保持了相当的延续性,但内部从响应式系统到编译器到渲染层全部翻新了。理解这两者的差异,比记住 API 对照表重要得多。


响应式系统:从 defineProperty 到 Proxy

Vue2 的响应式基于 Object.defineProperty。这个选择在 2016 年是合理的(Proxy 还没广泛支持),但它留下的坑每个 Vue2 开发者都踩过。

Object.defineProperty 劫持的是对象的属性,不是对象本身。后果:

js
// Vue2 的响应式漏洞
const vm = new Vue({
  data: { obj: { a: 1 } }
})

vm.obj.b = 2       // ❌ 不响应——b 是新属性,defineProperty 不会自动追踪
vm.arr = [1, 2, 3]
vm.arr[0] = 99     // ❌ 不响应——数组索引赋值不会触发 setter
vm.arr.length = 0  // ❌ 不响应——length 修改也不会触发

Vue2 为此在 Vue.set / this.$set 上打了补丁,以及对数组的 7 个变异方法(push/pop/shift/unshift/splice/sort/reverse)做了 monkey-patch。但治标不治本——如果你用 arr[0] = xarr.length = 0,该不响应还是不响应。

Vue3 换了 Proxy

js
// Vue3 reactive() 的简化模型
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      track(target, key)              // 依赖收集
      return isObject(result) ? reactive(result) : result  // 惰性深层代理
    },
    set(target, key, value, receiver) {
      const oldValue = target[key]
      const result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        trigger(target, key, value)   // 派发更新
      }
      return result
    },
    deleteProperty(target, key) {
      const hadKey = Object.prototype.hasOwnProperty.call(target, key)
      const result = Reflect.deleteProperty(target, key)
      if (hadKey) {
        trigger(target, key, undefined)  // 删除属性也触发更新
      }
      return result
    }
  })
}

Proxy 劫持的是对象本身——属性增删、数组索引赋值、length 修改,全部自动追踪。$set 和变异方法补丁成为历史。

Reflect 的作用不止是"配合 Proxy 使用":

  • Reflect.get/set/deleteProperty 返回布尔值表示操作是否成功,Proxy handler 需要这个返回值来决定是否抛异常。
  • Reflect.setreceiver 参数确保在原型链场景下 this 正确指向 Proxy 对象而非原始对象——这是 target[key] = value 做不到的。

track()trigger() 是响应式系统的两个核心:

text
get 被访问 → track(target, key)
  → 查找 target 对应的 depsMap(WeakMap<Target, Map<Key, Dep>>)
  → 在 depsMap 中找到 key 对应的 dep(Set<ReactiveEffect>)
  → 将当前活跃的 effect 加入 dep

set 被调用 → trigger(target, key, newValue)
  → 找到 target → key → dep
  → 遍历 dep 中的每个 effect
  → 调用 effect.scheduler() 或 effect.run()

Vue2 的依赖收集在 Object.defineProperty 的 getter/setter 里,依赖关系存储在闭包中,结构是 Dep(target, key) → [Watcher]。Vue3 的依赖图结构是 WeakMap<Target, Map<Key, Set<ReactiveEffect>>>——层级更清晰,且用 WeakMap 使得 target 在被释放时自动从 depsMap 中移除,避免内存泄漏。

另外,reactive() 是惰性深层代理——只有当你实际访问 obj.a.b 时,b 才会被包装为 reactive。Vue2 在 data() 返回时立即递归遍历整个对象树,每个属性都调用 defineProperty。对大型数据对象,Vue3 的首屏初始化更快。


ref vs reactive:适用边界

Vue3 提供了两条响应式路线:refreactive。它们的差异不是风格偏好,是底层机制使然。

reactive() 只能用 Proxy 代理对象(Object/Array/Map/Set)。基本类型(string/number/boolean)没有引用,Proxy 无从代理——所以 reactive(1) 会直接报错。

ref() 内部维护一个 .value 属性,基本类型被包裹在 { value: xxx } 中,这个包裹对象本身是 reactive 的:

js
// ref 的简化实现
function ref(value) {
  const r = {
    get value() {
      track(r, 'value')
      return value
    },
    set value(newVal) {
      if (newVal !== value) {
        value = newVal
        trigger(r, 'value')
      }
    }
  }
  return r
}

.value<script setup> 模板中会被编译器自动解包,模板里可以直接写 count;在 JS 中仍然需要显式访问 count.value。这也是 ref 使用时最容易出现遗漏的位置。

shallowRefshallowReactive 提供了更细的响应式边界控制:只有顶层属性是响应式的,内部对象不会被深层代理。对于每一项都比较复杂、但更新方式主要是整体替换而不是深层修改的大列表,shallowRef 可以省掉深层 reactive 化的递归开销。


编译器优化:Vue3 比 Vue2 快在哪

"Vue3 比 Vue2 快"通常被归因于 Proxy 替代了 defineProperty,但 Proxy 主要是修复了响应式漏洞,对性能的贡献有限。真正的性能提升来自编译器。

静态提升(Hoisted Static Nodes)

Vue2 的模板编译:每个渲染周期,整个 VNode 树重新创建,包括静态节点(从不变化的 DOM 结构)。

Vue3 编译器识别静态节点,将其创建提升到渲染函数之外:

js
// Vue2 每次渲染都创建
function render() {
  return h('div', [
    h('span', '我是静态文本'),           // 每次都重新创建 VNode
    h('span', this.dynamicText)           // 动态部分
  ])
}

// Vue3 编译后(简化)
const _hoisted_1 = h('span', '我是静态文本')  // 只创建一次

function render() {
  return h('div', [
    _hoisted_1,                            // 直接复用
    h('span', this.dynamicText)            // 只更新动态部分
  ])
}

Patch Flag(动态节点标记)

Vue3 编译器为每个带动态绑定的节点生成一个数字 flag,标识"这个节点哪些部分是动态的":

js
// 模板:<div :class="cls" :id="id">text</div>

// 编译后的 VNode
{
  type: 'div',
  props: { class: ctx.cls, id: ctx.id },
  children: 'text',
  patchFlag: 1 | 16  // 1 = 动态文本, 16 = 动态 props
}

运行时 diff 时,Vue3 先看 patchFlag。如果 flag = -1(静态节点),跳过 diff 直接复用。比 Vue2 全量比较 children props 省了大量无意义工作。

Block Tree(靶向 diff)

这是 Vue3 编译器最巧妙的设计。编译器将模板分割为 block——每个 block 是一个"动态节点集合"。Block 内部只 diff 动态节点,跳过所有静态内容。

html
<template>                         <!-- block tree -->
  <div>                             <!-- root block -->
    <span>静态</span>               ← 跳过 diff
    <span>{{ dynamic }}</span>      ← patchFlag 标记
    <div v-if="show">               <!-- 嵌套 block -->
      <p>静态</p>                   ← 跳过
      <p :id="id">动态</p>          ← patchFlag 标记
    </div>
  </div>
</template>

Vue2 的 diff 是全量 children 遍历——哪怕 90% 的节点是静态的,每个都会被遍历一次。Vue3 跳过全部静态节点,只 diff 带有 patchFlag 的动态节点。实际项目中的 diff 量减少了 60-80%。


Composition API 不是"另一种写法"

把 Composition API 理解为"和 Options API 功能等价但写法不同"是对它最大的误解。

Options API 的代码组织方式是按选项类型(data/methods/computed/watch)切分的。在一个复杂组件中(500+ 行),同一个业务逻辑的代码分散在 data、methods 和 watch 之间——读代码时需要在几个选项块之间跳来跳去。

Composition API 按逻辑关注点组织:

js
// Options API:同一功能分散在多个选项中
{
  data() {
    return {
      searchQuery: '',
      searchResults: [],
      searchLoading: false
    }
  },
  watch: {
    searchQuery: {
      handler(q) { this.doSearch(q) },
      immediate: true
    }
  },
  methods: {
    async doSearch(q) { /* ... */ }
  }
}
js
// Composition API:搜索功能全部集中在一个 composable 中
function useSearch() {
  const query = ref('')
  const results = ref([])
  const loading = ref(false)

  watch(query, async (q) => {
    loading.value = true
    results.value = await fetchSearchResults(q)
    loading.value = false
  })

  return { query, results, loading }
}

// 在组件中使用
const { query, results, loading } = useSearch()

这带来的收益不是单纯的“代码更干净”,而是逻辑的单位化useSearch() 可以通过显式函数调用在任意组件中复用,而不是依赖 mixin。相比之下,mixin 更容易引入命名冲突、来源不透明和隐含依赖。Composable 的输入、调用位置和返回值都更直观。

Composition API 也显著改善了 TypeScript 的可用性。Options API 中的 this 类型推断容易受到 mixin、插件和原型扩展影响;Composition API 以普通函数调用和变量声明为主,更适合静态分析。


生命周期和组件实例

Vue3 引入了 setup() 作为组件入口。它的执行时机在 beforeCreatecreated 之间——也就是说,执行 setup() 时组件实例已经创建了,但还没有开始执行 Options API 的 data()computed

所以在 setup() 中没有 this——不是故意不给,是 setup() 执行时组件实例还没完全初始化。需要用到的数据(props、context)通过 setup(props, { attrs, slots, emit }) 参数传入。

生命周期钩子的对应关系:

Vue2 Options APIVue3 Composition API变化说明
beforeCreate—(setup 替代)setup() 在此时机执行
created—(setup 替代)setup() 在此时机执行
beforeMountonBeforeMount命名统一为 onXxx
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeDestroyonBeforeUnmount语义从"销毁"改为"卸载"
destroyedonUnmounted
errorCapturedonErrorCaptured新增
onRenderTrackedVue3 新增:调试用
onRenderTriggeredVue3 新增:调试用

beforeDestroy/destroyed 改名为 beforeUnmount/unmounted 的原因:Vue3 的组件实例生命周期不只是"销毁",还有 keep-alive 的缓存/激活和 <Teleport> 的挂载/卸载。unmountdestroy 更准确地描述了"从 DOM 中移除组件"这一行为。


其他关键差异

Fragment(多根节点)

Vue2 的组件模板必须有唯一根元素,因为 Vue2 的 VNode 树假设每个组件对应一个根节点。

Vue3 支持多根节点(Fragment),编译器在内部用一个不可见的 Fragment 节点包裹多个根,渲染时将 Fragment 的 children 展平插入 DOM。这消除了 Vue2 中大量无意义的包裹 <div>

Teleport

Vue2 中要将内容渲染到组件树之外(比如 modal 渲染到 <body> 下),需要手动调 document.body.appendChild——这脱离了 Vue 的虚拟 DOM 管理,后续更新需要手动同步。

Vue3 的 <Teleport to="body"> 在虚拟 DOM 层面保持父组件的响应式连接(props、事件、provide/inject 正常工作),但在 DOM 层面将内容挂载到指定位置。Modal、Tooltip、Notification 这些场景不再需要 hack。

Suspense

Vue3 的 <Suspense> 提供了异步组件的加载状态管理——在异步依赖(async setup、异步组件)resolve 之前显示 fallback 内容。Vue2 需要用 v-if + loading state 手动管理。

<Suspense> 到 Vue3.5 为止仍然是实验性功能(在 Vue 3.3+ 中标记为 stable,但生产环境的广泛使用还需要时间验证)。

Tree-shaking

Vue2 的全局 API 挂在 Vue 构造函数上:Vue.nextTick()Vue.set()Vue.component()。打包时即使项目里没用 Vue.set,它仍然会被打包进去。

Vue3 的 API 全部按命名导出:import { ref, computed, watch } from 'vue'。只打包你实际 import 的。一个典型的 Vue3 SPA 的 Vue 运行时(不包含第三方库)可以压缩到 16KB gzipped,Vue2 是 23KB+——差异来自 tree-shaking 和编译器输出的更高效代码。

TypeScript 支持

Vue2 的 TypeScript 支持是通过 vue-class-componentvue-property-decorator 两个装饰器库实现的——这两个库不是官方维护的,类型推导有各种边界情况失效。

Vue3 本身用 TypeScript 重写,类型推导在 Composition API 中是原生的。defineProps<>()defineEmits<>()ref<>() 的类型参数让你可以在不写额外类型标注的情况下获得完整的 props/slots/emit 类型安全。


Vue2 到 Vue3:Breaking Changes 速查

迁移时最容易踩的 5 个 breaking changes:

  1. v-model 行为变更:Vue2 中 v-modelvalue prop + input 事件的语法糖。Vue3 改为 modelValue prop + update:modelValue 事件。同时支持多个 v-model:titlev-model:content
  2. v-if/v-for 优先级反转:Vue2 中 v-for 优先级 > v-if;Vue3 中 v-if > v-for。Vue2 的做法导致每次渲染都遍历整个列表然后过滤;Vue3 的做法更符合直觉但会导致依赖两者的代码行为变化。
  3. 事件总线(Event Bus)被移除:Vue2 的 new Vue() 可以当 event bus 用($on/$off/$emit)。Vue3 移除了 $on/$off/$once。替代方案:mitt(轻量 event emitter)或使用 provide/inject + callback。
  4. filters 过滤器被移除:Vue2 的 0 语法在 Vue3 中不再支持,可改为 computed 或普通方法。
  5. $listeners 被合并到 $attrs:Vue2 中 $attrs 只包含非 prop、非 class/style 的属性,$listeners 单独包含事件。Vue3 合并两者——$attrs 包含所有非 props 属性(包括 onXxx 事件处理器)。

Vue 官方提供了 @vue/compat 迁移构建——行为兼容 Vue2,但会在控制台输出废弃 API 的警告。你可以先把项目升级到 compat build,在控制台逐个修复警告,确认所有警告消除后切换到纯 Vue3 build。渐进式迁移,而不是大爆炸重写。


性能基准:数字说话

Bundle Size

指标Vue 2.7Vue 3.5差异
运行时(gzipped)~23KB~16KB-30%
运行时 + 编译器(gzipped)~32KB~22KB-31%
Tree-shakable按需打包

差异来源:Vue3 的编译器输出更高效的代码(静态提升、patch flag 减少了运行时所需的代码路径);全局 API 从挂载变为命名导出使 tree-shaking 生效;以及内部模块的代码体积优化。

渲染性能

基于 js-framework-benchmark(Chrome 132,2026 Q1):

场景Vue 2Vue 3提升
创建 1K 行~55ms~25ms2.2x
更新 1K 行(全部替换)~45ms~18ms2.5x
部分更新(1 行 / 1K)~350μs~80μs4.4x
选择行(选中高亮)~28ms~12ms2.3x
内存(1K 行)~22MB~14MB-36%

部分更新的差距(4.4x)最大——这正是编译器优化的主战场。Vue2 需要全量 diff children 来找到变化的那一行;Vue3 通过 patchFlag 和 block tree 直接定位到动态节点,跳过所有静态内容。

Scheduler 与浏览器渲染管线的交互

Vue3 的 scheduler 在浏览器事件循环中的工作方式,是理解"为什么 Vue3 比 Vue2 渲染更高效"的关键:

text
用户操作 → 同步修改响应式数据
         → trigger() 将 effect 加入 scheduler queue
         → 同一个 tick 内多次修改被去重(同一个 effect 只入队一次)
         → nextTick(微任务)flush queue
         → 批量执行所有 effect → 更新 DOM
         → 浏览器在下一帧进行 layout/paint/composite

Vue2 的更新也是批量的(通过 nextTick),但 scheduler 没有优先级概念。Vue3 的 scheduler 支持 queueJobqueuePostFlushCb 两级队列——前者在 DOM 更新前执行,后者在 DOM 更新后执行。这允许你在正确的时机做正确的事(比如 watchPostEffect 可以在 DOM 更新后操作更新的节点)。

另外,Vue3 的 nextTick 返回一个 Promise(Vue2 的 Vue.nextTick 接受回调)。这意味着可以直接 await nextTick() 而不是嵌套回调,在需要等待 DOM 更新后才做操作时(比如测试中验证更新后的 DOM 结构)代码更清晰。


架构视角:Vue3 的设计为什么更适合大型应用

Vue2 的架构是扁平的——全局 API 挂在一个构造函数上,mixin 是唯一复用逻辑的机制,依赖注入(provide/inject)是后来补的,TypeScript 支持是第三方库拼凑的。

Vue3 的架构是多层的:

text
应用层
  ├── SFC Compiler(<script setup> + 模板编译)
  │    └── 编译时优化:静态提升 / patchFlag / block tree
  ├── Runtime Core(@vue/runtime-core)
  │    ├── Reactivity System(@vue/reactivity,可独立使用)
  │    │    ├── reactive / ref / computed / watch
  │    │    └── track / trigger / effect 调度
  │    ├── Virtual DOM Renderer
  │    │    ├── 组件实例生命周期
  │    │    ├── diff 算法(靶向更新)
  │    │    └── scheduler(queueJob / queuePostFlushCb)
  │    └── Composition API(setup / composables)
  └── Platform Adapter(@vue/runtime-dom / @vue/runtime-test)

每一层的职责边界清晰。@vue/reactivity 可以完全脱离 Vue 组件系统独立使用——你可以只用 Vue 的响应式系统来管理应用状态,而用别的渲染方案(甚至是 Canvas/Three.js)。

这种模块化设计带来的副产品:

  • 跨平台@vue/runtime-core 是平台无关的。@vue/runtime-dom 适配浏览器,@vue/runtime-test 用于单元测试。社区已经基于 runtime-core 构建了 Weex、小程序等多种渲染目标。
  • 渐进式采用:可以在现有的 jQuery/Bootstrap 项目里引入 @vue/reactivity 做状态管理,逐步迁移到 Vue 组件。
  • Tree-shaking:模块化使 tree-shaking 生效。只 import 你使用的 API。

Composition API 对架构的影响

Vue2 的 mixin 是横向复用——多个 mixin 注入到同一个组件中,每个 mixin 都向组件的 data/methods/computed 注入内容。组件越大、mixin 越多,数据来源越不可追踪。

Composition API 改变了复用模型——从"注入"变成"组合":

text
Vue2 mixin 模型:
  Component
  ├── mixin A (data.x + method.doA)
  ├── mixin B (data.y + method.doB)
  └── mixin C (data.z + method.doC)
  → data 中的 x/y/z 来源不透明,命名冲突难以排查

Vue3 composable 模型:
  const { x, doA } = useFeatureA()
  const { y, doB } = useFeatureB()
  const { z, doC } = useFeatureC()
  → 每个变量和函数的来源完全可追踪

在大规模应用中(50+ 个组件、10+ 个开发者),composable 的可追踪性比 mixin 的黑盒注入在维护成本上有数量级的差异。


生产实践:Vue2 → Vue3 迁移的真实经验

迁移决策:不是所有项目都需要迁移

Vue2 于 2023 年 12 月 31 日 EOL(End of Life)。但这不意味着 2024 年 1 月 1 日所有 Vue2 项目必须迁移。核心判断:

  • 项目仍在活跃迭代 → 迁移。新功能在 Vue3 上开发,Vue2 上只修 bug。
  • 项目进入维护模式(只修 bug、不新增功能) → 不迁移。Vue2 虽然 EOL 但没有安全补丁的风险对纯前端代码不构成威胁(前端运行在浏览器沙箱中,没有 RCE 风险)。
  • 项目将在 6-12 个月内下线或重写 → 不迁移。迁移成本无法在剩余生命周期内收回。
  • 项目使用的 Vue2 生态库没有 Vue3 版本 → 延迟迁移。检查关键依赖(UI 框架、图表库、富文本编辑器)是否有 Vue3 兼容版本。vue-demi 可以帮助库作者同时支持 Vue2 和 Vue3,但不能解决所有问题。

渐进式迁移策略

完全遵循 Strangler Fig 模式:

text
阶段 1: 安装 @vue/compat(1 天)
  → 项目正常运行,控制台出现废弃 API 警告
  → 逐个消除警告(通常 1-2 周)

阶段 2: 切换到纯 Vue3 build(1 天)
  → 移除 @vue/compat
  → 确保所有测试通过

阶段 3: 逐个组件迁移到 Composition API(按需,逐步)
  → Options API 在 Vue3 中仍然完全可用
  → 仅在新功能或需要复用的逻辑上使用 Composition API
  → 不必"一次性全部重构"

关键实践:

  • 先升级构建工具:Vue3 需要 Vite 或 Webpack 5。如果还在用 Vue CLI(基于 Webpack 4),升级构建工具是迁移的第一步。
  • UI 框架先于业务代码:Element UI → Element Plus,Ant Design Vue 1 → 3/4。UI 框架的 API 变化通常比 Vue 核心更大。
  • 测试先行:在迁移前确保测试覆盖率 ≥70%。没有测试的迁移是盲飞——你只能靠手动点击发现回归 bug。
  • 双版本并行期不要超过 3 个月:如果团队分两组分别维护 Vue2 和 Vue3 版本超过 3 个月,同步成本会超过迁移成本。

典型坑

  1. $attrs$listeners 合并导致的事件穿透:Vue3 中 $attrs 包含所有非 props 属性,包括 onClick 等事件处理器。如果你以前手动传递 $attrs$listeners 到子组件,Vue3 中只需传 $attrs。但如果你的组件以前依赖"事件不被自动传递"这个行为(比如通过检查 $listeners 来决定是否传播事件),行为会发生变化。

  2. v-model 从单通道变成多通道:Vue2 中 v-model 只能绑定一个值。如果代码依赖 .sync modifier(Vue2 中给子组件传 :value.sync),Vue3 中需要改成 v-model:value

  3. transition 组件的 class 名称变更:Vue3 中 CSS transition class 从 v-enter 改为 v-enter-from。如果项目中有自定义 transition CSS,需要逐一更新。


几个 Principal 级别的判断

1. Vue2 → Vue3 的真实价值不在性能,在可维护性。

2x 的渲染性能提升和 30% 的 bundle size 缩减——这些数字在 benchmark 里漂亮,但在实际项目中用户几乎感知不到。真正改变团队日常开发的,是 Composition API 的逻辑组织能力、TypeScript 的类型安全、和编译器的错误提示(<script setup> 的编译时检查和更直观的报错信息)。这些不能用数字量化,但 6 个月后会体现在 bug 率和开发速度上。

2. "Vue3 的 Composition API"和"Vue2 的 Options API"不是对立关系。

Vue3 中 Options API 仍然完全可用,可以和 Composition API 混用。不需要把现有的 Options API 代码重写成 Composition API——除非你要复用某段逻辑。正确的迁移心态不是"换成新的写法",而是"在新的地方用新的写法"。渐进式升级的价值在于不用一次性重写。

3. Proxy 解决了响应式漏洞,但也引入了新的心智负担。

reactive() 的 Proxy 对象和原始对象的关联、解构丢失响应性、ref.value 在 JS 和模板中的不同行为——这些是 Vue3 使用者最常遇到的概念难点。Vue2 的 Object.defineProperty 虽然有漏洞,但概念模型简单("data 里的东西就是响应式的")。选 Vue3 不只是选了更好的技术,也是选了更陡峭的学习曲线。

4. @vue/reactivity 的独立性是 Vue3 被低估的特性。

可以脱离 Vue 组件体系,在纯 Node.js 脚本、CLI 工具、甚至后端服务中使用 Vue 的响应式系统。这对于需要复杂状态管理的场景是一个轻量替代方案——不需要引入整个 Vue 框架。许多项目用 @vue/reactivity 来管理 WebSocket 推送的数据流、处理复杂表单的联动逻辑。

5. 不要因为"Vue3 是最新的"就迁移。

如果 Vue2 项目运行稳定、没有新功能计划、没有安全补丁需求、没有 Composition API 或 TypeScript 的强烈需求——不迁移是完全合理的选择。技术栈的稳定性本身就是价值。迁移的理由是"目前的约束(响应式漏洞、TypeScript 缺失、mixin 维护困难)在伤害业务",不是"Vue3 更新"。


扩展问题

Q1: Vue3 的 reactive() 有什么局限性?什么情况下你会选 ref 而不是 reactive

三个核心局限:

  • reactive() 只能代理对象类型(Object/Array/Map/Set),基本类型无法使用。
  • reactive() 返回的 Proxy 对象不能被解构——const { x, y } = reactive({ x: 1, y: 2 }) 之后 xy 都是普通值,不再响应式。需要用 toRefs() 转换。
  • reactive() 的 Proxy 对象不能直接被替换——let state = reactive({}); state = reactive({}) 是创建了两个独立的响应式对象,原来引用 state 的地方不会自动更新到新对象。

所以通常建议:局部状态用 ref,复杂对象用 reactiveref.value 虽然麻烦但行为可预测——解构、替换、传递都不会丢失响应性。reactive 更适合"这是一个整体对象,我只通过属性访问它"的场景。

Q2: Vue3 的 diff 算法在哪些场景下不如 Vue2?

Vue3 的靶向 diff 在动态节点占比极高的场景下没有优势——如果模板中 > 80% 的节点都是动态的,patchFlag 和 block tree 的跳过优化基本失效。另外,Vue3 的 block tree 对 v-for 内部的动态节点追踪深度有限——如果 v-for 内的每个 item 都有多层嵌套的动态节点,block 的开销可能超过收益。

但这些场景在实际项目中罕见——通常情况下静态节点的比例远大于动态节点。

Q3: watchEffectwatch 的区别只是"自动追踪依赖"吗?更深层的差异是什么?

除了依赖追踪方式的差异(watchEffect 自动追踪 vs watch 显式指定),更深层的差异在于执行时机和旧值的可用性

  • watchEffect 在创建时立即同步执行一次,之后在依赖变化时异步执行(加入 queuePreFlushCb)。旧值不可用——callback 只接收副作用清理函数,不接收新旧值对比。
  • watch 默认是懒执行的(除非传 immediate: true),依赖变化后的执行默认也是异步的(但可以用 flush: 'sync' 强制同步)。提供了新旧值对比。
  • watchEffect 更适合"我需要每当这些依赖变化时做一件事,不关心之前是什么值"(比如同步数据到 localStorage、更新 document title)。
  • watch 更适合"我需要知道从什么值变成了什么值"(比如表单脏检测、撤销/重做栈)。

Q4: Vue3 的 nextTick 和 React 的 useEffect 在执行时机上有什么不同?

nextTick 是在当前 tick 的同步代码执行完毕后、浏览器进行下一帧渲染之前执行的微任务。它的执行顺序是:同步代码 → nextTick 回调 → 浏览器渲染。

useEffect 是在浏览器完成渲染(layout + paint)之后异步执行的。执行顺序是:同步代码 → 浏览器渲染 → useEffect 回调。

这意味着在 nextTick 中可以同步访问更新后的 DOM(此时 DOM 已经更新但可能还没渲染到屏幕),在 useEffect 中则是在用户看到新 UI 之后才执行。Vue 的 nextTick 更适合"更新之后立即操作 DOM"的场景(比如测量元素尺寸、设置焦点),React 的 useEffect 更适合"不阻塞渲染的副作用"(比如数据上报、分析埋点)。

Q5: 如果你要说服团队从 Vue2 迁移到 Vue3,你会强调哪三个论点?你又会主动提及哪三个风险?

强调的论点:

  1. Composition API 的逻辑复用能力——mixin 在 10+ 个组件中复用时的命名冲突和来源不透明问题在 Composables 中彻底消失。
  2. TypeScript 的原生支持——不需要装饰器库和类型体操,类型推导开箱即用。对于中大型项目,这意味着更少的运行时类型错误。
  3. 生态的未来方向——新库和新功能默认基于 Vue3,Vue2 的生态已经进入停滞(Vuetify 2、Element UI 等库不再更新)。

主动提及的风险:

  1. 学习曲线——Composition API 的 .value 心智模型、ref vs reactive 的选择、生命周期钩子的变化,团队需要 2-3 周适应期。
  2. 第三方依赖兼容性——部分小众但关键的依赖可能没有 Vue3 版本,需要自行寻找替代或 fork 维护。
  3. 迁移期间的业务停滞——如果迁移需要 3 个月,这 3 个月内原计划的业务功能会被推迟。需要和管理层对齐"迁移的投资回报率"。