持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情
一、前情回顾 & 背景
上一篇篇小作文讲述了 Vue 如何组织队列更新的,主要依托于下面几个方法:
Watcher.prototype.update,当响应式数据发生变化,其对应的dep.notify执行,watcher.update会调用queueWatcher;queueWatcher负责把watcher实例加入到待求值的watcher队列queue中,添加到队列需要根据当前队列是否处于刷新状态做不同的处理;queueWatcher还会调用nextTick方法,传入消耗queue队列的flushSchedulerQueue方法;nextTick会把flushSchedulerQueue包装然后放到callbacks队列,nextTick另一个重要任务就是把消耗callbacks队列的flushCallback放入到下一个事件循环(或者下一个事件循环的开头,即微任务);
总结起来就两件事:
- 响应式数据发生变化,将依赖它的
watcher放到queue队列; nextTick把消耗queue的flushSchedulerQueue放到callbacks队列,同时把消耗callbacks队列的flushCallbacks方法放到下个事件循环(或事件环的开头)
听完这些感觉已经很明白了,但是现在有两个具体的问题需要分析一番:
-
如果在一个用户
watcher中修改某一个渲染 watcher依赖的响应式数据,这个渲染 watcher会被多次添加到queue吗? -
在一个
tick中多次修改同一个被渲染 watcher依赖的响应式数据(或者修改多个不同的响应式数据)那么渲染watcher会被多次添加到queue队列中吗?
很多人在看 Vue 面试题的时候都看到过一句话:Vue 会合并当前事件循环中的所有更新,只触发一次依赖它的 watcher;官网上也这个场景有一段描述,## 异步更新队列;
所以答案很显然:是不会多次添加的,今天我们就来掰扯掰扯为什么不会?
二、用户 watcher 修改响应式数据
先来看一段示例代码:
这个示例代码是想表达:
渲染 watcher依赖了forProp.a以及条件渲染的imgFlag,即<div v-if="imgFlag">{{froProp.a}}</div>;- 当点击
button按钮时,更新响应式数据forProp.a属性,使之++; forProp.a的变化就会触发用户 watcher即forProp.a(nv, ov) {....},用户watcher会在触发时更新imgFlag;
首先 forProp.a 变化,渲染 watcher 肯定会被 push 到 queue 队列,那么用户 watcher 执行时会不会再次把渲染 watcher push 到 queue 队列,即 queue 中有两个渲染 watcher ?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue</title>
</head>
<body>
<div id="app">
<button @click="goForPatch">使 forProp.a++</button>
<div v-if="imgFlag"> ===> {{forProp.a}}</div>
<img v-else
:src="imgSrc"
onload="console.log('img onload')">
</div>
<script src="./dist1/vue.js"></script>
<script>
const src = 'https://gift-static.hongyibo.com.cn/static/ad_oss/image-1004-294/6139ce3ed297e/16311783028626.jpg'
debugger
new Vue({
el: '#app',
forProp: {
a: 100
},
imgFlag: false,
imgSrc: src
},
methods: {
goForPatch () {
this.forProp.a++
}
},
watch: {
// 这个 watch 选项就是 用户 watcher,
// 有别于 Vue 自己创建的渲染 watcher、计算属性对应的 lazy watcher
'forProp.a' (nv, ov) {
this.imgFlag = !this.imgFlag
}
}
})
</script>
</body>
</html>
2.1 queueWatcher 的 has[id]、waiting
2.1.1 watch.id
每个 watcher 被创建时,都会获取一个唯一自增的 id,这个值是唯一的,无论是用户 watcher 还是 渲染 watcher 都有;
2.1.2 has[id]
前面的 forProp.a++ 使得 forProp.a 的 setter 被触发,最终调用 dep.notity -> watcher.update -> queueWatcher(this);
queueWatcher 把 this(watcher 实例)添加到 queue,在添加之前会判断缓存对象 has 中是否已经存在该 watcher.id,如果判断出 has[id] 不存在,再 push 到 queue,并且 has[id] = watcher.id;
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
// 如果 watcher 已经存在,则跳过,不会重复进入 queue
if (has[id] == null) {
// 缓存 watcher.id,用于判断 watcher 是否已经进入队列
has[id] = true
if (!flushing) {
queue.push(watcher)
}
}
}
2.2 用户 watcher 和 渲染 watcher 的顺序
从上一篇讲述 消耗 queue 队列的 flushSchedulerQueue 方法中的得知,在触发 watcher 重新求值前会有一个给 queue 中的 watcher 按照 id 进行升序排序,所以 id 小的 watcher 将会被先执行;
所以现在问题变成了 用户 watcher 和 渲染 watcher 的 id 谁更小的问题。这个问题答案很显然,是用户 watcher id 更小。
在 Vue watcher 的 id 是个自增的值,先被创建的 watcher 的 id 会更小; 用户 watcher 是在初始化时初期进行响应式数据初始化的过程中创建的,而渲染 watcher 是在挂载阶段创建的,所以用户 watcher id 更小;
这里我们假设用户 watcher id 为 4,渲染 watcher 的 id 为 5;
此时缓存 watcher id 的 has 对象:{ 4: true, 5: true };
2.3 消耗 queue 队列
综上,当 flushSchedulerQueue 方法执行时,开始遍历排序后的 queue 队列执行 queue 中每一项 watcher.run() 方法,因为用户 watcher id 较小,所以就会先执行用户 watcher 的回调: forProp.a(nv, ov) { this.imgFlag = !this.imgFlag }。
imgFlag 被重新赋值,就会触发 imgFlag 这个响应式数据的 setter,进而触发 dep.notify(),notify() 执行会触发 watcher.update(),调用流程如下:
this.imgFlag = !this.imgFlag;
-> imgFlag setter ()
-> dep.notify()
-> watcher.update()
-> queueWatcher(this) this 是渲染 watcher,其 id 为 5
-> if (has[id] == null) 不成立,因为 has = { 4: true, 5: true }
export function queueWatcher (watcher: Watcher) {
// 此时 watcher 是渲染 watcher,id 为 5
const id = watcher.id
// 因为 has = { 4: true, 5: true },
// 由于 imgFlag 变更时,渲染 watcher 已经在 queue 了,
// 所以不会重复将渲染 watcher 放入 queue
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
}
}
}
2.4 总结
因为 watcher 被放入到 queue 前经过了判重处理,同时因为用户 watcher 的执行时机早于渲染 watcher,所以在用户 watcher 中修改渲染 watcher 依赖的数据时,不会多次将渲染 watcher 放入到 queue;
这么做的好处显而易见了,这就能够避免用户 watcher 中修改响应式数据导致页面刷新多次,这就减少了非常大的性能开销。
这里还有一个隐藏条件:当渲染 watcher 执行时,就能拿到用户 watcher 更新后的响应式数据最新值,这是为啥?因为用户 watcher 和 渲染 watcher 是同步串行的。
三、合并一个 tick 多次修改
3.1 一个 tick 多次修改同一个数据
先看一个例子:
这个例子很简单,当点击 button 按钮时,对 this.forProp++ 两次,此时分析一下会不会向 queue 中添加两次同一个渲染 watcher,同样我们假设渲染 watcher 的 watcher.id 为 5;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue</title>
</head>
<body>
<div id="app">
<button @click="goForPatch">使 forProp.a++</button>
<div> ===> {{forProp.a}}</div>
</div>
<script src="./dist1/vue.js"></script>
<script>
debugger
new Vue({
el: '#app',
data: {
forProp: {
a: 100
},
},
methods: {
goForPatch () {
this.forProp.a++
this.forProp.a++
}
}
})
</script>
</body>
</html>
点击事件触发时,this.forProp.a 第一次被 ++ 时,
this.forProp.a++
-> forProp.a 的 setter()
-> dep.notify()
-> 渲染 watcher.update()
-> queueWatcher(this)
-> if (has[id] == null) 成立
-> has[5] = true
第二次 this.forProp.a 被 ++ 时,还会走一变和上面类似的步骤,但是 has[5] == null 不成立了:
this.forProp.a++
-> forProp.a 的 setter()
-> dep.notify()
-> 渲染 watcher.update()
-> queueWatcher(this)
-> if (has[id] == null) 不成立,has[5] = true
虽然 this.forProps.a 在同一个 tick 中被 ++ 两次,但是最终 queue 中只有一个渲染 watcher;这个也就是常说的 Vue 性能优化的一个重要手段:合并同一个 tick 中对同一个响应式数据的多次更新。
为啥称之为合并呢?当渲染 watcher 真正触发重新求值的时候,已经是在多次更新响应式数据的 tick 之后的下一个 tick 了,此时渲染 watcher 重新求值,获取到的就是上一个 tick 中响应式数据的最新值,至于在最新值之前的值通通被渲染 watcher 忽略掉了,因为渲染 watcher 从来就不知道这个响应式数据有这么多的前任。
3.2 一个 tick 修改多个不同数据
这个原理同样被应用到在一个 tick 中一次性修改多个响应式数据,比如 this.forProp.a++ 然后 this.imgFlag = !this.imgFlag,这两个步骤都触发了各自的 setter,但是因为渲染 watcher 已经存在 queue 的原因,不会被重复添加,渲染 watcher 最后还是只有一个;
四、总结
本文是继 nextTick 的姊妹篇,同时又是一个深入理解 nextTick 精妙设计所在的过程。另外,也解答了何为合并多次修改的性能优化,其核心实现如下:
watcher被push到queue之前有一步判断重复处理,即has[id] == null;watcher被成功推入queue之后has[id] = true,可保证下次push这个watcher时不会通过上一步的判重,因而不会重复加入queue;- 当
queue中的watcher被重新求值前,会按id进行升序,用户 wacher的id小于渲染 watcher的id,所以用户 watcher放心大胆的改响应式数据,同样是由于has[渲染watcher id] = true故而不会将渲染 watcher多次加入queue; - 最后,每个
watcher从queue取出并重新求值后has[被取出 watcher id]被置为null,这就保证下个tick中修改响应式数据时,这些watcher又可以被重新添加到queue;