vue/reactivity 从 3.5.0 响应式重构开始

829 阅读8分钟

vue 3.5.0版本重构了响应式,重构后内存占用减少56%,性能提升176%~244%,本文依据源码简单总结了其实现原理。

回顾

关于vue2/3的响应式对比,直接上 Deepseek 总结一下

Vue2 的响应式实现

原理: 基于 Object.defineProperty 对对象的属性进行劫持,结合发布-订阅模式实现。

特点

  1. 属性劫持: 需要递归遍历对象的所有属性,为每个属性设置 gettersetter
  2. 数组处理: 重写数组的 pushpopsplice 等方法,手动触发更新。
  3. 新增/删除属性: 无法检测对象属性的新增或删除,需通过 Vue.set/Vue.delete 手动触发响应式。

劣势

  1. 初始化性能差: 递归遍历所有属性,对大型对象性能影响较大。
  2. 无法监听动态属性: 无法自动响应新增或删除的属性,需要手动处理。
  3. 数组局限性: 无法检测通过索引直接修改数组元素(如 arr[0] = value)或修改数组长度。
  4. 内存占用高: 每个属性都需要独立的 Dep 实例管理依赖。

Vue3 的响应式实现

原理: 基于 Proxy 代理整个对象,结合 Reflect 操作对象属性。

特点

  1. 代理对象Proxy 直接代理整个对象,无需遍历属性。
  2. 动态监听: 自动检测属性的新增、删除和深层嵌套对象的修改。
  3. 惰性响应式: 只有在访问属性时才会递归处理嵌套对象,提升初始化性能。
  4. 数组处理: 直接监听数组索引变化和 length 修改,无需重写方法。

优势

  1. 性能优化

    • 初始化更快(惰性处理嵌套对象)。
    • 大型对象或数组的响应式处理更高效。
  2. 动态响应: 支持属性的动态增删,无需手动触发。

  3. 内存占用低: 通过 Proxy 统一管理依赖,无需为每个属性创建 Dep

  4. 更强大的监听能力: 支持 MapSetWeakMap 等复杂数据结构。

劣势

  1. 兼容性问题Proxy 无法被 Polyfill,不支持 IE11 及更低版本。
  2. 调试复杂度Proxy 的透明代理特性可能增加调试难度。

vue^3.5.0 基于双向链表和计数的响应式实现

先贴 Example 代码,看代码的时候可以思考以下几个问题:

  • 响应式值(counter_1, counter_2)的初始化做了什么?
  • 响应式值被订阅(watchEffect,渲染template)时发生了什么?

Vue 的 Reactive 是观察者模式,后文中,我们就把 ref 这样的被观察者叫做 Dep(Dependency),而 watchEffect 这样的观察者叫做 Sub(Subscriber)。

<script setup lang="ts">
import { ref, watchEffect, type Ref } from 'vue'const counter_1 = ref(1)
const counter_2 = ref(2)
​
watchEffect(() => {
  console.log('counter_1:', counter_1, 'counter_2', counter_2)
})
watchEffect(() => {
  console.log('counter_2:', counter_2, 'counter_1', counter_1)
})
​
const updateCount = (val: Ref<number, number>) => { val.value++ };
​
</script><template>
  <button @click="updateCount">updateCount</button>
  <div>counter_1: {{ counter_1 }}</div>
  <div>counter_2: {{ counter_2 }}</div>
</template>

上面的示例中,响应式分为 初始化 - 依赖收集 - 依赖触发 几个阶段,下面也用这个顺序去看;

响应式的初始化

简单总结一下,响应式的初始化就是:

  1. 传入原始值,创建类 RefImpl 的实例,以代理原始值的 getset

    • 基本数据类型 只需要包裹其原始值即可;
    • 引用数据类型 则需要用 Proxy 包装原始值,以劫持对象属性的访问(如果不是普通对象,还要根据不同类型为其设置不同的 ProxyHandler,以劫持其内建方法,比如 Set.has)

对象分成 普通对象&ArrayCOLLECTION ,COLLECTION 类型的对象需要考虑代理基于内部插槽的方法,为了保持简单,这一部分不在本文展开;

  1. RefImpl 的 value 属性设置 get/set 方法,并在 get 中收集依赖(使用 Dep 类),在 set 中通知 Sub(也就是 effect 副作用);
function ref(value) {
  return (new RefImpl(value))
}
​
class RefImpl<T> {
  _value: T
  dep: Dep = new Dep()
  
  constructor(value){
    this.value = toReactive(value)
  }
  
  get value() {
    this.dep.track()
    return this._value
  }
  
  set value(newValue) {
    this._value = toReactive(newValue)
    this.dep.trigger()
  }
}
​
// reactivity
function toReactive(value) {
  return isObject(value)
    ? reactive(value)
    : value
}
​
function reactive(target) {
  // cache 机制
  const existingProxy = proxyMap.get(target)
  if (existingProxy) return existingProxy
​
  const proxy = new Proxy(
    target,
    // COLLECTION 类型的对象需要额外代理基于内部插槽的方法;
    getTargetType(target) === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
​
  return proxy
}

依赖收集

RefImpl.value 的 get 方法里收集依赖;

class RefImpl<T> {
  ...
  
  dep: Dep = new Dep()
  
  get value() {
    this.dep.track()
    return this._value
  }
​
  ...
}

顺着线头看一下 Dep,这里的 subsHead & subs 就分别指向依赖当前 Dep 的双向链表的头和尾,这里是用 Link 这个指代“连接”的类表示的,从代码不难看出来,整个由 Sub Dep Link 组成的数据结构是方格网状结构,相比于以前的“多对多”关系,既可以维护 Sub 依赖的 Dep 顺序,又可以节省空间。

export class Link {
  /**
   * - Before each effect run, all previous dep links' version are reset to -1
   * - During the run, a link's version is synced with the source dep on access
   * - After the run, links with version -1 (that were never used) are cleaned
   *   up
   */
  version: number
​
  /**
   * Pointers for doubly-linked lists
   */
  nextDep?: Link
  prevDep?: Link
  nextSub?: Link
  prevSub?: Link
  prevActiveLink?: Link
​
  constructor(
    public sub: Subscriber,
    public dep: Dep,
  ) {
    this.version = dep.version
    this.nextDep =
      this.prevDep =
      this.nextSub =
      this.prevSub =
      this.prevActiveLink =
        undefined
  }
}
​
class Dep {
  // 当前 Dep 被访问的次数
  version = 0
​
  // 分别指向subs双向链表的头和尾
  subsHead: Link
  subs: Link
​
  // 也可能依赖
  map?: KeyToDepMap = undefined
  key?: unknown = undefined
​
  /**
   * Subscriber counter
   */
  sc: number = 0
​
  track() {
    /**
     * Edgecase
     * activeSub 是指当前活跃的 effect,是一个 ReactiveEffect;
     * 回忆一下代码是如何运行到 Dep.track 的:当访问 RefImpl.value 的时候,比如 
     * <div>{{ refVal }}<div>
     * 或者 watchEffect(() => { console.log('val', refVal.value) })
     * 当 Sub 运行时,会把自己挂载到全局变量 activeSub,来指明当前活跃的 Sub 是哪个,便于依赖收集
     */
    if (!activeSub) return;
​
    let link = this.activeLink
    // 当前 Dep 作为 Sub 的新依赖
    if (link === undefined || link.sub !== activeSub) {
      link = this.activeLink = new Link(activeSub, this)
​
      // add the link to the activeEffect as a dep (as tail)
      if (!activeSub.deps) {
        activeSub.deps = activeSub.depsTail = link
      } else {
        link.prevDep = activeSub.depsTail
        activeSub.depsTail!.nextDep = link
        activeSub.depsTail = link
      }
​
      /**
       * 1、更新dep的sc计数;
       * 2、处理 computed 依赖收集,递归的将 computed 的 deps 也加到此 effect 的
       */
      addSub(link)
    // 当前 Dep 已经是 Sub 的依赖,且重复访问
    } else if (link.version === -1) {
      // reused from last run - already a sub, just sync version
      // 设置 link.version,最后垃圾回收的时候,link.version 仍然为 -1 的依赖会被回收
      link.version = this.version
​
      // 重新排列 deps 的顺序,保证链表顺序与代码执行顺序一致
      // If this dep has a next, it means it's not at the tail - move it to the
      // tail. This ensures the effect's dep list is in the order they are
      // accessed during evaluation.
      if (link.nextDep) {
        const next = link.nextDep
        next.prevDep = link.prevDep
        if (link.prevDep) {
          link.prevDep.nextDep = next
        }
​
        link.prevDep = activeSub.depsTail
        link.nextDep = undefined
        activeSub.depsTail!.nextDep = link
        activeSub.depsTail = link
​
        // this was the head - point to the new head
        if (activeSub.deps === link) {
          activeSub.deps = next
        }
      }
    }
​
    return link
  }
}

依赖改变,通知 Sub

调用 RefImpl.value 的 set 方法,触发 Dep.notify 方法,然后沿着链表调用每一个 Sub.notify 方法,这一步距离副作用的实际执行还有一个 Batch,也就是批处理,下面我们顺着线头接着捋,看 ReactiveEffect(Sub)。

class RefImpl<T> {
  ...
  
  set value(newValue) {
    this._value = toReactive(newValue)
    this.dep.trigger()
  }
​
  ...
}
​
class Dep {
  ...
  
  trigger() {
    this.version++
    this.notify()
  }
  
  notify() {
    // 递归调用
    startBatch()
    try {
      for (let link = this.subs; link; link = link.prevSub) {
        if (link.sub.notify()) {
          // if notify() returns `true`, this is a computed. Also call notify
          // on its dep - it's called here instead of inside computed's notify
          // in order to reduce call stack depth.
          // 遇到 sub 是 computed 时需要递归,因为 computed 可能既是 Sub 也是 Dep
          ;(link.sub as ComputedRefImpl).dep.notify()
        }
      }
    } finally {
      endBatch()
    }
  }
​
  ...
}

Sub 的代码里,也有 deps/depsTail 的指向 Dep 的双向链表头尾;

flags 是记录当前 Sub 的状态的标志位,这里巧妙的用了位运算来表示,性能更好;

constructor 里的 EffectScope 是用来收集管理 effect 的,在一些使用 reactivity 特性的 vue 生态库中比较常见。

说多了......我们直接看 ReactiveEffect.notify,直接调用了 batch 方法,把需要运行的副作用连成链表,最后在 Dep.notifyendBatch,完成实际的副作用执行。

let activeSub: Subscriber | undefined
​
// Sub
class ReactiveEffect<T = any> implements Subscriber {
  // 双向链表的头和尾
  deps: Link
  depsTail: Link
  // 当前的运行状态,二进制表示,位运算转换
  flags: EffectFlags = EffectFlags.ACTIVE | EffectFlags.TRACKING
​
  // EffectScope 是用来收集管理 effect 的,在一些使用 reactivity 特性的 vue 生态库中比较常见;
  constructor(public fn: () => T) {
    if (activeEffectScope && activeEffectScope.active) {
      activeEffectScope.effects.push(this)
    }
  }
​
  // 实际副作用的执行
  run(): T {
    // 这个标志位记录当然 ReactiveEffect 的状态,用位运算实现,很巧妙,感兴趣的自己去看;
    // this.flags |= EffectFlags.RUNNING
​
    prepareDeps(this)
    const prevEffect = activeSub
    activeSub = this
​
    try {
      // this.fn 在 constructor 里用语法糖设置了
      return this.fn()
    } finally {
      cleanupDeps(this)
      activeSub = prevEffect
    }
  }
​
  // 触发副作用执行
  trigger(): void {
    // 暂停逻辑
    if (this.flags & EffectFlags.PAUSED) {
      pausedQueueEffects.add(this)
    } else if (this.scheduler) {
      // 使用额外定义的调度器来执行副作用,委托模式,一般不走这个分支
      this.scheduler()
    } else {
      // 检查是否有依赖变化,变化才执行
      this.runIfDirty()
    }
  }
​
  // 被 Dep 调用,通知 Sub
  notify(): void {
    // 禁止递归标志位
    if (
      this.flags & EffectFlags.RUNNING &&
      !(this.flags & EffectFlags.ALLOW_RECURSE)
    ) {
      return
    }
    // 
    if (!(this.flags & EffectFlags.NOTIFIED)) {
      batch(this)
    }
  }
}
​
// 用链表收集本次的副作用
function batch(sub: Subscriber, isComputed = false): void {
  sub.flags |= EffectFlags.NOTIFIED
  if (isComputed) {
    sub.next = batchedComputed
    batchedComputed = sub
    return
  }
  sub.next = batchedSub
  batchedSub = sub
}
​
/**
 * Run batched effects when all batches have ended
 * @internal
 */
// 触发依赖,调用每个 Sub 的 trigger 方法
export function endBatch(): void {
  while (batchedSub) {
    let e: Subscriber | undefined = batchedSub
    batchedSub = undefined
    while (e) {
      const next: Subscriber | undefined = e.next
      e.next = undefined
      e.flags &= ~EffectFlags.NOTIFIED
      if (e.flags & EffectFlags.ACTIVE) {
        try {
          // ACTIVE flag is effect-only
          ;(e as ReactiveEffect).trigger()
        } catch (err) {
          if (!error) error = err
        }
      }
      e = next
    }
  }
}
​
function isDirty(sub: Subscriber): boolean {
  for (let link = sub.deps; link; link = link.nextDep) {
    if (
      link.dep.version !== link.version ||
      (link.dep.computed &&
        (refreshComputed(link.dep.computed) ||
          link.dep.version !== link.version))
    ) {
      return true
    }
  }
  // @ts-expect-error only for backwards compatibility where libs manually set
  // this flag - e.g. Pinia's testing module
  if (sub._dirty) {
    return true
  }
  return false
}

依赖回收

Commit 里面说 refactors the core reactivity system to use version counting and a doubly-linked list,双线链表就是刚才聊的那些,现在再看 counting

就是每次执行副作用之前,prepareDeps 方法把所有 Linkversion 设置为 -1;然后在副作用运行时,当访问到某条 Link 对应的 Dep 时,再把这个值设置为别的;等到 endBatch 的时候,仍为 -1 的就是不再被依赖的 Dep 了,就完成了依赖回收。

class ReactiveEffect<T = any> {
  ...
  run(): T {
    prepareDeps(this)
  }
  ...
}
​
function prepareDeps(sub: Subscriber) {
  // Prepare deps for tracking, starting from the head
  for (let link = sub.deps; link; link = link.nextDep) {
    // set all previous deps' (if any) version to -1 so that we can track
    // which ones are unused after the run
    link.version = -1
    // store previous active sub if link was being used in another context
    link.prevActiveLink = link.dep.activeLink
    link.dep.activeLink = link
  }
}
​
class Dep {
  if (link.version === -1) {
    // reused from last run - already a sub, just sync version
    link.version = this.version
  }
}