Vue2 和 Vue3 响应式原理的区别?

2026年 阅读约 9 分钟 面试指南 · 前端面试

深入解析Vue响应式原理:Vue2 Object.defineProperty与Vue3 Proxy的区别、依赖收集与派发更新流程、数组监听限制、ref与reactive本质,分三层讲解面试高频考点。

一句话总结

Vue2 用 Object.defineProperty 劫持对象的每个属性——必须递归初始化,监听不到新增/删除属性和数组下标,靠重写 7 个数组方法和 $set 打补丁;Vue3 用 Proxy 代理整个对象,增删改、下标、length 全都能感知,且嵌套对象访问时才代理(懒代理),性能更好。

初级理解

Vue2 的三大限制:对象新增属性、数组按索引赋值、修改数组 length 都不是响应式的,官方提供 Vue.set / this.$set、Vue.delete 和重写后的 7 个数组方法(push/pop/shift/unshift/splice/sort/reverse)来解决。

// Vue2 的坑 this.obj.newKey = 1 // 非响应式!视图不更新 this.$set(this.obj, 'newKey', 1) // 响应式 this.list[0] = newItem // 非响应式! this.list.splice(0, 1, newItem) // 响应式 this.list.length = 0 // 非响应式! this.list = [] // 响应式(换引用)

Vue3 的两个响应式 API:reactive 包装对象(基于 Proxy),ref 包装任意值(基本类型必须用它),通过 .value 访问。

const state = reactive({ count: 0, list: [] }) state.list[0] = 1 // 响应式 ✓ state.newKey = 1 // 响应式 ✓ const num = ref(0) num.value++ // 响应式 ✓
记忆口诀:Vue2 是"给每个属性装监听器",装不到就是盲区;Vue3 是"给整个房子装监控",进出全知道。

中级深入

依赖收集与派发更新(Vue2):每个响应式属性对应一个 Dep;渲染时 Watcher 读取属性触发 getter,把当前 Watcher 收集进 Dep(依赖收集);数据变化触发 setter,通知 Dep 里所有 Watcher 重新渲染(派发更新)。数组因为没有 setter,靠拦截原型方法手动 notify。

// Vue2 简化模型 class Dep { subs = []; depend() { this.subs.push(Dep.target) } notify() { this.subs.forEach(w => w.update()) } } function defineReactive(obj, key, val) { const dep = new Dep() Object.defineProperty(obj, key, { get() { if (Dep.target) dep.depend(); return val }, // 收集 set(nv) { val = nv; dep.notify() } // 派发 }) }

Vue3 的 track/trigger:用全局 activeEffect 替代 Dep.target,用 targetMap → key → Set<effect> 三级结构存依赖。Proxy 的 handler 里 get 时 track、set 时 trigger,逻辑集中且不用递归。

ref 为什么需要 .value:基本类型(数字、字符串)无法被 Proxy 代理(Proxy 只能代理对象),所以 ref 把值包成 { value } 对象再代理;同时 ref 也支持对象类型(内部自动调用 reactive)。

注意:Vue3 中 reactive 对象直接解构会丢失响应式(解构出来是普通值),要用 toRefs 保持链接。

高级拓展

Vue3 懒代理(惰性响应式):Vue2 初始化时必须递归遍历所有嵌套属性全部劫持,大对象初始化开销大;Vue3 只代理最外层,嵌套对象在 get 访问到时才递归创建 Proxy,按需开销。

effect 与调度器:effect 支持自定义 scheduler——渲染 effect 的 scheduler 把更新任务推入去重的微任务队列(同一 tick 内多次修改只渲染一次),这就是 nextTick 批量更新的底层。watch 本质是“effect + scheduler 回调”。

// 简化的批量更新队列 let queue = new Set(), flushing = false function queueJob(job) { queue.add(job) // Set 去重 if (!flushing) { flushing = true Promise.resolve().then(() => { queue.forEach(j => j()); queue.clear(); flushing = false }) } }

其他进阶 API:shallowReactive/shallowRef(只代理第一层,适合大表格/图表实例)、markRaw(永远不代理,如 Three.js 对象)、toRaw(拿回原始对象)、readonly(只读代理)。

面试加分项:主动说出"Proxy 也有监听不到的场景——比如用非响应式的中间对象做赋值桥接",以及 Vue2 Watcher 里 Dep.target 为什么只需要一个(渲染在同一时刻只有一个),能明显拉开层次。

实战场景

场景一:解构 reactive 丢失响应式

// 反例:解构后 count 是普通数字,视图不再更新 let { count } = reactive({ count: 0 }) // 正例一:toRefs 保持链接 const state = reactive({ count: 0 }) let { count } = toRefs(state) // count 是 ref // 正例二:始终通过 state.count 访问

场景二:Vue3 reactive 整体替换不生效

// 反例:Proxy 代理的是原对象,重新赋值只是换了变量指向 let state = reactive({ list: [] }) state = reactive({ list: newData }) // 视图不会跟随新对象! // 正例:改属性而不是换对象 state.list = newData // 或用 ref 包对象:state.value = { list: newData } ✓

场景三:大表格渲染慢

// 1 万行 × 20 列的 reactive 深层代理,每格读取都触发依赖收集 const rows = shallowReactive(bigRows) // 只代理第一层 const chart = markRaw(echarts.init(el)) // 图表实例永不代理 // 配合虚拟滚动,只渲染可视区 20 行

面试模拟

Q:为什么 Vue2 无法监听数组下标修改?Vue3 为什么可以?

A:Vue2 用 defineProperty 劫持的是属性,数组是按索引存取的稀疏集合,给上千个索引都装 getter/setter 性能不可接受,官方权衡后只重写了会改变原数组的 7 个方法。Vue3 的 Proxy 代理的是整个对象,任何 get/set/deleteProperty(包括索引和 length)都会进入 handler,天然覆盖。

Q:ref 和 reactive 怎么选?

A:简单规则——基本类型必须 ref;对象/数组两者皆可。工程实践上推荐统一用 ref(.value 心智一致、可整体替换、不会踩解构丢失响应式的坑),reactive 适合聚合一组强相关的表单/配置字段。ref 存对象时内部会自动调 reactive。

Q:Vue3 的依赖收集为什么不用递归初始化就能工作?

A:Proxy 的 get 是运行时拦截——嵌套对象只有在被访问时才触发 get,此时再对返回值创建代理并收集依赖(懒代理)。初始化只需代理最外层,复杂度从"启动时全量"变成"访问时增量",这也是 Vue3 初始化性能提升的原因之一。