Vue 3 响应式原理深度剖析
Vue 3 响应式原理深度剖析
-
整体流程概览【核心架构】
Vue 3 的响应式系统建立在 Proxy 和 副作用函数(Effect) 之上。整体流程可以概括为四个环节:数据劫持 → 依赖收集 → 派发更新 → 调度执行。具体来说:
- 数据劫持:使用
Proxy代理原始对象,能够拦截对对象的各种操作(读取、设置、删除、in等)。 - 依赖收集:当像组件渲染函数这样的副作用函数执行时,它会访问响应式数据。一旦访问,Proxy 的
get拦截就会被触发,此时 Vue 会记录下“当前这个副作用函数依赖于该数据”,这一步称为依赖收集。 - 派发更新:当响应式数据被修改时(例如
state.count++),Proxy 的set拦截会触发。Vue 根据之前收集的依赖关系,找出所有依赖于该数据的副作用函数,并准备重新执行它们。 - 调度执行:为了避免频繁重复执行副作用函数,Vue 会将这些函数放入一个异步队列,利用
nextTick进行批量处理。同一个副作用函数只会被加入队列一次,最终在下一个微任务或宏任务中统一执行,从而显著提升性能。
核心数据结构:
targetMap是一个WeakMap,用来存储所有响应式对象与它们属性依赖的关系。其结构为:targetMap: WeakMap<原始对象, Map<属性名, Set<副作用函数>>>- 键是原始对象(
target) - 值是一个
Map,该Map的键是属性名(key),值是一个Set集合,里面存放着所有依赖于这个对象特定属性的副作用函数(ReactiveEffect实例)。
这个三层结构使得 Vue 能够精确地知道“哪个对象的哪个属性”被哪些副作用函数依赖,从而在属性变化时只通知必要的函数。
- 数据劫持:使用
-
如何让数据变成响应式【原理】
Vue 3 提供了reactive和ref两个 API 来创建响应式数据。它们底层都依赖于 Proxy 和 Reflect。① reactive 的实现原理
function reactive(target) { // 如果已经是响应式对象,直接返回 if (target && target.__v_isReactive) return target const proxy = new Proxy(target, { get(target, key, receiver) { // 1. 依赖收集 track(target, key) // 2. 获取原始值 const res = Reflect.get(target, key, receiver) // 3. 懒代理:如果值是对象,递归调用 reactive if (isObject(res)) return reactive(res) return res }, set(target, key, value, receiver) { const oldValue = target[key] const result = Reflect.set(target, key, value, receiver) if (hasChanged(value, oldValue)) { // 4. 派发更新 trigger(target, key, value, oldValue) } return result }, deleteProperty(target, key) { const hadKey = hasOwn(target, key) const result = Reflect.deleteProperty(target, key) if (hadKey && result) { trigger(target, key, undefined, undefined) } return result } }) return proxy }讲解:
reactive接收一个普通对象,返回一个Proxy代理对象。后续对代理对象的操作都会被拦截。get拦截器:当读取属性时,首先调用track(target, key)进行依赖收集(稍后详细解释)。然后使用Reflect.get获取原始属性值。这里必须用Reflect而不是直接target[key],因为Reflect可以保证正确的this指向(尤其当属性是访问器属性时)。接着,如果获取到的值是一个对象,就递归调用reactive将其变成响应式。这就是 懒代理:只有当真正访问到嵌套对象时,才会将其转换,避免了初始化时遍历整个对象树的开销。set拦截器:当修改属性时,先通过Reflect.set完成实际赋值,然后比较新旧值是否真的发生变化。如果变化了,就调用trigger(target, key)派发更新,通知所有依赖该属性的副作用函数重新执行。deleteProperty拦截器:用于拦截delete obj.prop操作。当删除成功时,同样调用trigger通知更新。- 这种设计使得 Vue 3 能够完美监听属性的新增、删除以及数组的索引赋值和
length变化,而这些在 Vue 2 中都需要额外处理。
② ref 的实现原理
class RefImpl { constructor(value) { // 如果 value 是对象,则调用 reactive 包装成响应式 this._value = toReactive(value) this.dep = undefined // 用于存储依赖该 ref 的副作用函数集合 } get value() { // 依赖收集 trackRefValue(this) return this._value } set value(newVal) { if (hasChanged(newVal, this._rawValue)) { this._value = toReactive(newVal) // 派发更新 triggerRefValue(this) } } } function ref(value) { return new RefImpl(value) }讲解:
ref会创建一个RefImpl实例,该实例通过value属性的getter和setter来实现拦截(类似于Object.defineProperty的访问器属性)。- 在
get value()中,调用trackRefValue(this)进行依赖收集,将当前活跃的副作用函数添加到dep集合中。 - 在
set value()中,如果值发生了变化,就调用triggerRefValue(this)触发dep集合中的所有副作用函数。 - 当传入的
value本身是对象时,toReactive会调用reactive将其转为代理对象,从而实现对深层属性的响应式。 - 与
reactive不同,ref可以包装基本类型(如数字、字符串),这是reactive无法做到的。同时,ref在解构或传递时仍然保持响应性(因为整个RefImpl实例是响应式的),而reactive解构后会丢失响应性。
-
依赖收集:track 与 activeEffect【核心】
依赖收集的本质是:在副作用函数执行期间,建立该函数与所访问的响应式数据之间的映射关系。① 副作用函数封装:ReactiveEffect
class ReactiveEffect { constructor(fn, scheduler = null) { this.fn = fn // 原始函数(如组件渲染函数、watchEffect 回调) this.scheduler = scheduler // 调度器(用于控制函数执行时机) this.active = true this.deps = [] // 记录哪些依赖集合包含该 effect(用于清理) } run() { if (!this.active) return this.fn() try { // 将全局变量 activeEffect 设置为当前 effect activeEffect = this return this.fn() } finally { // 执行完毕,清除全局标记 activeEffect = undefined } } }讲解:
ReactiveEffect是 Vue 3 中所有副作用函数的包装类。任何需要响应式追踪的函数(watchEffect、computed、组件渲染)都会被包装成ReactiveEffect实例。run()方法在执行原始函数前,会把activeEffect指向自己,这样在后续的track中就能知道当前是哪个副作用函数正在执行。执行完毕后恢复activeEffect为undefined,避免后续错误收集。deps数组用于存储哪些依赖集合(即Set<ReactiveEffect>)中包含该 effect。当 effect 需要被清理(例如组件卸载)时,可以方便地从这些集合中移除自己。scheduler允许自定义调度逻辑。对于组件渲染 effect,scheduler会将更新函数推入异步队列,而不是立即执行。
② track 函数:依赖收集的实现
let activeEffect = null const targetMap = new WeakMap() function track(target, key) { // 如果没有正在执行的副作用函数,直接返回 if (!activeEffect) return // 获取 target 对应的依赖映射表 depsMap let depsMap = targetMap.get(target) if (!depsMap) { targetMap.set(target, (depsMap = new Map())) } // 获取 key 对应的依赖集合 dep let dep = depsMap.get(key) if (!dep) { depsMap.set(key, (dep = new Set())) } // 如果当前 effect 还没有被加入该依赖集合,则加入 if (!dep.has(activeEffect)) { dep.add(activeEffect) // 同时让 effect 记录这个 dep,以便后续清理 activeEffect.deps.push(dep) } }讲解:
track函数接收原始对象target和属性名key,它负责将当前活跃的activeEffect添加到该属性的依赖集合中。- 首先判断
activeEffect是否存在,如果不存在(比如没有在副作用函数中访问数据),则不做任何收集。 targetMap是一个WeakMap,它的键是原始对象,值是一个Map(称为depsMap)。depsMap的键是属性名,值是一个Set集合(称为dep),这个集合里存放着所有依赖于该对象特定属性的副作用函数。- 如果当前
activeEffect尚未被添加到dep中,就添加,并同时将dep记录到activeEffect.deps数组中。这一步是为了建立双向关系:既可以从数据找到 effect,也可以从 effect 找到所有依赖它的数据,方便后续清理。 - 正是通过
track,Vue 实现了精确到属性的依赖收集,而不是整个对象。
-
派发更新:trigger 函数【原理】
function trigger(target, key) { // 获取 target 的依赖映射表 const depsMap = targetMap.get(target) if (!depsMap) return // 获取 key 对应的依赖集合 const dep = depsMap.get(key) if (dep) { // 复制一份依赖集合,避免在遍历过程中集合被修改 const effects = [...dep] effects.forEach(effect => { // 如果有 scheduler,则调用 scheduler;否则直接执行 effect.run() if (effect.scheduler) { effect.scheduler() } else { effect.run() } }) } }讲解:
trigger函数在响应式数据被修改时调用,它的任务就是找出所有依赖于该数据的副作用函数并执行(或调度执行)。- 首先通过
targetMap找到该对象对应属性的依赖集合dep,如果没有,说明没有副作用函数依赖此属性,直接返回。 - 由于在遍历过程中可能会修改
dep集合(比如某个 effect 执行时又会触发新的依赖添加),因此先通过[...dep]创建一份副本,避免边遍历边修改导致问题。 - 对于每个副作用函数,如果它带有
scheduler(调度器),则调用调度器;否则直接调用run()。对于组件渲染 effect,它的scheduler会将更新函数推入异步队列,而不是立即执行;而对于一般的watchEffect,通常没有scheduler,会立即执行。 - 这种设计使得 Vue 能够灵活控制不同副作用函数的执行时机,是性能优化的关键。
-
调度器与异步更新队列【优化】
问题:在一个组件中,如果同时修改多个响应式数据,理论上会触发多次渲染,造成不必要的性能浪费。解决方案:Vue 3 为组件渲染 effect 提供了一个
scheduler,该调度器会将组件的更新任务放入一个异步队列,并去重,最终批量执行。const queue = [] let isFlushing = false function queueJob(job) { // 如果队列中还没有这个任务,就加入 if (!queue.includes(job)) { queue.push(job) // 如果还没有开始刷新队列,则开始异步刷新 if (!isFlushing) { isFlushing = true nextTick(flushJobs) } } } function flushJobs() { let job while ((job = queue.shift())) { job() } isFlushing = false }讲解:
queueJob是用于将更新函数(job)推入队列的入口。每个组件更新函数就是一个job。- 通过
!queue.includes(job)确保同一个组件的更新函数只会被加入队列一次,避免重复渲染。 isFlushing标志位防止多次调用nextTick创建多个微任务。当第一个更新任务被推入队列时,isFlushing为false,于是调用nextTick(flushJobs),将刷新操作放入下一个微任务。nextTick在 Vue 3 中默认使用Promise.resolve().then(),将回调推迟到当前同步代码执行完毕之后。flushJobs会依次执行队列中的所有更新函数。这样,一个事件循环内无论修改多少次响应式数据,组件都只会在最后重新渲染一次,极大提升了性能。
组件渲染 effect 的
scheduler就是这样实现的:const effect = new ReactiveEffect( componentUpdateFn, () => queueJob(instance.update) // scheduler 将更新推入队列 ) instance.update = effect.run.bind(effect)当响应式数据变化时,
trigger会调用这个scheduler,进而调用queueJob,将组件的instance.update放入异步队列,最终批量执行。 -
响应式与组件生命周期的串联【完整流程】
要理解响应式如何驱动组件更新,必须将响应式系统与 Vue 3 的生命周期钩子结合起来看。① 初始化阶段
- beforeCreate:组件实例刚刚创建,此时 data、props 等尚未处理,没有响应式。
- created:data、props、computed 等已经被处理为响应式(
reactive/ref),但模板尚未渲染。此时可以访问响应式数据,但操作 DOM 会无效。 - beforeMount:开始编译模板(如果使用
runtime-only版本,模板已在编译阶段转为渲染函数),并准备创建渲染 effect。
② 渲染 effect 的创建
在beforeMount之后,Vue 会执行以下核心逻辑:// 组件更新函数,负责生成虚拟 DOM 并更新真实 DOM const componentUpdateFn = () => { // 执行渲染函数,生成新的虚拟 DOM 树 const subTree = instance.render() // 调用 patch 函数,将新虚拟 DOM 与旧的对比并更新真实 DOM patch(instance.subTree, subTree, container) instance.subTree = subTree } // 创建渲染 effect,第二个参数是 scheduler const effect = new ReactiveEffect( componentUpdateFn, () => queueJob(instance.update) ) instance.update = effect.run.bind(effect)讲解:
componentUpdateFn是组件真正的更新逻辑:执行渲染函数得到新的虚拟 DOM,然后调用patch去更新真实 DOM。- 将这个函数包装成一个
ReactiveEffect实例,并传入自定义的scheduler。这个scheduler会在响应式数据变化时被调用,将instance.update推入异步队列。 - 首次渲染时,会手动调用
instance.update(),即effect.run()。这时会执行componentUpdateFn,过程中会访问模板中使用的响应式数据(比如{{ count }}),从而触发track,将当前effect添加到这些数据的依赖集合中。
③ 派发更新
- 当响应式数据发生变化(例如用户点击按钮执行
count.value++),会触发trigger。 trigger找到该数据对应的依赖集合,取出组件渲染effect,并调用其scheduler。scheduler调用queueJob(instance.update),将instance.update放入异步队列。- 当前同步代码执行完毕后,
nextTick的回调flushJobs开始执行,遍历队列,依次调用每个组件的instance.update,重新执行componentUpdateFn,生成新虚拟 DOM 并patch,从而更新界面。
④ 生命周期钩子的调用时机
- mounted:首次渲染完成后(即
componentUpdateFn首次执行并patch完成后)调用。 - beforeUpdate:在数据变化导致重新渲染之前,即
componentUpdateFn中执行patch之前调用。 - updated:重新渲染并
patch完成后调用。 - beforeUnmount / unmounted:组件卸载时,会调用
effect.stop()清除依赖,并触发相应钩子。
这种设计使得响应式系统与生命周期完美融合:数据变化 → 触发 scheduler → 异步队列 → 执行更新 → 触发 beforeUpdate/updated。
-
computed 原理:缓存与懒执行【进阶】
computed是 Vue 中一个重要的响应式衍生数据,它具备缓存和懒执行的特点:只有当依赖的数据变化且被实际访问时,才会重新计算。class ComputedRefImpl { constructor(getter) { this._value = undefined this._dirty = true // 标记缓存是否过期 // 创建一个 effect,并传入 scheduler this.effect = new ReactiveEffect(getter, () => { if (!this._dirty) { this._dirty = true // 当依赖变化时,通知依赖于该计算属性的副作用函数(如组件渲染) trigger(this, 'value') } }) } get value() { // 如果缓存过期,则重新计算 if (this._dirty) { this._value = this.effect.run() this._dirty = false } // 收集当前访问计算属性的副作用函数 track(this, 'value') return this._value } }讲解:
- 每个计算属性内部都有一个
ReactiveEffect实例,它的getter就是用户定义的计算函数。这个 effect 被配置为懒执行,即不会立即运行。 _dirty标志位表示当前缓存的值是否有效。初始为true,表示尚未计算。- 首次访问计算属性的
.value时,_dirty为true,于是调用this.effect.run()执行getter,得到计算结果并存储在_value中,然后将_dirty设为false。在getter执行过程中,会访问其他响应式数据(如count),这些数据的track会将计算属性的effect作为依赖收集起来。 - 当计算属性的依赖数据变化时(例如
count被修改),会触发trigger,从而调用计算属性effect的scheduler。这个scheduler只做两件事:- 将
_dirty设为true,表示缓存已过期。 - 调用
trigger(this, 'value'),通知所有依赖该计算属性的副作用函数(比如组件渲染 effect):“我(计算属性)的值可能变化了,你们需要重新读取我”。
- 将
- 随后,组件渲染 effect 会重新执行,在模板中访问计算属性时,再次触发
.value的get。此时_dirty为true,于是重新执行getter获取最新值,并更新缓存。 - 这种机制保证了:只要依赖不变化,多次访问计算属性只会返回缓存的
_value,不会重复计算;只有依赖真正变化且计算属性被访问时,才会重新计算。同时,依赖计算属性的副作用函数也能得到正确的更新。
对比 Vue 2,Vue 3 的 computed 实现更加简洁高效,不再需要维护两个 watcher,而是通过 effect 的嵌套和调度机制自然解决。
- 每个计算属性内部都有一个
-
Vue 3 响应式与 Vue 2 的差异总结【对比】
维度 Vue 2 Vue 3 数据劫持 Object.defineProperty,只能拦截已有属性的读写Proxy+Reflect,可拦截 13 种操作,包括属性删除、新增、in等数组处理 重写数组原型方法(push、pop 等)才能侦测变化 直接代理,索引赋值和 length修改均可自动侦测对象新增属性 无法侦测,需使用 Vue.set自动侦测,无需额外 API 初始化性能 递归遍历所有属性,深层对象开销大 懒代理:只代理外层,内层在访问时才代理,性能更好 依赖存储 Dep + Watcher,每个属性一个 Dep targetMap(WeakMap) + ReactiveEffect,更灵活 异步更新 queueWatcher+nextTick,基于微任务queueJob+nextTick,逻辑类似但更清晰代码体积 无法 tree-shaking,体积较大 模块化设计,可 tree-shaking,体积更小 TypeScript 支持有限,需要装饰器或额外类型定义 原生支持,类型推断强大 -
面试题汇总【考点】
Q1:Vue 3 的响应式原理是什么?与 Vue 2 相比有哪些改进?
A:Vue 3 使用
Proxy+Reflect实现数据劫持,通过track和trigger进行依赖收集和派发更新。每个响应式对象有一个targetMap存储其属性与副作用函数的映射关系。副作用函数被封装为ReactiveEffect实例,支持调度器控制执行时机。主要改进包括:可监听新增/删除属性、数组索引直接赋值、懒代理提升初始化性能、支持更多操作拦截、更好的 TypeScript 支持和更小的体积。Q2:请详细描述 Vue 3 中依赖收集的过程。
A:依赖收集过程如下:
- 在副作用函数执行前,Vue 会设置全局变量
activeEffect指向当前副作用函数(ReactiveEffect实例)。 - 副作用函数执行过程中访问响应式数据(如
state.count),触发Proxy的get拦截器。 get拦截器调用track(target, key)。track函数通过targetMap找到该对象对应属性的依赖集合dep(一个Set),如果不存在则创建。- 如果
activeEffect不在dep中,则将其加入,并同时在activeEffect.deps中记录这个dep,方便后续清理。 - 副作用函数执行完毕,
activeEffect被重置为undefined。
通过这种方式,每个响应式数据属性都精确地知道哪些副作用函数依赖于它。
Q3:Vue 3 的异步更新队列是如何工作的?为什么能优化性能?
A:Vue 3 为组件渲染 effect 设置了
scheduler,该调度器调用queueJob将更新函数放入队列queue中。queueJob会去重(同一个更新函数只会入队一次),并通过nextTick在下一个微任务中执行flushJobs,遍历队列批量执行所有更新。这样做的好处是:无论短时间内数据变化多少次,组件都只会重新渲染一次,避免了频繁的 DOM 操作和重复的虚拟 DOM 计算,显著提升性能。Q4:computed 的缓存机制是如何实现的?
A:每个计算属性内部创建一个
ReactiveEffect(懒执行),并维护_dirty标志和_value缓存。首次访问时,_dirty为true,执行effect.run()计算值并缓存,然后_dirty设为false。当依赖的响应式数据变化时,会触发该effect的scheduler,它只将_dirty设为true,并通知依赖该计算属性的副作用函数。下次访问计算属性时,_dirty为true,重新计算并更新缓存。这样既保证了只有在依赖变化且被访问时才重新计算,又避免了重复计算。Q5:什么是懒代理?它带来了什么好处?
A:懒代理是指在使用
reactive创建响应式对象时,只代理最外层对象,内层的嵌套对象只有在被实际访问时才会递归调用reactive变成响应式。好处是:对于深层嵌套的大对象,初始化时不需要遍历所有属性,显著提升性能。例如,一个对象有 1000 个深层嵌套属性,但组件只使用了其中几个,懒代理可以避免为那 990 个未使用的属性创建响应式开销。Q6:在 Vue 3 中,为什么
reactive不能直接解构,而ref可以配合toRefs解构?A:
reactive返回的是一个Proxy代理对象。解构时会触发get拦截,得到的是基本类型的值(如数字、字符串),这些值没有响应性。而ref返回的RefImpl实例本身就是一个响应式对象,解构后得到的是ref对象,仍具有响应性。若想解构reactive并保持响应性,需使用toRefs,它会把每个属性转换为ref对象,从而保留响应性。Q7:Vue 3 中如何手动停止一个副作用函数(如
watchEffect)?A:
watchEffect会返回一个stop函数。该函数内部调用effect.stop(),将effect.active设为false,并遍历effect.deps数组,从每个依赖集合中移除自己。此后,该副作用函数将不再响应任何数据变化。Q8:请简述 Vue 3 的渲染 effect 是如何与组件生命周期结合的。
A:在
beforeMount后,Vue 会创建组件渲染 effect,其原始函数是componentUpdateFn,调度器是queueJob。首次执行 effect 会触发渲染,完成后调用mounted。数据变化时,trigger调用调度器,将instance.update放入异步队列,执行前调用beforeUpdate,执行后调用updated。组件卸载时,调用effect.stop()清除依赖,并触发beforeUnmount和unmounted。这样响应式更新就无缝融入了生命周期。 - 在副作用函数执行前,Vue 会设置全局变量
-
知识点总结
- 核心架构:
Proxy劫持 +targetMap存储依赖 +ReactiveEffect副作用 +scheduler调度。 - 依赖收集:
track函数利用全局activeEffect,将当前副作用函数加入属性对应的Set集合。 - 派发更新:
trigger函数取出Set中的所有副作用函数,通过scheduler或直接执行。 - 异步队列:
queueJob+nextTick实现批量更新,避免重复渲染。 - 懒代理:按需递归,提升深层对象初始化性能。
- computed:懒执行 +
dirty缓存,依赖变化仅标记,访问时重新计算。 - 生命周期融合:渲染 effect 贯穿组件从挂载到更新的全过程,钩子调用时机精准。
- 与 Vue 2 差异:全面升级,解决诸多痛点,更高效、更灵活、更健壮。
- 核心架构:
以上内容深度结合了源码原理与实战理解,并辅以详细的文字讲解,足以帮助读者透彻理解 Vue 3 响应式系统,并在面试中展现出扎实的功底。