Skip to content

02|track / trigger:订阅关系是在读取时建立的 ​

第三章:响应式系统纵览 / 前置:ref 与 reactive / 下一篇:Proxy 与 Reflect

“状态变了,为什么页面没更新?”要拆成三个问题:副作用运行时读了哪个 key?写入触发了哪个 key?触发后是立即运行还是进入调度队列?

运行上下文与四个角色 ​

本篇实验是 Vue 3.5+ 的纯 JavaScript,可保存为 .mjs 后在 Node 中运行,不需要组件和 DOM。

角色例子职责
targetstate 背后的原对象依赖图第一层身份
key'price'、'length'依赖图第二层入口
active effect渲染、computed 内部 effect、watchEffect读取时被登记的订阅者
schedulerVue 的更新队列合并与排序重新执行

概念上,订阅表可以画成 WeakMap<target, Map<key, Set<effect>>>。WeakMap 让已不可达的 target 不会仅因订阅表而常驻内存;每个 key 的 Set 避免同一个 effect 被重复登记。

track:只在活动 effect 中读取才订阅 ​

下面是教学伪代码,不是 Vue 源码的逐行复制:

ts
let activeEffect: (() => void) | undefined
const graph = new WeakMap<object, Map<PropertyKey, Set<() => void>>>()

function track(target: object, key: PropertyKey) {
  if (!activeEffect) return
  const depsMap = graph.get(target) ?? new Map()
  graph.set(target, depsMap)
  const effects = depsMap.get(key) ?? new Set()
  depsMap.set(key, effects)
  effects.add(activeEffect)
}

组件初始化渲染时,Vue 把组件更新函数设为活动 effect;模板读取 state.price,代理 getter 调用 track(rawState, 'price')。在普通事件函数中单独读取 state.price 没有活动 effect,因此不会凭空增加一个长期订阅。

trigger:写入只通知相关集合 ​

ts
function trigger(target: object, key: PropertyKey) {
  const effects = graph.get(target)?.get(key)
  for (const effect of effects ?? []) effect()
}

真实实现还会处理值是否真的变化、数组长度、集合迭代、effect 递归、computed 优先失效和调度队列。核心判断仍是:写入的操作能否找到此前读取形成的那条边。

source → derived → effect 的状态推进 ​

ts
import { computed, ref, watchEffect } from 'vue'

const net = ref(100)                         // source
const taxRate = ref(0.2)                     // source
const gross = computed(() => net.value * (1 + taxRate.value)) // derived

watchEffect(() => {
  console.log(`应付 ${gross.value}`)          // effect
})
时刻sourcederivedeffect
首次运行net=100、rate=0.2 被读取gross=120 并缓存打印 应付 120
net=200 写入net 触发订阅者gross 被标记失效被排入更新队列
队列刷新保持 200、0.2被读取时重算为 240打印 应付 240

trigger 不等于“立刻把所有东西重算一遍”。computed 先失效,消费者真正读取它时再重算;普通 watcher 默认也会批处理。

条件分支会重建依赖 ​

js
import { nextTick, reactive, watchEffect } from 'vue'

const state = reactive({ useMetric: true, celsius: 20, fahrenheit: 68 })
const seen = []

watchEffect(() => {
  seen.push(state.useMetric ? `${state.celsius}°C` : `${state.fahrenheit}°F`)
})

state.fahrenheit = 70
await nextTick()       // 当前分支没读 fahrenheit,不重新运行
state.useMetric = false
await nextTick()       // 分支切换,本轮开始订阅 fahrenheit
state.celsius = 21
await nextTick()       // 旧分支依赖已清除,不重新运行
state.fahrenheit = 71
await nextTick()       // 当前依赖触发

console.log(seen) // ['20°C', '70°F', '71°F']

从依赖合同看,每轮结束后应移除本轮不再读取的旧依赖,并保留本轮读到的依赖。Vue3.5内部可以通过版本标记和链表维护这些关系,不必真的先清空整个集合再从零重建。否则条件分支切换几次后,effect 会永远订阅所有历史分支。

“没触发”和“还没刷新”不是一回事 ​

  • 写回同一个值时,Vue 通常根据 Object.is 判断无变化,不触发消费者。
  • 默认 watcher / 渲染 effect 进入队列;同步写三次常只执行一次最终更新。
  • flush: 'sync' 才跳过批处理,但它改变的是调度,不会补回缺失的依赖。
  • nextTick() 等待当前 Vue 更新队列刷新;它也不会让一个从未 track 的值突然可追踪。

排错:按依赖边逐段验 ​

  1. 在 effect 内确认目标属性确实被读取;读取容器不等于读取所有未来字段。
  2. 确认写入经过 ref setter 或 reactive proxy;直接改 raw 对象不会经过 trigger。
  3. 检查是否写回相同值,或当前条件分支已不再订阅该 key。
  4. await nextTick() 后再判断批处理 effect 是否执行。
  5. 开发环境使用 onTrack / onTrigger 收集 target、key、type,而不是先加 deep: true。

练习:先预测,再验证动态依赖 ​

给定 source { mode: 'name', name: 'Ada', score: 90 },写一个 watchEffect:mode === 'name' 时输出 name,否则输出 score。

  • 依次修改 score、mode、name、score,每步都 await nextTick()。
  • 修改前先写下你预测的输出数组。
  • 最终用严格断言证明:未被当前分支读取的字段不会触发;切换分支后旧依赖被清除。
  • 再增加一次“写回同值”,证明它不会产生新输出。

查看第 02 题参考答案。

用心记录每一步成长