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 提供了 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 解构后会丢失响应性。
  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 中所有副作用函数的包装类。任何需要响应式追踪的函数(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 实现了精确到属性的依赖收集,而不是整个对象。
  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 创建多个微任务。当第一个更新任务被推入队列时,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 放入异步队列,最终批量执行。

  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 时,_dirty 为 true,于是调用 this.effect.run() 执行 getter,得到计算结果并存储在 _value 中,然后将 _dirty 设为 false。在 getter 执行过程中,会访问其他响应式数据(如 count),这些数据的 track 会将计算属性的 effect 作为依赖收集起来。
    • 当计算属性的依赖数据变化时(例如 count 被修改),会触发 trigger,从而调用计算属性 effect 的 scheduler。这个 scheduler 只做两件事:
      1. 将 _dirty 设为 true,表示缓存已过期。
      2. 调用 trigger(this, 'value'),通知所有依赖该计算属性的副作用函数(比如组件渲染 effect):“我(计算属性)的值可能变化了,你们需要重新读取我”。
    • 随后,组件渲染 effect 会重新执行,在模板中访问计算属性时,再次触发 .value 的 get。此时 _dirty 为 true,于是重新执行 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 实现数据劫持,通过 track 和 trigger 进行依赖收集和派发更新。每个响应式对象有一个 targetMap 存储其属性与副作用函数的映射关系。副作用函数被封装为 ReactiveEffect 实例,支持调度器控制执行时机。主要改进包括:可监听新增/删除属性、数组索引直接赋值、懒代理提升初始化性能、支持更多操作拦截、更好的 TypeScript 支持和更小的体积。

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

    A:依赖收集过程如下:

    1. 在副作用函数执行前,Vue 会设置全局变量 activeEffect 指向当前副作用函数(ReactiveEffect 实例)。
    2. 副作用函数执行过程中访问响应式数据(如 state.count),触发 Proxy 的 get 拦截器。
    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 缓存。首次访问时,_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。这样响应式更新就无缝融入了生命周期。

  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日
许可协议