Vue.js依赖收集与追踪机制深度剖析

改一个数据,Vue 怎么知道该更新谁。Dep.target 是个全局变量配一个栈,Watcher 每次求值都重建一遍依赖表,computed 的缓存只是一个 dirty 标记。最后对照 Vue 3 的 targetMap 和 effect。

位置
第 04 篇 / 共 12 篇
预计
20 分钟

依赖是渲染时读出来的,不再读的那份会被删掉

渲染 watcher 身上挂着一张依赖表 deps。它不是谁声明的,是渲染函数执行时一路读出来的,而且每渲染一次就重建一次。下面这段在完整版 Vue 2 的控制台里可以直接跑:

浏览器控制台 · 完整版 Vue 2
let renders = 0;
const vm = new Vue({
data: { show: true, a: 1, b: 1 },
render(h) { renders++; return h('p', [String(this.show ? this.a : this.b)]); }
}).$mount(document.createElement('div'));
const deps = () => vm._watcher.deps.map(d => d.id).join(',');
const step = label => console.log(label, renders, deps());
step('挂载后 ');
vm.b = 2; await Vue.nextTick(); step('改 b ');
vm.a = 2; await Vue.nextTick(); step('改 a ');
vm.show = false; await Vue.nextTick(); step('show 切 false ');
vm.a = 3; await Vue.nextTick(); step('再改 a ');
实测输出(左边是 render 执行次数,右边是依赖的 dep id)
挂载后 1 3,4
改 b 1 3,4
改 a 2 3,4
show 切 false 3 3,5
再改 a 3 3,5

b 改了,render 次数纹丝不动。渲染函数从来没读到过它,它的订阅者名单里就没有这个 watcher —— 哪怕 b 明明是 data 里声明的响应式数据。

show 切成 false 之后,依赖表从 3,4 变成了 3,5a 那个 dep 被摘掉,b 那个补上。所以最后一行改 a 一点动静都没有,而它上一秒还是这个组件的依赖。dep 的 id 是全局自增的,具体数字取决于此前建过多少个 Dep,值得看的是第二个位置换了人。

三行输出划出了这篇要讲的三件事:谁在听(Dep.target)、怎么记住谁在听(addDepaddSub)、以及读完之后怎么把过期的关系断掉(cleanupDeps)。数据本身怎么变成可观测的 —— Object.defineProperty 装 getter 和 setter 的那一层 —— 是响应式原理那一篇的事,这里从 getter 已经被触发的那一刻接着讲。

graph TB
A[响应式数据] --> B[依赖收集]
B --> C[Watcher 1组件A渲染]
B --> D[Watcher 2组件B渲染]
B --> E[Watcher 3计算属性]
F[数据变化] --> G[通知依赖]
G --> C
G --> D
G --> E

图上一份数据挂着三个 watcher,两条线分别是收集和通知。要点在于左右两条线用的是同一份名单:收集那一侧往名单里加,通知那一侧照着名单挨个叫。名单存在哪、什么时候加、什么时候减,是下面三节。

Dep.target 是一个全局变量,配一个栈

Dep 的全部实现在 src/core/observer/dep.js,连注释六十来行,是 Vue 2 核心里最短的文件之一:

Vue 2 · Dep 类(src/core/observer/dep.js,去掉 Flow 类型标注)
let uid = 0
export default class Dep {
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub) { this.subs.push(sub) }
removeSub (sub) { remove(this.subs, sub) }
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice() // 先把订阅者名单固定住
// 省略:开发环境且 config.async 为 false 时,这里补一次按 id 的排序
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}

一个 Dep 就是一份订阅者名单加一个自增 id,没有别的字段。depend() 是加人,notify() 是叫人,两个方法各七八行,整个类没有任何状态机。

有意思的是 depend() 里那个 if:加谁进名单,Dep 自己并不知道,它得去问一个外部变量。这个变量就定义在同一个文件的末尾:

Vue 2 · Dep.target 和它的栈(同一个文件)
// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []
export function pushTarget (target) {
targetStack.push(target)
Dep.target = target
}
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}

源码注释把理由写得很干脆:同一时刻只可能有一个 watcher 在求值,所以它可以是全局唯一的。

为什么非得是全局变量?因为 getter 是 Object.defineProperty 装上去的,签名里没有地方放「谁在读我」这个参数。dep.depend() 被调用的那一刻,它唯一能问的就是一个全局变量。这是 Vue 2 这套设计的起点,也是它后来所有别扭的来源。

反过来看,depend() 里那个 if 还有个直接后果:Dep.targetnull 的时候,depend() 整个是空操作。在 created 里读十遍 this.list,或者在事件回调、setTimeout 里读,都不会建立任何依赖 —— 那些时刻没有任何 watcher 在求值。依赖只在 watcher 的 getter 执行期间才可能产生,这是一条比「响应式数据会自动追踪」准确得多的说法。

那既然同一时刻只有一个,为什么还要 targetStack?因为「同一时刻只有一个」在时间上成立,在调用栈上不成立 —— 一个 watcher 的求值过程里会嵌套启动另一个 watcher 的求值。这件事可以直接量出来:

浏览器控制台 · 数一数栈上有几层 Watcher.get
Error.stackTraceLimit = 200;
const depth = () => new Error().stack.split('\n').filter(l => /Watcher\.get/.test(l)).length;
const Child = { render(h) { console.log('子组件 render,栈上的 Watcher.get =', depth()); return h('i'); } };
new Vue({
components: { Child },
render(h) { console.log('父组件 render,栈上的 Watcher.get =', depth()); return h('div', [h(Child)]); }
}).$mount(document.createElement('div'));
实测输出(V8 的栈帧名,Chrome 和 Node 都是这个)
父组件 render,栈上的 Watcher.get = 1
子组件 render,栈上的 Watcher.get = 2

子组件渲染的时候,父组件那次 Watcher.get() 还没返回。原因在组件系统那边:子组件实例不是父组件 render 时创建的,是 patch 走到占位 VNode、调用 data.hook.init 时才创建的;而 patch 发生在 updateComponent = () => vm._update(vm._render()) 里面,这整个函数就是父组件渲染 watcher 的 getter。于是子组件的 pushTarget 压在了父组件的上面。

栈的价值就在子组件 popTarget() 的那一刻:Dep.target 要还原成父组件那个 watcher,而不是清成 null。清成 null 的话,嵌套一结束,外层就再也收不到任何依赖了 —— computed 尤其致命,它的求值走的是同一套 push/pop,而 pop 之后紧接着还要读一次 Dep.target,那一段代码在后面 computed 那一节。

sequenceDiagram
participant Component as 组件渲染
participant Watcher as Watcher
participant Getter as 响应式Getter
participant Dep as Dep
participant Target as Dep.target
Component->>Watcher: new Watcher(render)
Watcher->>Target: pushTarget(this)
Watcher->>Component: 执行render函数
Component->>Getter: 访问this.message
Getter->>Target: 检查Dep.target
Getter->>Dep: dep.depend()
Dep->>Watcher: watcher.addDep(this)
Watcher->>Dep: dep.addSub(this)
Note over Dep,Watcher: 建立依赖关系
Component->>Getter: 访问this.user.name
Note over Getter,Dep: 重复收集过程
Watcher->>Target: popTarget()
Note over Component: 依赖收集完成

图上这一趟是一次完整的收集:pushTarget 把自己顶上去,跑 getter,每读到一个响应式属性就走一遍 depend → addDep → addSub,读完 popTarget 还原现场。注意箭头是双向的 —— dep 记住 watcher,watcher 也记住 dep,两边各存一份。为什么要存两份,是下一节的事。

顺带一个小用法:pushTarget() 不带参数会把 undefined 顶上去,等于临时关掉依赖收集。生命周期钩子和 $watchimmediate 回调都用了这一手。

一次求值重建一张依赖表,旧表用来做减法

Watcher 那边和收集有关的是三个方法,都在 src/core/observer/watcher.js。先看求值这一趟:

Vue 2 · Watcher.prototype.get
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
// 省略:用户 watcher 走 handleError,其余直接抛
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value)
}
popTarget()
this.cleanupDeps()
}
return value
}

整个收集过程被夹在 pushTargetpopTarget 中间,而且收尾放在 finally 里 —— getter 抛异常也照样还原现场。一个组件的 render 函数报错之后,剩下的组件还能正常收集依赖,靠的就是这个 finally

cleanupDeps() 排在 popTarget() 后面,两件事的分工是:前者管「谁在听」这个全局状态,后者管这一个 watcher 自己那张表。表怎么攒、怎么减,是下面两个方法:

Vue 2 · Watcher 的 addDep 与 cleanupDeps
addDep (dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}

四个字段分成两组:deps / depIds 是上一轮的依赖表,newDeps / newDepIds 是这一轮正在攒的。

同一份东西存两遍,一个数组一个 Set,是分工不是冗余。数组用来遍历和保序,cleanupDeps 要倒着走一遍;Set 只用来回答「这个 id 在不在」,而 addDep 每读到一个响应式属性就要问一次,问的次数和这一轮读到的属性数一样多。放在这个位置,数组的 indexOf 是不划算的 —— 多存一份 id 换来的是每次查表都不用扫数组。

addDep 里那两层判断各挡一种重复。外层查 newDepIds,挡的是同一轮里的重复读 —— 渲染函数里把 this.n 读三次是很常见的事,实测下来 deps.length 是 1,那个 dep 的 subs.length 也是 1。内层查 depIds,挡的是跨轮的重复 —— 上一轮已经订阅过的,这一轮不必再 addSub 一次,否则名单里会堆出同一个 watcher 的十几份副本。

cleanupDeps 是这套机制里最容易被略过、却正好解释了开头那个实验的一段。它倒着遍历上一轮的 deps,凡是这一轮没再读到的,就调 dep.removeSub(this) 把自己从那份名单里摘掉。接着两组容器互换:新的一份变成 deps,旧的那份清空留给下一轮接着用。不新建 Set 和数组,是因为这是每次重渲染都要走一遍的热路径。

开头 3,4 变成 3,5 就是在这里换的。这条设计有明确的代价和收益:代价是每次重渲染都要把整张依赖表重建一遍并做一次差集,收益是不再读到的数据立刻断开,v-if 藏掉的那个分支不会再把组件叫醒。

也正因如此,「一个组件的依赖」在 Vue 2 里永远等于「它上一次渲染读到的东西」,不多不少。v-showv-if 的区别在这儿看得特别清楚:v-show 只是给元素挂了 display: none,插值照读,依赖照收,数据一变照样重渲染一遍;v-if 那一支根本没进渲染函数,依赖当场就被减掉了。

组件销毁和手动 unwatch 走的是同一个出口 teardown():把自己从每一个 dep.subs 里摘掉,从 vm._watchers 里摘掉,再把 active 置成 false(所以已经排进队列的那次 run() 会变成空转)。实测 $watch 返回的函数调用之后,dep.subs 从 1 变 0,vm._watchers 也从 1 变 0。

一个 Watcher 类,三种用法,区别全在 options

watcher.js 从头到尾只有一个 Watcher 类,没有 RenderWatcherComputedWatcher 这种子类,也没有继承。三种 watcher 的差别只在构造时传的 options 和创建它的地方:

在哪创建 options 数据变了做什么
渲染 watcher lifecycle.jsmountComponent { before },第五个参数 isRenderWatchertrue 进调度队列,下个 tick 重渲染
computed watcher state.jsinitComputed { lazy: true } 只把 dirty 标成 true,不进队列
用户 watcher state.js$watch { user: true },加上你写的 deep / sync / immediate 默认进队列,sync 就地执行

分岔全写在 update() 这九行里:

Vue 2 · Watcher.prototype.update
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}

lazy 排在最前面,意味着一个既 lazysync 的 watcher 也只会置一下 dirty。computed watcher 因此从来不进调度队列 —— 它不是「被通知了就去算」,是「被通知了就记一笔,等有人问再算」。

渲染 watcher 那一行有个容易看反的地方:它的 cb 传的是 noop。渲染是 getter 的副作用,不是回调干的活 —— updateComponent 里那句 vm._update(vm._render()) 本身就是 getter。传给它的 before 才是 beforeUpdate 钩子的触发点,由调度器在 run() 之前调。

派发那一侧只有一句 dep.notify(),做的事上面已经贴过:复制一份名单,挨个 update()。复制不是保守,是必需的 —— watcher 的 run() 会重建依赖表,而 cleanupDeps 里的 removeSub 会让正在遍历的那个数组当场变短。

至于 queueWatcher 之后队列怎么去重、怎么排序、什么时候 flush,归异步更新和 nextTick 那一篇。这里只提一条跟收集有关的:队列按 watcher.id 升序跑,而 id 是自增的,于是「父组件先于子组件」「用户 watcher 先于渲染 watcher」这两条顺序全是「谁先创建谁先跑」的副产品,源码注释里为这次排序列了三条理由,前两条都建立在这个自增上。

computed 的缓存就是一个 dirty 标记,它自己没有订阅者

computed 属性的 getter 不是你写的那个函数,是 state.js 里包出来的这一个:

Vue 2 · createComputedGetter(src/core/instance/state.js)
function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}

两步,各解决一个问题。

第一步是缓存:dirty 为真才 evaluate(),而 evaluate() 就是 this.value = this.get() 加一句 this.dirty = false。所谓缓存,既不是 Map 也不是 WeakMap,就是一个布尔值加一个 value 字段,都挂在那个 lazy watcher 上。失效同样简单:底层数据变了,notify 叫到这个 watcher,update() 走第一条分支把 dirty 置回 true,值不重算,等下一次真的有人读它才算。

第二步是转交,也是全篇最绕的一句。pushTarget / popTarget 已经在 evaluate() 里成对走完了,此刻的 Dep.target 已经还原成外面那个 watcher(通常是渲染 watcher)。watcher.depend() 做的不是「让外面那个订阅我」—— computed watcher 压根没有可供订阅的东西 —— 而是遍历自己收集到的每一个 dep,各调一次 depend(),把自己的依赖原样转交出去。

结果可以直接打印出来:

浏览器控制台 · 三个 watcher 的依赖表
let subCalls = 0;
const vm = new Vue({
data: { price: 10, qty: 2 },
computed: {
subtotal() { subCalls++; return this.price * this.qty; },
total() { return this.subtotal * 2; }
},
render(h) { return h('p', [String(this.total)]); }
}).$mount(document.createElement('div'));
const ids = w => w.deps.map(d => d.id).join(',');
console.log('渲染 watcher 的依赖 =', ids(vm._watcher));
console.log('total 的依赖 =', ids(vm._computedWatchers.total));
console.log('subtotal 的依赖 =', ids(vm._computedWatchers.subtotal));
console.log('subtotal 有自己的 dep 吗?', vm._computedWatchers.subtotal.dep);
for (let i = 0; i < 5; i++) vm.total;
console.log('连读 5 次 total,subtotal 求值次数 =', subCalls);
vm.price = 20;
console.log('刚改完 price,求值次数 =', subCalls, ' dirty =', vm._computedWatchers.subtotal.dirty);
实测输出
渲染 watcher 的依赖 = 9,10
total 的依赖 = 10,9
subtotal 的依赖 = 9,10
subtotal 有自己的 dep 吗? undefined
连读 5 次 total,subtotal 求值次数 = 1
刚改完 price,求值次数 = 1 dirty = true

三个 watcher 的依赖表装的是同一对 dep,也就是 priceqty。没有任何一个 watcher 订阅了「subtotal 这个 computed」,因为 computed 不是可订阅的东西 —— 第四行印出来的 undefined 就是证据,computed watcher 身上没有 dep 字段。totalsubtotal 时,Dep.target 正是 total 自己的 watcher,于是 subtotal.depend()priceqty 转交给了它;再往外一层,渲染 watcher 又拿到同样两个。链条有多长都一样,最后所有人都直接订阅在最底下那两个 dep 上。

total 那一行的顺序是反的(10,9),另外两行是 9,10。因为 depend()let i = this.deps.length; while (i--) 倒着遍历的。这个细节没什么用,但它是「转交」确实发生过的一个指纹。

后两行是缓存本身:连读五次只求值一次;改完 price 之后 dirty 立刻是 true,求值次数却还停在 1 —— 惰性就体现在这里,标记和重算是分开的两件事。

这套设计有一个说不上是 bug、但确实是局限的地方:dirty 由底层数据无条件置位,中间那层算出来的值有没有变根本不参与判断。

浏览器控制台 · computed 的值没变,组件照样重渲染
let renders = 0;
const vm = new Vue({
data: { price: 10 },
computed: { isCheap() { return this.price < 100; } },
render(h) { renders++; return h('p', [String(this.isCheap)]); }
}).$mount(document.createElement('div'));
vm.price = 20; // isCheap 从 true 变成……还是 true
await Vue.nextTick();
console.log(renders, vm.isCheap, vm._watcher.deps.length); // 2 true 1

isCheap 前后都是 true,组件还是重渲染了一遍。回头看依赖表就明白了:渲染 watcher 直接订阅的是 pricedeps.length 是 1,就它一个),price 一变它就被通知,中间那个 computed 没有任何机会说一句「我的值没变,别往下传了」。Vue 3.4 那次响应式重构针对的正是这件事,最后一节会说到。

还有一条决定你该不该用 computed 的规矩:求值时机由 dirty 和「有没有人读它」共同决定,不是数据一变就跑。所以 computed 的 getter 里放副作用(发请求、改别的状态、打点)是靠不住的 —— 没人读它的那几轮它根本不会执行,而一旦有两个地方同时读,你又说不清它到底算过几次。要「数据一变就干点什么」,那是 watch 的活。

另外两条边界:服务端渲染时 defineComputed 里的 shouldCachefalse,压根不建 computed watcher,属性退化成每读一次调一次 getter —— 一次性渲染,缓存的维护成本大过收益。还有循环依赖,两个 computed 互相引用时 Vue 2 没有任何检测,实测直接 RangeError: Maximum call stack size exceeded,栈溢出在读它的那一刻发生。

deep 的代价是把整棵对象读一遍

$watch('user', cb) 不加 deep 时监听不到 user.name 的变化,原因在依赖表里看得很直白:

浏览器控制台 · deep 收了几个依赖
const vm = new Vue({ data: { user: { name: 'A', profile: { city: 'X' } }, n: 1 } });
vm.$watch('user', () => console.log(' [不带 deep] 触发'));
vm.$watch('user', () => console.log(' [deep] 触发'), { deep: true });
console.log('不带 deep 的 watcher 依赖数 =', vm._watchers[0].deps.length);
console.log('带 deep 的 watcher 依赖数 =', vm._watchers[1].deps.length);
console.log('改 user.profile.city');
vm.user.profile.city = 'Y';
await Vue.nextTick();
vm.$watch('n', () => console.log(' [sync] 回调跑了'), { sync: true });
vm.$watch('n', () => console.log(' [默认] 回调跑了'));
console.log('n = 2 这一行之前');
vm.n = 2;
console.log('n = 2 这一行之后');
实测输出
不带 deep 的 watcher 依赖数 = 2
带 deep 的 watcher 依赖数 = 6
改 user.profile.city
[deep] 触发
n = 2 这一行之前
[sync] 回调跑了
n = 2 这一行之后
[默认] 回调跑了

不带 deep 的那个只收到 2 个依赖:user 这个键的 dep,加上 user 指向的那个对象自己的 Observer dep。字符串路径的 getter 是 parsePath('user') 生成的,读到 vm.user 这一步就停了,profilecity 的 getter 根本没被碰过。

deep 干的事就是把整棵对象读一遍,实现在 src/core/observer/traverse.js

Vue 2 · traverse.js(去掉 Flow 类型标注)
const seenObjects = new Set()
export function traverse (val) {
_traverse(val, seenObjects)
seenObjects.clear()
}
function _traverse (val, seen) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
return
}
if (val.__ob__) {
const depId = val.__ob__.dep.id
if (seen.has(depId)) {
return
}
seen.add(depId)
}
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen)
}
}

它没有做任何登记动作,只是把每个属性都读一遍 —— 源码注释里的说法是 evoke all converted getters。收集是读的副作用,所以 traverse 被放在 get()finally 里、popTarget() 之前:那时 Dep.target 还是自己,递归读到的每一个属性都算它的依赖。这个顺序反了整个 deep 就失效了。

一个两层的小对象收上来 6 个依赖:前面那 2 个,加 nameprofile 两个键的 dep、profile 对象的 Observer dep、city 的 dep。对象越大越贵,而且回调触发后重新求值还要再走一遍。deep 不适合挂在大数组或者深层配置对象上,真需要的话改成监听一个具体路径,或者用函数形式的 getter 只读你关心的那几个字段。

去重按的是 val.__ob__.dep.id,不是对象本身。seenObjects 是个模块级单例,一次 traverse 结束就清空。所以循环引用不会让它转死 —— 但这份保护只覆盖已经被观测过的对象,没有 __ob__ 的普通嵌套对象不参与去重。

输出后半段是另外两个选项。syncupdate() 的第二条分支,就地 run(),所以 [sync] 回调跑了 印在两句 console.log 中间;默认那个排进队列,等到本轮同步代码跑完才执行。sync 换来的是即时,换掉的是去重 —— 同一轮里改十次,回调就跑十次。

immediate 不是 watcher 的选项,是 $watch 里的一段额外代码:watcher 建完立刻调一次回调,oldValue 那个参数是 undefined。这次调用被 pushTarget() / popTarget() 夹着,不带参数的 pushTarget 顶上去的是 undefined,所以回调里读到的响应式数据不会被误算成这个 watcher 的依赖。

Vue 3 换掉的是存法,没换那个动作

Vue 2 把 dep 藏在 defineReactive 的闭包里,一个属性一个,对象本身再挂一个在 __ob__.dep 上。Proxy 拦的是整个对象,没有一个闭包给你藏东西,所以 Vue 3 把这张表整个挪到了外面:

Vue 3.5 · track(packages/reactivity/src/dep.ts)
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<object, KeyToDepMap>()
export function track(target: object, type: TrackOpTypes, key: unknown): void {
if (shouldTrack && activeSub) {
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Dep()))
dep.track()
}
}

三层:对象 → 键 → 订阅者集合。WeakMap 挑得有讲究 —— 对象不再被引用时,它那张依赖表跟着一起回收,不用谁去清。tracktrigger 对应的就是 Vue 2 的 dep.depend()dep.notify(),一读一写,位置和作用完全对得上。

activeSub(3.5 之前叫 activeEffect)就是 Dep.target。还原现场这件事,Vue 3 改过好几轮写法:

版本 怎么还原上一个 effect
3.0 ~ 3.2.29 effectStack 数组,和 Vue 2 的 targetStack 一个样
3.2.30 ~ 3.3 存在当前 effect 的 parent 字段上,栈拆成了链
3.4 起 run() 里一个局部变量存旧值,finally 里放回去

到 3.4 那一行,显式的栈没有了 —— 局部变量加 finally 本来就是 JS 调用栈,等于把 Vue 2 手工维护的那个栈还给了语言。四种写法干的是同一件事:进去之前记住上一个,出来的时候放回去。

真正换掉行为的是 computed 的脏判断。Vue 2 只有一个布尔 dirty,也就有了上一节那个「值没变照样重渲染」。3.4(PR #5912)把它换成 DirtyLevels 枚举加版本号,官方博客给这次改动的说法是提升 computed 的重算效率;3.5(PR #10397)又改成双向链表加一个全局版本号 globalVersion,博客给的数字是内存下降 56%、行为不变。判断变成了三层:EffectFlags.DIRTY 这一位、全局版本号对不对得上、最后才逐个比 link 上的版本号,而 computed 只有在算出来的值真的变了才把自己的 dep.version 加一 —— 那个「我的值没变,别往下传」的机会,Vue 2 缺的正是它。

这一段版本敏感得厉害:targetMap 的文件位置在 3.3、3.4、3.5 各换过一次,Dep 从带 w/n 位标记的 Set 变成 Map 再变成类。3.6 还在 RC,reactivity 又重写了一轮。翻到跟你手上版本对不上的源码,先看 tag 再看代码。

Vue 2 这边剩下的问题在队列那一侧:queueWatcher 收到通知之后,谁先跑、重复的怎么合、这一批什么时候真的落到 DOM 上。那是调度器和 nextTick 的地盘。

这篇是 Vue.js 内部机制深度解析的第 4 篇。前一篇是 Vue.js 响应式系统核心原理,后一篇是 Virtual DOM 实现详解