Vue.js依赖收集与追踪机制深度剖析
改一个数据,Vue 怎么知道该更新谁。Dep.target 是个全局变量配一个栈,Watcher 每次求值都重建一遍依赖表,computed 的缓存只是一个 dirty 标记。最后对照 Vue 3 的 targetMap 和 effect。
依赖是渲染时读出来的,不再读的那份会被删掉
渲染 watcher 身上挂着一张依赖表 deps。它不是谁声明的,是渲染函数执行时一路读出来的,而且每渲染一次就重建一次。下面这段在完整版 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 ');挂载后 1 3,4改 b 1 3,4改 a 2 3,4show 切 false 3 3,5再改 a 3 3,5b 改了,render 次数纹丝不动。渲染函数从来没读到过它,它的订阅者名单里就没有这个 watcher —— 哪怕 b 明明是 data 里声明的响应式数据。
show 切成 false 之后,依赖表从 3,4 变成了 3,5:a 那个 dep 被摘掉,b 那个补上。所以最后一行改 a 一点动静都没有,而它上一秒还是这个组件的依赖。dep 的 id 是全局自增的,具体数字取决于此前建过多少个 Dep,值得看的是第二个位置换了人。
三行输出划出了这篇要讲的三件事:谁在听(Dep.target)、怎么记住谁在听(addDep 和 addSub)、以及读完之后怎么把过期的关系断掉(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 核心里最短的文件之一:
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 自己并不知道,它得去问一个外部变量。这个变量就定义在同一个文件的末尾:
// The current target watcher being evaluated.// This is globally unique because only one watcher// can be evaluated at a time.Dep.target = nullconst 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.target 是 null 的时候,depend() 整个是空操作。在 created 里读十遍 this.list,或者在事件回调、setTimeout 里读,都不会建立任何依赖 —— 那些时刻没有任何 watcher 在求值。依赖只在 watcher 的 getter 执行期间才可能产生,这是一条比「响应式数据会自动追踪」准确得多的说法。
那既然同一时刻只有一个,为什么还要 targetStack?因为「同一时刻只有一个」在时间上成立,在调用栈上不成立 —— 一个 watcher 的求值过程里会嵌套启动另一个 watcher 的求值。这件事可以直接量出来:
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'));父组件 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 顶上去,等于临时关掉依赖收集。生命周期钩子和 $watch 的 immediate 回调都用了这一手。
一次求值重建一张依赖表,旧表用来做减法
Watcher 那边和收集有关的是三个方法,都在 src/core/observer/watcher.js。先看求值这一趟:
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}整个收集过程被夹在 pushTarget 和 popTarget 中间,而且收尾放在 finally 里 —— getter 抛异常也照样还原现场。一个组件的 render 函数报错之后,剩下的组件还能正常收集依赖,靠的就是这个 finally。
cleanupDeps() 排在 popTarget() 后面,两件事的分工是:前者管「谁在听」这个全局状态,后者管这一个 watcher 自己那张表。表怎么攒、怎么减,是下面两个方法:
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-show 和 v-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 类,没有 RenderWatcher、ComputedWatcher 这种子类,也没有继承。三种 watcher 的差别只在构造时传的 options 和创建它的地方:
| 在哪创建 | options | 数据变了做什么 | |
|---|---|---|---|
| 渲染 watcher | lifecycle.js 的 mountComponent |
{ before },第五个参数 isRenderWatcher 为 true |
进调度队列,下个 tick 重渲染 |
| computed watcher | state.js 的 initComputed |
{ lazy: true } |
只把 dirty 标成 true,不进队列 |
| 用户 watcher | state.js 的 $watch |
{ user: true },加上你写的 deep / sync / immediate |
默认进队列,sync 就地执行 |
分岔全写在 update() 这九行里:
update () { if (this.lazy) { this.dirty = true } else if (this.sync) { this.run() } else { queueWatcher(this) }}lazy 排在最前面,意味着一个既 lazy 又 sync 的 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 里包出来的这一个:
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(),把自己的依赖原样转交出去。
结果可以直接打印出来:
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,10total 的依赖 = 10,9subtotal 的依赖 = 9,10subtotal 有自己的 dep 吗? undefined连读 5 次 total,subtotal 求值次数 = 1刚改完 price,求值次数 = 1 dirty = true三个 watcher 的依赖表装的是同一对 dep,也就是 price 和 qty。没有任何一个 watcher 订阅了「subtotal 这个 computed」,因为 computed 不是可订阅的东西 —— 第四行印出来的 undefined 就是证据,computed watcher 身上没有 dep 字段。total 读 subtotal 时,Dep.target 正是 total 自己的 watcher,于是 subtotal.depend() 把 price 和 qty 转交给了它;再往外一层,渲染 watcher 又拿到同样两个。链条有多长都一样,最后所有人都直接订阅在最底下那两个 dep 上。
total 那一行的顺序是反的(10,9),另外两行是 9,10。因为 depend() 是 let i = this.deps.length; while (i--) 倒着遍历的。这个细节没什么用,但它是「转交」确实发生过的一个指纹。
后两行是缓存本身:连读五次只求值一次;改完 price 之后 dirty 立刻是 true,求值次数却还停在 1 —— 惰性就体现在这里,标记和重算是分开的两件事。
这套设计有一个说不上是 bug、但确实是局限的地方:dirty 由底层数据无条件置位,中间那层算出来的值有没有变根本不参与判断。
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 变成……还是 trueawait Vue.nextTick();console.log(renders, vm.isCheap, vm._watcher.deps.length); // 2 true 1isCheap 前后都是 true,组件还是重渲染了一遍。回头看依赖表就明白了:渲染 watcher 直接订阅的是 price(deps.length 是 1,就它一个),price 一变它就被通知,中间那个 computed 没有任何机会说一句「我的值没变,别往下传了」。Vue 3.4 那次响应式重构针对的正是这件事,最后一节会说到。
还有一条决定你该不该用 computed 的规矩:求值时机由 dirty 和「有没有人读它」共同决定,不是数据一变就跑。所以 computed 的 getter 里放副作用(发请求、改别的状态、打点)是靠不住的 —— 没人读它的那几轮它根本不会执行,而一旦有两个地方同时读,你又说不清它到底算过几次。要「数据一变就干点什么」,那是 watch 的活。
另外两条边界:服务端渲染时 defineComputed 里的 shouldCache 为 false,压根不建 computed watcher,属性退化成每读一次调一次 getter —— 一次性渲染,缓存的维护成本大过收益。还有循环依赖,两个 computed 互相引用时 Vue 2 没有任何检测,实测直接 RangeError: Maximum call stack size exceeded,栈溢出在读它的那一刻发生。
deep 的代价是把整棵对象读一遍
$watch('user', cb) 不加 deep 时监听不到 user.name 的变化,原因在依赖表里看得很直白:
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 这一步就停了,profile 和 city 的 getter 根本没被碰过。
deep 干的事就是把整棵对象读一遍,实现在 src/core/observer/traverse.js:
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 个,加 name、profile 两个键的 dep、profile 对象的 Observer dep、city 的 dep。对象越大越贵,而且回调触发后重新求值还要再走一遍。deep 不适合挂在大数组或者深层配置对象上,真需要的话改成监听一个具体路径,或者用函数形式的 getter 只读你关心的那几个字段。
去重按的是 val.__ob__.dep.id,不是对象本身。seenObjects 是个模块级单例,一次 traverse 结束就清空。所以循环引用不会让它转死 —— 但这份保护只覆盖已经被观测过的对象,没有 __ob__ 的普通嵌套对象不参与去重。
输出后半段是另外两个选项。sync 走 update() 的第二条分支,就地 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 把这张表整个挪到了外面:
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 挑得有讲究 —— 对象不再被引用时,它那张依赖表跟着一起回收,不用谁去清。track 和 trigger 对应的就是 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 实现详解。