Vue 3 响应式原理深度剖析

Vue 3 响应式原理深度剖析

  1. 整体流程概览【核心架构】
    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 能够精确地知道“哪个对象的哪个属性”被哪些副作用函数依赖,从而在属性变化时只通知必要的函数。
  2. 如何让数据变成响应式【原理】
    Vue 3 提供了 reactiveref 两个 API 来创建响应式数据。它们底层都依赖于 ProxyReflect

    ① 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 属性的 gettersetter 来实现拦截(类似于 Object.defineProperty 的访问器属性)。
    • get value() 中,调用 trackRefValue(this) 进行依赖收集,将当前活跃的副作用函数添加到 dep 集合中。
    • set value() 中,如果值发生了变化,就调用 triggerRefValue(this) 触发 dep 集合中的所有副作用函数。
    • 当传入的 value 本身是对象时,toReactive 会调用 reactive 将其转为代理对象,从而实现对深层属性的响应式。
    • reactive 不同,ref 可以包装基本类型(如数字、字符串),这是 reactive 无法做到的。同时,ref 在解构或传递时仍然保持响应性(因为整个 RefImpl 实例是响应式的),而 reactive 解构后会丢失响应性。
  3. 依赖收集: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 中所有副作用函数的包装类。任何需要响应式追踪的函数(watchEffectcomputed、组件渲染)都会被包装成 ReactiveEffect 实例。
    • run() 方法在执行原始函数前,会把 activeEffect 指向自己,这样在后续的 track 中就能知道当前是哪个副作用函数正在执行。执行完毕后恢复 activeEffectundefined,避免后续错误收集。
    • 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 实现了精确到属性的依赖收集,而不是整个对象。
  4. 派发更新: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 能够灵活控制不同副作用函数的执行时机,是性能优化的关键。
  5. 调度器与异步更新队列【优化】
    问题:在一个组件中,如果同时修改多个响应式数据,理论上会触发多次渲染,造成不必要的性能浪费。

    解决方案: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 创建多个微任务。当第一个更新任务被推入队列时,isFlushingfalse,于是调用 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 放入异步队列,最终批量执行。

  6. 响应式与组件生命周期的串联【完整流程】
    要理解响应式如何驱动组件更新,必须将响应式系统与 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。

  7. 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 时,_dirtytrue,于是调用 this.effect.run() 执行 getter,得到计算结果并存储在 _value 中,然后将 _dirty 设为 false。在 getter 执行过程中,会访问其他响应式数据(如 count),这些数据的 track 会将计算属性的 effect 作为依赖收集起来。
    • 当计算属性的依赖数据变化时(例如 count 被修改),会触发 trigger,从而调用计算属性 effectscheduler。这个 scheduler 只做两件事:
      1. _dirty 设为 true,表示缓存已过期。
      2. 调用 trigger(this, 'value'),通知所有依赖该计算属性的副作用函数(比如组件渲染 effect):“我(计算属性)的值可能变化了,你们需要重新读取我”。
    • 随后,组件渲染 effect 会重新执行,在模板中访问计算属性时,再次触发 .valueget。此时 _dirtytrue,于是重新执行 getter 获取最新值,并更新缓存。
    • 这种机制保证了:只要依赖不变化,多次访问计算属性只会返回缓存的 _value,不会重复计算;只有依赖真正变化且计算属性被访问时,才会重新计算。同时,依赖计算属性的副作用函数也能得到正确的更新。

    对比 Vue 2,Vue 3 的 computed 实现更加简洁高效,不再需要维护两个 watcher,而是通过 effect 的嵌套和调度机制自然解决。

  8. Vue 3 响应式与 Vue 2 的差异总结【对比】

    维度Vue 2Vue 3
    数据劫持Object.defineProperty,只能拦截已有属性的读写Proxy + Reflect,可拦截 13 种操作,包括属性删除、新增、in
    数组处理重写数组原型方法(push、pop 等)才能侦测变化直接代理,索引赋值和 length 修改均可自动侦测
    对象新增属性无法侦测,需使用 Vue.set自动侦测,无需额外 API
    初始化性能递归遍历所有属性,深层对象开销大懒代理:只代理外层,内层在访问时才代理,性能更好
    依赖存储Dep + Watcher,每个属性一个 DeptargetMap(WeakMap) + ReactiveEffect,更灵活
    异步更新queueWatcher + nextTick,基于微任务queueJob + nextTick,逻辑类似但更清晰
    代码体积无法 tree-shaking,体积较大模块化设计,可 tree-shaking,体积更小
    TypeScript支持有限,需要装饰器或额外类型定义原生支持,类型推断强大
  9. 面试题汇总【考点】

    Q1:Vue 3 的响应式原理是什么?与 Vue 2 相比有哪些改进?

    A:Vue 3 使用 Proxy + Reflect 实现数据劫持,通过 tracktrigger 进行依赖收集和派发更新。每个响应式对象有一个 targetMap 存储其属性与副作用函数的映射关系。副作用函数被封装为 ReactiveEffect 实例,支持调度器控制执行时机。主要改进包括:可监听新增/删除属性、数组索引直接赋值、懒代理提升初始化性能、支持更多操作拦截、更好的 TypeScript 支持和更小的体积。

    Q2:请详细描述 Vue 3 中依赖收集的过程。

    A:依赖收集过程如下:

    1. 在副作用函数执行前,Vue 会设置全局变量 activeEffect 指向当前副作用函数(ReactiveEffect 实例)。
    2. 副作用函数执行过程中访问响应式数据(如 state.count),触发 Proxyget 拦截器。
    3. get 拦截器调用 track(target, key)
    4. track 函数通过 targetMap 找到该对象对应属性的依赖集合 dep(一个 Set),如果不存在则创建。
    5. 如果 activeEffect 不在 dep 中,则将其加入,并同时在 activeEffect.deps 中记录这个 dep,方便后续清理。
    6. 副作用函数执行完毕,activeEffect 被重置为 undefined
      通过这种方式,每个响应式数据属性都精确地知道哪些副作用函数依赖于它。

    Q3:Vue 3 的异步更新队列是如何工作的?为什么能优化性能?

    A:Vue 3 为组件渲染 effect 设置了 scheduler,该调度器调用 queueJob 将更新函数放入队列 queue 中。queueJob 会去重(同一个更新函数只会入队一次),并通过 nextTick 在下一个微任务中执行 flushJobs,遍历队列批量执行所有更新。这样做的好处是:无论短时间内数据变化多少次,组件都只会重新渲染一次,避免了频繁的 DOM 操作和重复的虚拟 DOM 计算,显著提升性能。

    Q4:computed 的缓存机制是如何实现的?

    A:每个计算属性内部创建一个 ReactiveEffect(懒执行),并维护 _dirty 标志和 _value 缓存。首次访问时,_dirtytrue,执行 effect.run() 计算值并缓存,然后 _dirty 设为 false。当依赖的响应式数据变化时,会触发该 effectscheduler,它只将 _dirty 设为 true,并通知依赖该计算属性的副作用函数。下次访问计算属性时,_dirtytrue,重新计算并更新缓存。这样既保证了只有在依赖变化且被访问时才重新计算,又避免了重复计算。

    Q5:什么是懒代理?它带来了什么好处?

    A:懒代理是指在使用 reactive 创建响应式对象时,只代理最外层对象,内层的嵌套对象只有在被实际访问时才会递归调用 reactive 变成响应式。好处是:对于深层嵌套的大对象,初始化时不需要遍历所有属性,显著提升性能。例如,一个对象有 1000 个深层嵌套属性,但组件只使用了其中几个,懒代理可以避免为那 990 个未使用的属性创建响应式开销。

    Q6:在 Vue 3 中,为什么 reactive 不能直接解构,而 ref 可以配合 toRefs 解构?

    Areactive 返回的是一个 Proxy 代理对象。解构时会触发 get 拦截,得到的是基本类型的值(如数字、字符串),这些值没有响应性。而 ref 返回的 RefImpl 实例本身就是一个响应式对象,解构后得到的是 ref 对象,仍具有响应性。若想解构 reactive 并保持响应性,需使用 toRefs,它会把每个属性转换为 ref 对象,从而保留响应性。

    Q7:Vue 3 中如何手动停止一个副作用函数(如 watchEffect)?

    AwatchEffect 会返回一个 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() 清除依赖,并触发 beforeUnmountunmounted。这样响应式更新就无缝融入了生命周期。

  10. 知识点总结

    • 核心架构Proxy 劫持 + targetMap 存储依赖 + ReactiveEffect 副作用 + scheduler 调度。
    • 依赖收集track 函数利用全局 activeEffect,将当前副作用函数加入属性对应的 Set 集合。
    • 派发更新trigger 函数取出 Set 中的所有副作用函数,通过 scheduler 或直接执行。
    • 异步队列queueJob + nextTick 实现批量更新,避免重复渲染。
    • 懒代理:按需递归,提升深层对象初始化性能。
    • computed:懒执行 + dirty 缓存,依赖变化仅标记,访问时重新计算。
    • 生命周期融合:渲染 effect 贯穿组件从挂载到更新的全过程,钩子调用时机精准。
    • 与 Vue 2 差异:全面升级,解决诸多痛点,更高效、更灵活、更健壮。

以上内容深度结合了源码原理与实战理解,并辅以详细的文字讲解,足以帮助读者透彻理解 Vue 3 响应式系统,并在面试中展现出扎实的功底。


Vue 3 响应式原理深度剖析
http://localhost:8090/archives/vue-3-xiang-ying-shi-yuan-li-shen-du-pou-xi
作者
Administrator
发布于
2026年04月07日
许可协议