一、背景
Vue 如何组织队列更新,主要依托于下面几个方法:
1.Watcher.prototype.update,当响应式数据发生变化,其对应的 dep.notify 执行,watcher.update 会调用 queueWatcher;
2.queueWatcher 负责把 watcher 实例加入到待求值的 watcher 队列 queue 中,添加到队列需要根据当前队列是否处于刷新状态做不同的处理;
3.queueWatcher 还会调用 nextTick 方法,传入消耗 queue 队列的 flushSchedulerQueue 方法;
4.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,即
当点击 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 精妙设计所在的过程。另外,也解答了何为合并多次修改的性能优化,其核心实现如下:
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;
源码附件已经打包好上传到百度云了,大家自行下载即可~
链接: https://pan.baidu.com/s/14G-bpVthImHD4eosZUNSFA?pwd=yu27
提取码: yu27
百度云链接不稳定,随时可能会失效,大家抓紧保存哈。
如果百度云链接失效了的话,请留言告诉我,我看到后会及时更新~
开源地址
码云地址:
http://github.crmeb.net/u/defu
Github 地址:
http://github.crmeb.net/u/defu
开源不易,Star 以表尊重,感兴趣的朋友欢迎 Star,提交 PR,一起维护开源项目,造福更多人!