vue3.x 响应式系:依赖收集与派发更新

0 阅读10分钟

Vue 3 的响应式系统基于 Proxy 进行了彻底重构,相比 Vue 2 的 Object.defineProperty 方案,它能够拦截更多操作(如属性的添加/删除、数组索引和 length 修改、Map/Set 等方法调用),并且采用 懒递归 和 精细化依赖管理,性能与可维护性都大幅提升。

副作用

概念

在 Vue 3 的响应式系统里,“副作用”(Effect)是一个核心概念。简单来说,副作用是指那些依赖于响应式数据,并且当这些数据变化时需要重新执行的函数。

  • 组件的渲染函数(当数据变化时重新生成虚拟 DOM)
  • watch 或 watchEffect 的回调(当被监听的数据变化时执行某些逻辑)
  • computed 的 getter(当其依赖变化时重新计算值)

这些函数在依赖的数据变化时会被“触发”重新运行,从而产生一些“额外的影响”(比如更新视图、发起请求、操作 DOM 等),因此被称为副作用。

副作用有哪些特点?

  1. 依赖追踪
    副作用函数在执行期间会访问响应式数据(如 refreactive 对象的属性)。Vue 的响应式系统会记录这个副作用与这些数据的对应关系。
  2. 自动重新执行
    当副作用所依赖的数据发生变化时,Vue 会自动将该副作用标记为“需要重新运行”,并在合适的时机(通常是下一个微任务)重新执行它。
  3. 可停止
    副作用可以被手动或自动停止(组件卸载时)。停止后,它不再响应任何数据变化。

一些概念

副作用实例 ReactiveEffect

在 Vue 3 的响应式系统中,ReactiveEffect 是代表一个副作用(Effect) 的核心类。任何需要“在响应式数据变化时自动重新执行”的逻辑,都会被封装成一个 ReactiveEffect 实例。

依赖容器 Dep 

在 Vue 3 的响应式系统中,Dep 是 Dependency(依赖)  的缩写,它代表一个依赖容器。每个响应式数据(例如 ref 的 .valuereactive 对象的某个属性、computed 的计算结果)都对应一个唯一的 Dep 实例。Dep 负责存储所有依赖该数据的副作用(ReactiveEffect ,并在数据变化时通知它们更新。

双向链表节点 Link

在 Vue 3 的响应式系统中,Link 是一个核心的数据结构,用于连接 ReactiveEffect(副作用)  和 Dep(依赖容器) ,形成双向链表。每个 Link 节点同时存在于 effect.deps 链表(记录该 effect 依赖的所有 Dep)和 dep.subs 链表(记录依赖该 Dep 的所有 effect)。这种设计使得依赖关系的增删和更新操作都能在 O(1)  时间内完成,并且为高效的增量依赖清理提供了基础。

Dep.version 与 Link.version 协作机制

Dep.version:每次该数据被修改(通过 set 陷阱),dep.version 会自增 1

image.png

Link.version:Effect 上一次看到的版本。

步骤1: effect 准备运行(prepareDeps

Vue 在每次执行 effect 前,会遍历该 effect 的 deps 链表,将所有 Link.version 设置为 -1

image.png

步骤2: effect 执行(run 中的 fn
  • 当 effect 内的函数访问某个响应式数据时,会触发 track
  • track 会找到或创建与该 Dep 和当前 Effect 关联的 Link 节点。
    • 如果 Link 已存在且其 version === -1(本轮尚未访问),则将其 version 更新为当前 dep.version(一个正数),并将该 Link 移动到 effect 依赖链表的尾部(LRU 优化)。
    • 如果 Link 是新创建的,version 直接设置为 dep.version
  • 这样,本次运行中被访问过的依赖,其 Link.version 被更新为 dep.version(不再是 -1)。

image.png

Vue 3 维护 effect.deps 链表时采用了 LRU(最近最少使用)  策略:

  • 链表尾部:存放本次 effect 执行中最近被访问的依赖。
  • 链表头部:存放可能已过时的依赖(即上次执行后未被再次访问的依赖)。

移动 link 到尾部的目的,是在 effect 执行结束后,能够高效地清理无效依赖

  • 清理阶段(cleanupDeps)会从链表头部开始遍历,遇到第一个 version !== -1 的节点时停止。
  • 由于被访问过的依赖都被移到了尾部,头部连续区域全是 version === -1 的废弃节点,清理时只需删除头部一段连续的节点,而无需扫描整个链表。
步骤3: effect 执行完毕(cleanupDeps
  • 再次遍历 effect 的 deps 链表,检查每个 Link.version
    • 如果 Link.version === -1 → 该依赖在本次执行中没有被访问 → 调用 removeSub(link) 将其从 Dep.subs 和 Effect.deps 中移除(即清理失效依赖)。
    • 如果 Link.version !== -1 → 依赖仍然有效,保留在链表中。
function cleanupDeps(sub: Subscriber) {
  // Cleanup unused deps
  let head // 用于记录新的依赖链表头部
  let tail = sub.depsTail // 从订阅者的 depsTail 开始,即依赖链表的尾部
  let link = tail // 当前处理的依赖链接,初始化为 tail

  // 逆序遍历依赖链表
  while (link) {
    const prev = link.prevDep
    // 在 prepareDeps 函数中,所有依赖的版本号会被重置为 -1
    if (link.version === -1) {
      if (link === tail) tail = prev
      // unused - remove it from the dep's subscribing effect list
      removeSub(link)
      // also remove it from this effect's dep list
      removeDep(link)
    } else {
      // The new head is the last node seen which wasn't removed
      // from the doubly-linked list
      head = link
    }

    // restore previous active link if any
    // 复依赖的 activeLink 为之前保存的 prevActiveLink
    link.dep.activeLink = link.prevActiveLink
    link.prevActiveLink = undefined // 清除 prevActiveLink,避免内存泄漏
    link = prev
  }
  // set the new head & tail
  sub.deps = head
  sub.depsTail = tail
}

effect API

Vue3 响应式系统的核心机制,负责创建一个响应式副作用(Reactive Effect),并立即执行一次,返回一个可以手动触发该副作用的 runner 函数

语法

// 语法
const runner = effect(fn,options:ReactiveEffectOptions)
interface ReactiveEffectOptions extends DebuggerOptions {
  // 调度器函数,用于在依赖变化时调用
  scheduler?: EffectScheduler
  // 是否允许递归调用
  allowRecurse?: boolean
  // 停止函数,用于在订阅者被移除时调用
  onStop?: () => void
}

示例 多个副作用 关联同一个响应式数据

import { effect, ref } from "vue";
const color = ref(0);
const count = ref(0);
const title = ref("云平台首页新");

const handleClick = () => {
  count.value++;
};

/*  情况一: 多个 effect 订阅 同一个响应式变量 */

// 副作用函数、监听 count变化
effect(() => {
  console.log("effect1 count 值", count.value);
});

// 副作用函数、监听 count变化
effect(() => {
  console.log("effect2 count 值", count.value);
});

执行 第一个 effect , 新增双向链表 link 结构示意图

  1. 第一步:创建 count Ref响应式数据,初始化 dep = new Dep()
  2. 第二步:执行 effect, 创建副作用 ReactiveEffect(即effect1)
  3. 第三步:执行 effect1 副作用的回调函数,count.value 触发依赖收集
  4. 第四步:执行 countRef.dep.track, 触发依赖收集,创建双向链表 Link(sub,dep)
  5. 第五步:建立 effect1link 的关联
  6. 第六步:建立 deplink 的关联

image.png

执行 第二个 effect , 新增双向链表 link 结构示意图

  1. 第一步:执行 effect, 创建副作用 ReactiveEffect(即effect2)
  2. 第二步:执行 effect2 副作用的回调函数,count.value 触发依赖收集
  3. 第三步:执行 countRef.dep.track, 触发依赖收集,创建双向链表 Link2(effect2,dep)
  4. 第四步:建立 effect2link2 的关联
  5. 第五步:建立 deplink2 的关联

image.png

点击按钮触发依赖更新,从 dep.subs 尾指针开始向前遍历。

image.png

触发 trigger 函数,从 dep.subs (订阅者链表尾指针 往前遍历),触发 link.sub.notify()

image.png

遍历双向链表指针,将 effect 添加到普通订阅者的批处理队列头

image.png

示例 一个 effect 关联多个响应式数据

const color = ref(0);
const count = ref(0);

const handleClick = () => {
  count.value++;
};
effect(() => {
  console.log("effect1 color 值", color.value);
  console.log("effect1 count 值", count.value);
});

执行过程:

  1. 创建 color Ref 对象,初始化 this.dep = new Dep()
  2. 创建 count Ref 对象,初始化 this.dep = new Dep()
  3. 创建 effect 副作用
  4. 执行副作用回调函数
    • 读取 color.value ,触发依赖收集,创建 link 双向链表,关联effectcolorRef 的依赖链表
    • 读取 count.value ,触发依赖收集,创建 link1 双向链表,关联effectcountRef 的依赖链表
    • 后创建的双向链表Link(如link1) 会在effect的依赖链表尾部。

image.png

点击按钮,触发响应式通知

image.png

示例 手动控制

effect 返回一个 runner 函数,可用于手动触发 effect 的执行

<template>
  <div>
    <h2>手动控制</h2>
    <button @click="color++">点击</button>
    <button @click="handleClick">监听</button>
    <button @click="handleStop">取消监听</button>
  </div>
</template>
<script setup lang="ts">
import { effect, ref } from "vue";
const color = ref(0);

// 返回 runner 函数,用于手动触发监听
const runner = effect(() => {
  console.log("color 值", color.value);
});

// 手动执行不受依赖变化影响
const handleClick = () => {
  runner();
};

const handleStop = () => {
  runner.effect.stop();
};
</script>

image.png

执行 runner.effect.stop()

image.png

示例 停止 effect

runner.effect.stop() 不会阻止手动调用 runner() 函数,但会影响其行为:

  1. 手动调用仍然有效:即使调用了 stop(),仍然可以通过 runner() 手动执行副作用函数
  2. 依赖追踪被禁用:手动调用时不会再追踪依赖关系,也不会在依赖变化时自动重新执行
<template>
  <div>
    <button @click="color++">点击</button>
    <button @click="handleClick">监听</button>
    <button @click="handleClick2">手动执行</button>
  </div>
</template>
<script setup lang="ts">
import { effect, ref } from "vue";
const color = ref(0);

// 返回 runner 函数,用于手动触发监听
const runner = effect(() => {
  // color 变化时,会触发监听函数
  console.log("color 值", color.value);
});

// 手动执行不受依赖变化影响
// 即使取消监听,手动执行仍会触发
const handleClick = () => {
  runner();
};

const handleClick2 = () => {
  // 停止effect监听
  runner.effect.stop();
};
</script>

示例 effect、reactive

<template>
  <div>effect B</div>
</template>

<script lang="ts" setup>
import { effect, reactive } from "vue";

const obj = reactive({
  count: 0,
  name: "effectB",
  age: 20,
});

effect(() => {
  console.log("effectB 值", obj.count, obj.name, obj.age);
});
</script>

image.png

image.png

effect 读取 obj.count

image.png

image.png

effect 读取 obj.name

之前读取 obj.count时, target(即 reactive obj 对象) 已经缓存到 targetMap

给属性 count 设置 dep 依赖对象。

image.png

执行 dep.track

dep 是新创建的,因此需要创建 new Link(sub,dep) 双向依赖链表。

image.png

image.png

image.png

image.png

image.png

示例 effect computed

image.png

执行 count.value++

执行 computedEffect 的 notify(),再执行 computed.dep.notify()

继续执行 effect.notify()

image.png

示例 watch 批量更新

<template>
  <div>
    <button @click="handleClick">点击</button>
  </div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
const count = ref(0);

const handleClick = () => {
  // 批量更新,触发 watch 一次
  count.value++;
  count.value++;
  count.value++;
};

watch(count, (newVal, oldVal) => {
  console.log("watch count 值", newVal, oldVal);
});
</script>

第一次执行 count.vauel++

  • 获取 count.value
  • 执行 count.value 自增,触发 countRef 的 dep.trigger,然后执行 dep.notify,这里会进行批处理,startBatch,执行相应 effect.notify将副作用实例加入队列 batchedSub),结束处理 批处理结束 endBatch
  • endBatch 会执行 effect.tigger 方法,由于 watch effect实例有 调度器 scheduler,因此执行 effect.scheduler.
  • 调度器执行 queueJob 函数 任务 job 加入 队列 queue,标记 job.flag 为已加入队列。暂不执行。
  • 执行 queueFlush,调度一个微任务队列。

image.png

第二次 执行 count.vauel++

  • 获取 count.value
  • 执行 count.value 自增,触发 countRef 的 dep.trigger,然后执行 dep.notify,这里会进行批处理,startBatch,执行相应 effect.notify将副作用实例加入队列 batchedSub),结束处理 批处理结束 endBatch
  • endBatch 会执行 effect.tigger 方法,由于 watch effect实例有 调度器 scheduler,因此执行 effect.scheduler.
  • 调度器执行 queueJob 函数 任务 job 加入 队列 queue,由于job 已在队列中,不再重复加入。

第三次 执行 count.vauel++

  • 获取 count.value
  • 执行 count.value 自增,触发 countRef 的 dep.trigger,然后执行 dep.notify,这里会进行批处理,startBatch,执行相应 effect.notify将副作用实例加入队列 batchedSub),结束处理 批处理结束 endBatch
  • endBatch 会执行 effect.tigger 方法,由于 watch effect实例有 调度器 scheduler,因此执行 effect.scheduler.
  • 调度器执行 queueJob 函数 任务 job 加入 队列 queue,由于job 已在队列中,不再重复加入。
  • 微任务执行 flushJobs,按顺序执行任务队列,先执行主队列 queue,再执行后置任务队列 pendingPostFlushCbs,最后还会检查是否有新任务入队,有则递归处理。

image.png

image.png

image.png

枚举 SchedulerJobFlags

enum SchedulerJobFlags {
  QUEUED = 1 << 0, // 1 标记已经加入队列
  PRE = 1 << 1, // 2 标记任务在 DOM 更新前 执行
  ALLOW_RECURSE = 1 << 2, // 4 允许自身递归
  DISPOSED = 1 << 3, // 已被销毁或已取消
}

track 源码

reactive 对象读取属性,依赖收集。

function track(target: object, type: TrackOpTypes, key: unknown): void {
  // 前置条件:是否需要收集依赖
  // - shouldTrack:全局标志,控制是否允许追踪依赖。
  // - activeSub:当前正在执行的 ReactiveEffect 实例(副作用)
  if (shouldTrack && activeSub) {
    // targetMap:全局 Map,存储所有响应式对象的依赖映射
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      // 如果该 target 第一次被追踪,则创建一个新的 Map 并存入 targetMap。
      targetMap.set(target, (depsMap = new Map()))
    }

    // 获取或创建依赖对象
    let dep = depsMap.get(key)
    if (!dep) {
      depsMap.set(key, (dep = new Dep()))
      dep.map = depsMap // 存储依赖映射表,用于触发依赖时快速查找
      dep.key = key // 存储属性名,用于触发依赖时快速查找
    }
    if (__DEV__) {
      dep.track({
        target,
        type,
        key,
      })
    } else {
      // 收集依赖
      dep.track()
    }
  }
}

最后