响应式我从 Proxy 和 defineProperty 的差异入手,慢慢补上数组索引、新增属性、ref 解包和 track/trigger 这几块,才看清依赖是按属性收集的。
Q1: Vue 2 的 defineProperty 和 Vue 3 的 Proxy 有什么区别?
1
Vue 2 时代: Object.defineProperty
Vue 2 用
Object.defineProperty 给对象的每个属性设置 getter/setter。读取属性时收集依赖,修改属性时触发更新。这套方案支撑了 Vue 2 全系列的响应式实现。2
致命痛点: 无法拦截新增属性
defineProperty 是针对已有属性的——你必须预先知道对象有哪些属性。新增属性(obj.newProp = 'x')不在预设列表中,自然拦截不到。Vue 2 不得不提供 $set、$delete 这些补丁 API。数组下标变化同理。3
Vue 3 的方案: Proxy
Vue 3 用
Proxy 代理整个对象。Proxy 拦截的是对象级别的操作——不管访问什么属性、新增什么属性、删除什么属性、遍历对象,全部能拦截到。4
代价与权衡
Proxy 不能代理原始类型(number、string、boolean),所以 Vue 3 需要用
ref() 来包装原始值。这是 Vue 3 的设计权衡:用 ref 的 .value 语法换取了更强大的拦截能力。5
关键洞察: 深层代理是递归的
Proxy 只代理第一层属性。Vue 3 在 get trap 中检查返回值,如果是对象就递归调用
reactive() 包装——这就是”惰性代理”,只有访问到的嵌套对象才会被代理,避免一次性代理整棵对象树的性能开销。defineProperty 是”逐个属性上锁”,Proxy 是”给整个保险箱上锁”——Vue 3 用对象级拦截替代属性级拦截,彻底消除了响应式盲区。
Vue 2 的 defineProperty
只能拦截已有属性的读写。新增属性需要
$set,删除属性需要 $delete,数组下标赋值不触发更新。就像你给家里的每扇门单独装了锁,但有人从窗户爬进来你完全不知道。Vue 3 的 Proxy
拦截整个对象的所有操作,包括新增、删除、遍历。就像你给整栋房子装了一个智能门禁系统,不管谁从哪个门进、带了什么东西出去,系统都能感知。
数据响应式的本质:track、trigger 与响应式粒度
很多人能说出”Vue 用 Proxy 实现响应式”,但被追问”响应式的本质到底是什么”时就卡壳了。这道冷门题考的是你是否理解响应式系统的三个核心机制:依赖收集(track)、触发更新(trigger)、响应式粒度(组件级 vs 属性级)。 响应式的本质是一个发布-订阅模式——数据是”发布者”,视图是”订阅者”。当数据被读取时,当前正在执行的副作用函数(effect)自动订阅这个数据(track);当数据被修改时,所有订阅了这个数据的副作用函数被通知执行(trigger)。Proxy vs defineProperty 对响应式粒度的影响
响应式粒度是指”数据变化时,更新范围有多精确”。Vue 2 的 defineProperty 和 Vue 3 的 Proxy 在粒度上有本质区别:Q2: 依赖收集的三层 Map 是怎么设计的?
Vue 3 的依赖收集系统用三层嵌套的数据结构来存储”谁依赖了什么数据”的关系:三层 Map 的设计保证了属性级粒度的精确依赖收集——WeakMap 防内存泄漏、Map 保证 O(1) 查找、Set 自动去重。
Q3: reactive vs ref vs computed 有什么区别?
基础梳理
专家视角深度剖析
很多人能背出三者的 API 区别,但实际更在意的是:为什么 Vue 3 要同时设计 reactive 和 ref 两个 API?只用一个不行吗? 核心原因是 Proxy 无法代理原始类型。new Proxy(0, handlers) 会报错,因为 Proxy 的第一个参数必须是对象。所以 Vue 3 必须用一个对象把原始值包起来——这就是 ref。
但 ref 的 .value 语法确实不如 reactive 的直接访问直观。为什么 Vue 3 不把所有东西都做成 reactive 呢?因为 reactive 返回的是 Proxy 对象,解构赋值会丢失响应式:
computed 的设计哲学是惰性求值 + 缓存。依赖变化时只标记 dirty(推),有人读取时才计算(拉)。这比 eager 模式(每次依赖变化立即计算)更高效——如果 computed 没有被读取,计算就完全省掉了。
reactive 的解构陷阱与 toRefs 的设计
reactive 返回的是 Proxy 对象,解构赋值是值复制,脱离了 Proxy 代理。这是面试中最常被追问的点:toRefs 的源码实现(packages/reactivity/src/ref.ts)遍历 reactive 对象的每个属性,为每个属性创建一个 getter/setter 包装的 ref——读取时走 reactive 的 get trap,写入时走 reactive 的 set trap,从而保持响应式连接。
Q4: 解构 reactive 对象为什么会丢失响应式?
原因:解构赋值是值复制,脱离了 Proxy 代理。Q5: Vue 的批量更新是怎么实现的?(queueJob + 微任务)
Vue 的批量更新核心机制是:同一事件循环内的多次数据修改,只触发一次组件更新。queueJob 用微任务实现批量更新——同一事件循环内的多次修改只触发一次组件更新,确保更新发生在浏览器渲染前。
Q6: nextTick 的原理和使用场景?
1
数据变化后 DOM 不会立即更新
Vue 的响应式系统在数据变化时不会立即同步更新 DOM,而是把更新任务推入一个异步队列(queueJob)。同一事件循环内的多次数据修改会被合并,只触发一次 DOM 更新。这意味着在同步代码中读取 DOM,拿到的还是旧值。
2
致命痛点: 怎么拿到更新后的 DOM?
很多场景需要在 DOM 更新后执行逻辑——比如获取元素尺寸、操作滚动位置、初始化第三方库。但因为 DOM 更新是异步的,同步代码中读取 DOM 永远是旧值。
3
nextTick: 等 DOM 更新完再执行
nextTick 接受一个回调函数,把它推入一个微任务队列。当 DOM 更新完成后(queueJob 的 flushJobs 执行完毕),nextTick 的回调才会执行——此时读取 DOM 就是最新值。4
底层实现: 复用同一个 Promise
Vue 3 的
nextTick 返回一个 Promise,内部用 Promise.resolve() 创建微任务。多个 nextTick 调用会复用同一个 resolvedPromise,保证回调在同一个微任务批次中按顺序执行。5
关键洞察: nextTick 是批量更新的出口
nextTick 的本质是”批量更新完成后的一个通知机制”。它和 queueJob 共享同一个微任务调度——queueJob 负责安排更新,nextTick 负责在更新完成后执行用户回调。
没有 nextTick
数据变化后立刻读 DOM,拿到的是旧值。就像你刚发出一封快递,立刻去查物流信息,系统还没来得及更新,显示的还是”未揽收”。
引入 nextTick
用
nextTick 等 DOM 更新完成后再读取。就像你等快递员扫码揽收后(DOM 更新完成),再去查物流信息(nextTick 回调),就能看到最新状态。Q7: watch 和 watchEffect 有什么区别?
watch 和 watchEffect 都是 Vue 3 的侦听器 API,但设计理念完全不同:watch 是显式侦听源的惰性副作用,watchEffect 是自动追踪依赖的立即执行副作用——前者能拿旧值,后者不能。
Q8: computed 的惰性求值是怎么实现的?
computed 的核心设计是**推拉结合(push-pull)**模型——依赖变化时只标记 dirty(推),有人读取时才计算(拉)。computed 的核心是推拉结合——依赖变化只标记 dirty(推),读取时才计算(拉),无人读取则完全省掉计算。
Q9: effect 和 ReactiveEffect 是什么关系?
ReactiveEffect 是 Vue 3 响应式系统的底层核心类,effect 函数是创建 ReactiveEffect 实例的工厂函数。ReactiveEffect 是响应式系统的底层核心类——组件渲染、computed、watch/watchEffect 都是它的实例,区别只在 scheduler 的实现。
底层源码:Vue 3 的响应式是如何实现的?
Proxy 的 handler 是怎么定义的?
Vue 3 为不同类型的对象定义了不同的 handler(详见 baseHandlers.ts#L38):
track 和 trigger 的实现细节?
详见 effect.ts#L209:
十、及格回答 vs 满分回答
问题:“说说 Vue 3 的响应式原理”
及格回答(60-70分):“Vue 3 用 Proxy 代理对象,读取属性时 track 收集依赖,修改属性时 trigger 触发更新。ref 包装原始类型,computed 是惰性求值加缓存。”问题出在:知道机制但不理解设计。 满分回答(90-100分):
“Vue 3 的响应式系统有三个核心设计决策。第一,用 Proxy 替代 defineProperty。defineProperty 只能拦截已有属性,新增属性、删除属性、数组下标赋值都需要$set补丁。Proxy 拦截对象级别的操作,包括has、ownKeys、deleteProperty,一招解决所有响应式盲区。代价是不能代理原始类型,所以需要 ref 包装。[1]”
“第二,三层 Map 的依赖收集。targetMap(WeakMap) → depsMap(Map) → dep(Set)。WeakMap 以对象为 key,对象被 GC 时依赖自动清理,避免内存泄漏。Map 以属性名为 key,O(1) 查找。Set 存储 effect,自动去重。三层结构确保了修改name不会触发依赖age的 effect。[2]”
“第三,computed 的推拉结合模型。依赖变化时只标记 dirty(推),有人读取时才计算(拉)。这比 eager 模式高效得多——如果 computed 没有被读取,计算完全省掉。queueJob 的批量更新机制保证同一事件循环内的多次修改只触发一次组件更新,用微任务调度确保更新发生在浏览器渲染前。[3]“
常见追问
题目来源于真实面经(字节/腾讯/阿里前端面试、牛客网、haizlin/fe-interview),按难度递进。
reactive 解构为什么会丢失响应式?怎么解决?
reactive 解构为什么会丢失响应式?怎么解决?
答题要点:
解构赋值是值复制,变量脱离了 Proxy 代理。高分回答:
“reactive 返回的是 Proxy 对象,属性访问通过 get trap 收集依赖。JavaScript 的解构赋值
let { count } = state 本质上是值复制——count 被赋值为 state.count 的返回值(一个普通数字),它和 Proxy 没有任何关系了。解决方案有三种:1)不解构,直接用 state.count 访问;2)用 toRefs(state) 把每个属性转为 ref,解构出来的是 Ref 对象,.value 访问仍然走响应式;3)用 toRef(state, 'count') 单独转一个属性。模板中直接用 {{ state.count }} 不需要解构,这是最常见的场景。”为什么 ref 需要 .value 而 reactive 不需要?
为什么 ref 需要 .value 而 reactive 不需要?
答题要点:
从 Proxy 的限制出发解释设计权衡。高分回答:
“根本原因是 Proxy 只能代理对象,不能代理原始类型。
new Proxy(0, handlers) 会报错。所以 Vue 3 必须用一个对象把原始值包起来——RefImpl 就是这个包装对象。访问 .value 才能触发 getter 进行依赖收集,修改 .value 才能触发 setter 进行更新。reactive 不需要 .value 是因为它返回的是 Proxy 对象本身,直接访问属性就经过了 get trap。但 reactive 也有代价:解构丢失响应式、不能用于原始类型。Vue 团队内部讨论过”编译器自动脱 ref”方案(ref sugar),让编译器自动把 .value 去掉,但最终因为容易引起混淆而废弃了。目前的共识是:.value 是一个可接受的代价,换取了完整的类型安全和明确的语义。”computed 和 watch 的实现原理有什么区别?
computed 和 watch 的实现原理有什么区别?
答题要点:
核心区别是”惰性求值”vs”立即执行”以及 scheduler 的不同。高分回答:
“computed 和 watch 底层都是 ReactiveEffect,区别在于 scheduler 的实现。computed 的 scheduler 只设置
dirty = true(标记需要重新计算),不立即执行 getter。只有当有人读取 .value 时,如果 dirty 为 true 才执行计算并缓存结果。这是’推拉结合’——依赖变了推一个 dirty 标记,读取时拉取新值。watch 的 scheduler 是立即调度一个 job(通过 queueJob)。依赖变化时,watch 的回调函数会在微任务中被调用。watch 还支持 flush: 'pre'/'post'/'sync' 来控制回调的执行时机——pre 在组件更新前,post 在更新后,sync 是同步立即执行。一句话总结:computed 是’被动的、缓存的派生值’,watch 是’主动的、有副作用的响应’。”effectScope 是什么?为什么需要它?
effectScope 是什么?为什么需要它?
答题要点:
effectScope 解决的是 effect 的批量管理问题,尤其在组合式函数和组件卸载场景。高分回答:
“effectScope 是 Vue 3.2 引入的一个 API,用来收集和批量管理 effect(包括 computed、watch、watchEffect)。它的核心场景是:当你写一个组合式函数(composable),内部创建了多个 watch/effect,组件卸载时需要全部清理。没有 effectScope 的话,你需要手动 return 一个 stop 函数,在 onUnmounted 中逐个调用。有了 effectScope,只需要在 scope 内创建 effect,调用
scope.stop() 就能一次性清理所有。Vue 的 setup() 函数内部就隐式创建了一个 effectScope——所以 setup 中创建的 watch/computed 会在组件卸载时自动清理。一些状态管理库(如 Pinia)也用 effectScope 来管理 store 的响应式依赖。”nextTick 的实现原理?为什么不用 setTimeout?
nextTick 的实现原理?为什么不用 setTimeout?
答题要点:
nextTick 用 Promise.resolve 创建微任务,和 queueJob 共享调度。高分回答:
“Vue 3 的
nextTick 内部用 Promise.resolve() 创建微任务,返回一个 Promise。它和 queueJob(组件更新的调度器)共享同一个微任务批次——先执行 queueJob 中的更新任务,再执行 nextTick 的回调。多个 nextTick 调用会复用同一个 resolvedPromise,保证回调按顺序执行。为什么用微任务而不是宏任务(setTimeout)?因为微任务在当前同步代码执行完毕后、浏览器渲染前执行。这意味着:1)同步代码中的所有数据修改都收集完毕后,才统一更新 DOM;2)nextTick 回调在 DOM 更新后、浏览器渲染前执行。如果用 setTimeout,更新要等到浏览器渲染之后,会导致先渲染旧状态再更新为新状态的闪烁问题。”watch 和 watchEffect 的区别?什么时候用哪个?
watch 和 watchEffect 的区别?什么时候用哪个?
答题要点:
watch 显式指定侦听源,可拿旧值;watchEffect 自动追踪依赖,立即执行。高分回答:
“watch 和 watchEffect 底层都是
doWatch 函数(packages/runtime-core/src/apiWatch.ts),区别在于三个维度:1)侦听源:watch 需要明确指定侦听源(ref、reactive 或 getter 函数),watchEffect 不需要——它自动追踪回调中访问的所有响应式数据。
2)首次执行:watch 默认惰性(只在源变化时执行),加 immediate: true 可首次执行。watchEffect 立即执行一次。
3)旧值:watch 的回调可以拿到 (newVal, oldVal),watchEffect 拿不到。选型建议:需要对比新旧值或有条件地执行副作用时用 watch(如监听路由参数变化重新请求数据);需要自动收集依赖并立即执行副作用时用 watchEffect(如根据响应式数据动态设置 document.title)。”effect 和 ReactiveEffect 的关系?组件渲染是怎么用的?
effect 和 ReactiveEffect 的关系?组件渲染是怎么用的?
答题要点:
effect 是工厂函数,ReactiveEffect 是核心类,组件渲染是特殊的 ReactiveEffect。高分回答:
“
effect() 是一个工厂函数(packages/reactivity/src/effect.ts),内部创建 ReactiveEffect 实例并立即执行一次。ReactiveEffect 是响应式系统的核心类,有 run()、stop() 等方法,run() 执行副作用函数并收集依赖。组件渲染也是 ReactiveEffect——在 setupRenderEffect(packages/runtime-core/src/renderer.ts)中创建,但它的 scheduler 不是直接执行,而是调用 queueJob 把组件更新推入队列。这就是为什么数据变化不会立即更新 DOM,而是等微任务中批量更新。computed 的 ReactiveEffect 更特殊——它的 scheduler 只设置 dirty = true,不触发更新。watch/watchEffect 的 ReactiveEffect 通过 doWatch 创建,scheduler 调用 queueJob 来调度回调执行。”computed 的脏检查机制?连续修改 10 次会计算几次?
computed 的脏检查机制?连续修改 10 次会计算几次?
答题要点:
computed 用 dirty 标志位实现惰性求值,连续修改只在读取时计算一次。高分回答:
“computed 的核心是
dirty 标志位。当依赖变化时,scheduler 只设置 dirty = true(推),不重新计算。只有当有人读取 .value 时,如果 dirty 为 true 才执行 getter 计算新值,并设置 dirty = false(拉)。所以连续修改 10 次:依赖变化 10 次 → dirty 被设置为 true 10 次(但每次都是同一个标志位)→ 只有在最终读取 .value 时才计算 1 次。这比 eager 模式(每次变化都计算)高效 10 倍。但有一个陷阱:如果模板中有多个地方使用了同一个 computed,Vue 的模板编译器会缓存读取结果——在同一个渲染 effect 中,computed 只计算一次。这就是为什么 computed 比在 methods 中写同样的逻辑更高效——methods 每次渲染都重新执行,computed 只在依赖变化时重新计算。“Q10: reactive() 底层源码详解
reactive() 是 Vue 3 响应式系统的入口函数。它的核心逻辑是:创建一个 Proxy 对象,拦截所有操作,在 get 时收集依赖、在 set 时触发更新。
源码流程
handlers 的核心 trap
惰性代理的精妙设计
defineProperty 在初始化时必须递归遍历整个对象树,为每个属性设置 getter/setter。Proxy 只代理第一层,嵌套对象在 get trap 中按需递归——这就是”惰性代理”。Q11: 解构 reactive 为什么丢失响应式?
根本原因:解构是值复制
JavaScript 的解构赋值let { count } = state 本质上是:
ref 解构会丢失吗?
会。ref 只是把值包在 RefImpl 对象里,解构 .value 仍然是值复制:
toRefs 的原理
toRefs 把 reactive 对象的每个属性转为一个 ref,解构出来的是 Ref 对象:
Q12: ref vs reactive 的核心区别
为什么需要两个 API?
根本原因是 Proxy 只能代理对象,不能代理原始类型。ref 的内部实现
详细对比
什么时候用 ref,什么时候用 reactive?
Q13: watch 的原理
watch 的底层实现
watch 底层也是 ReactiveEffect,但和组件渲染的 effect 不同——它的 scheduler 是调度回调函数执行,而不是调度组件更新。watch 的 deep 选项
Q14: computed 的原理
computed 的推拉结合模型
computed 的核心设计是惰性求值 + 缓存。依赖变化时只标记 dirty(推),有人读取时才计算(拉)。computed vs method 的区别
Q15: effect 的原理
ReactiveEffect 的核心设计
ReactiveEffect 是 Vue 3 响应式系统的底层核心类。组件渲染、computed、watch/watchEffect 都是 ReactiveEffect 的实例。activeEffect 的工作原理
谁在创建 ReactiveEffect?
延伸阅读与引用参考
- Vue 3 源码:baseHandlers.ts - Proxy handler 定义:get/set/has/ownKeys/deleteProperty 的完整实现,理解 Proxy 代理的每一个拦截点。[1]
- Vue 3 源码:effect.ts - track/trigger 实现:三层 Map 的依赖收集和触发更新的核心逻辑,包括 ITERATE_KEY 和 MAP_KEY_ITERATE_KEY 的设计。[2]
- Vue 3 官方文档:Reactivity in Depth:尤雨溪对响应式系统设计哲学的最权威解释,包括 Proxy vs defineProperty 的动机和响应式丢失的解决方案。[3]
- 霍春阳《Vue.js 设计与实现》:国内最权威的 Vue 3 源码解析著作,从响应式系统到编译器的完整架构剖析。
- 黄轶《Vue.js 技术揭秘》:从 Vue 2 defineProperty 到 Vue 3 Proxy 的演进脉络,源码级深度解析。
