一,前言
上篇,介绍了 Vue依赖收集的视图更新部分,主要涉及以下几点:
视图初始化时:
- render方法中会进行取值操作,进入 Object.defineProperty 的 get 方法
- get 方法中为数据添加 dep,并记录当前的渲染 watcher
- 记录方式:watcher查重并记住 dep,dep 再记住 watcher
数据更新时:
- 当数据发生改,会进入 Object.defineProperty 的 set 方法
- 在 set 方法中,使 dep 中收集的全部 watcher 执行视图渲染操作 watcher.get()
- 在视图渲染前(this.getter方法执行前),通过 dep.target 记录当前的渲染 watcher
- 重复视图初始化流程
本篇,介绍 Vue 的异步更新流程
二,异步更新的实现
1,为什么要做异步更新
上文末尾提到了一个问题:
当前代码版本,在视图渲染阶段会进行依赖收集,数据改变将通知所有被收集的渲染 watcher
更新视图:
// dist/index.html
let vm = new Vue({
el: '#app',
data() {
return { name: "Brave" , age: 123}
}
});
vm.name = "Brave Wang";
vm.name = "Brave";
vm.name = "Brave Wang";
vm.name = "Brave";
vm.name = "Brave Wang";
vm.name = "Brave";
在这种情况下,频繁地更新同一数据,就会多次触发视图渲染dep.notify ---> watcher.update
;
虽然name
的值总共变化了6
次,但实际上,只要最后一次更新视图就可以;
由于当前采用同步调用watcher.update
进行更新,即数据变化一次就会触发一次视图更新操作;
想要做到仅在最后执行一次视图更新操作,这就需要将视图的更新,改造为“异步更新”机制;
2,异步更新的实现思路
当数据发生变化时,先将数据变更的逻辑缓存起来,不直接处理;如果有相同数据更新就进行合并,在最后仅执行一次视图更新操作;
在Vue
中,vue.nextTick
方法就可以实现异步更新;
- 同步更新,每次数据更新都会同步调用
update
方法;- 异步更新,先将更新逻辑
Watcher
缓存起来,合并后一起处理;
3,数据变更缓存的位置
数据变更就会进入setter
方法,但并不能在setter
中进行做缓存:
- 因为,数组的变化是不会进入
setter
的; - 但是,不论何种数据变化,最终视图渲染都将汇集到
watcher.update
方法
所以,在watcher.update
方法中进行watcher
的缓存是最合适的;
4,缓存 watcher 更新逻辑
缓存的思路:
将
watcher
集中缓存到一个队列中,在缓存过程中进行合并,最后一次性执行;由于此时为异步代码,当逻辑全部执行完成后,才会将队列中的
watcher
都run
执行;
在vue
中,存在一个任务调度方法:src/observe/schedule.js
:
queueWatcher
方法:缓存队列,用于watcher
的去重和缓存(唯一标识 id);
// src/observe/schedule.js
let queue = []; // 用于缓存渲染 watcher
let has = {}; // 存放 watcher 唯一 id,用于 watcher 的查重
let pending = false; // 等待状态标记,用于控制 setTimeout 只走一次
/**
* 对 watcher 进行查重并缓存,最后统一执行更新
* @param {*} watcher 需更新的 watcher
*/
export function queueWatcher(watcher) {
let id = watcher.id;
if (has[id] == null) { // has 对象中没有当前 watcher
has[id] = true; // 缓存标记
queue.push(watcher); // 缓存 watcher 但不调用,后续统一处理
if (!pending) { // 等效于防抖,内部逻辑只执行依次
setTimeout(() => { // 执行 watcher
queue.forEach(watcher => watcher.run()) // 依次触发视图更新
queue = []; // reset...
has = {}; // reset...
pending = false; // reset....
}, 0);
pending = true; // 首次进入被置为 true,使微任务执行完成后宏任务才执行
}
}
}
pending
标记的解释:
- 在
queueWatcher
方法中,同一 watcher 只会保存一次,不同 watcher 就会多次加入到queue
队列中,pending
标记用于控制setTimeout
中的watcher
批量执行逻辑仅执行一次,相当于一个防抖操作(多次执行只走一次);setTimeout
是宏任务,相当于异步代码,当全部watcher
存入队列后,就会执行内部的批量执行逻辑;
在Watcher
类的update
方法中使用queueWatcher
缓存队列,为Watcher
类添加run
方法执行视图更新操作,从而实现异步更新:
// src/observe/watcher.js
import Dep from "./dep";
import { queueWatcher } from "./scheduler";
let id = 0;
class Watcher {
constructor(vm, fn, cb, options){
this.vm = vm;
this.fn = fn;
this.cb = cb;
this.options = options;
this.id = id++;
this.depsId = new Set();
this.deps = [];
this.getter = fn;
this.get();
}
addDep(dep){
let did = dep.id;
if(!this.depsId.has(did)){
this.depsId.add(did);
this.deps.push(dep);
dep.addSub(this);
}
}
get(){
Dep.target = this;
this.getter();
Dep.target = null;
}
update(){
console.log("watcher-update", "查重并缓存需要更新的 watcher")
queueWatcher(this);
}
run(){
console.log("watcher-run", "真正执行视图更新")
this.get();
}
}
export default Watcher;
测试异步更新:
观察控制台输出:6
次同步更新操作被查重并缓存,仅在最后执行了一次异步更新操作;
5,代码重构
- 1,优化
nextTick
异步方案:改用promise
包装的方案来实现异步操作;
// src/utils.js
/**
* 将方法异步化
* @param {*} fn 需要异步化的方法
* @returns
*/
export function nextTick(fn) {
return Promise.resolve().then(fn);
}
- Vue3 的
nextTick
就是采用 Promise 实现的;- Vue2 中做了一些兼容性处理;这里的源码实现暂不考虑兼容问题;
- 2,将刷新队列逻辑,抽取为独立方法
flushschedulerQueue
setTimeiout
内部逻辑用于刷新队列:执行所有watcher.run
并清空队列;
/**
* 刷新队列:执行所有 watcher.run 并将队列清空;
*/
function flushschedulerQueue() {
queue.forEach(watcher => watcher.run()) // 依次触发视图更新
queue = []; // reset
has = {}; // reset
pending = false; // reset
}
- 重构完成后的代码
**
* 将 watcher 进行查重并缓存,最后统一执行更新
* @param {*} watcher 需更新的 watcher
*/
export function queueWatcher(watcher) {
let id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
if (!pending) {
nextTick(flushschedulerQueue); // 改造后,使用 nextTick
pending = true;
}
}
}
6,测试异步更新
<div id="app">
<li>{{name}}</li>
<li>{{name}}</li>
<li>{{name}}</li>
<li>{{age}}</li>
</div>
let vm = new Vue({
el: '#app',
data() {
return { name: "Brave"}
}
});
vm.name = "Brave Wang";
console.log("数据更新后立即获取 dom", vm.$el.innerHTML);
控制台输出结果:
此时,观察控制台输出,打印的dom
元素仍为旧值,这时由于vm.name
已使用异步更新;
那么,如何获取更新后的dom
元素呢?
7,获取更新后的 dom
在Vue
中,可以使用vm.$nextTick
方法,获取到异步更新后的值;
所以,在Vue
初始化阶段initMixin
方法中,添加原型方法$nextTick
:
// src/init.js
import { nextTick } from "./utils";
export function initMixin(Vue) {·
Vue.prototype._init = function (options) {...}
Vue.prototype.$mount = function (el) {...}
// 为 Vue 扩展原型方法 $nextTick
Vue.prototype.$nextTick = nextTick;
}
功能测试:
let vm = new Vue({
el: '#app',
data() {
return { name: "Brave"}
}
});
vm.name = "Brave Wang";
console.log("数据更新后立即获取 dom", vm.$el.innerHTML);
vm.$nextTick(()=>{
console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})
这样,就实现了$nextTick
原型方法,并通过此方法获取到异步更新后的dom
元素值;
三,异步更新实现的优化
在上边的实现中,共创造了两个promise
:
- 第一次,在更新数据时,创造了一个
promise
; - 第二次,在
nextTick
中,又创造了一个promise
; - 第一个
promise
先执行,第二个promise
再执行;
所以,第二个拿到的,其实是第一个成功后的结果;
这里可以进行优化,合并成为一个promise
,与watcher
执行异步更新的原理相似:
- 当更新数据时,先将更新逻辑缓存起来;
- 当用户通过
nextTick
取值时,再将取值逻辑也缓存起来;
将两个逻辑缓存到一个数组,在同一个微任务中全部执行即可;
这样一来,整个过程中就只创建了一个promise
:
// src/utils.js
let callbacks = []; // 缓存异步更新的 nextTick
let waiting = false; // 是否等待清空中
/**
* 将方法异步化(与 watcher 的异步更新实现相似)
* @param {*} fn 需要异步化的方法
* @returns
*/
export function nextTick(fn) {
// return Promise.resolve().then(fn);
callbacks.push(fn); // 缓存异步更新的 nextTick,后续统一处理
if(!waiting){ // 如果未处于等待状态,就触发一次执行,并置位
Promise.resolve().then(flushsCallbacks);
waiting = true; // 首次进入被置为 true,控制逻辑只走一次
}
}
/**
* 依次执行缓存的 nextTick,并清空队列
*/
function flushsCallbacks() {
callbacks.forEach(fn => fn())
callbacks = []; // reset
waiting = false; // reset
}
在callbacks
中:
- 第一次传入的
fn
,一定是来自内部的; - 第二次传入的
fn
,才是用户自己写的;
先将两个fn
的逻辑缓存起来,之后再一次性执行更新;
这样,就实现了将用户的nextTick
和内部更新的nextTick
合并在一起;
Promise
的.then
是一个微任务;
测试效果:
let vm = new Vue({
el: '#app',
data() {
return { name: "Brave"}
}
});
vm.name = "Brave Wang";
console.log("数据更新后立即获取 dom", vm.$el.innerHTML);
vm.$nextTick(()=>{
console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})
vm.$nextTick(()=>{
console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})
vm.$nextTick(()=>{
console.log("$nextTick获取更新后的 dom", vm.$el.innerHTML);
})
在以上代码示例中,共包含了4
个nextTick
:
1
次更新数据的nextTick
;3
次用户实现的nextTick
;
共4
次nextTick
,但只创建了一个Promise
,在同一个微任务中全部执行;
注意:这里多个
nextTick
只执行了一次then
,而非多次;
测试结果:
四,结尾
本篇,主要介绍了 Vue 的异步更新流程,主要涉及以下几点:
- 为什么要做异步更新
- 异步更新的实现思路
- 数据变更缓存的位置
- 缓存 watcher 更新逻辑
- vm.$nextTick 获取更新后的 dom
- 测试异步更新
下一篇,数组的依赖收集
维护记录
- 20230207:优化异步更新内容描述和排版、添加了必要的逻辑说明;添加了代码注释,添加内容中的代码高亮;添加同步更新逻辑、异步更新逻辑的相关测试截图;更新文章摘要;