前置知识
dom 更新
首先明确一下 dom 更新的概念。
浏览器中有一个 js 引擎线程执行我们的 js 代码,同时还有一个 GUI 渲染线程来进行绘图,并且两个线程是互斥的,只能交替着进行。
而dom 更新是在 js 线程中进行的,因此 dom 更新了并不代表我们就一定可以看到,只有当渲染线程把更新的 dom 绘制完毕我们才会看到。
简单理解就是下边的样子:
举一个极端的例子,如果我们在 js 线程里修改了 dom ,但某种原因使得 js 线程一直在执行,没有轮到渲染线程,那么我们就永远看不到更新后 dom 了。
html 引入 bundle.js 。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>
bundle.js 首先修改 dom ,然后执行一个死循环。
document.getElementById("root").innerText = "hello";
while (true) {}
此时页面就永远是空白了。但事实上我们的 dom 已经更新了,只是没有轮到渲染线程展示出来。
只更新最后一次结果
在 js 线程中如果修改同一个 dom 元素,无论修改多少次,最终轮到渲染线程的时候,渲染线程当前读到的 dom 是啥就会是啥。
document.getElementById("root").innerText = "hello";
document.getElementById("root").innerText = "hello2";
document.getElementById("root").innerText = "hello3";
document.getElementById("root").innerText = "liang";
上边 dom 变化了多次,但屏幕上只会看到 liang。
宏任务微任务任务队列
这里简单说一下,不细讲了。
- 宏任务生成方式:script 标签, setTimeout, setInterval 等
- 微任务生成方式:Promise, MutationObserver 等。
js 线程中,通过 <script> 执行代码,也就是开始执行第一个宏任务,执行过程中新生成的宏任务丢到任务队列,新生成的微任务丢到微任务队列。
当前宏任务执行结束后,开始执行微任务队列,直到微任务队列执行完毕。
js 线程退出来,开始执行渲染线程。
渲染线程执行完毕后,然后又回到 js 线程,去任务队列中取一个宏任务,重复上边的过程。
让 dom 更新多次
document.getElementById("root").innerText = "hello";
document.getElementById("root").innerText = "hello2";
document.getElementById("root").innerText = "hello3";
document.getElementById("root").innerText = "liang";
这个例子中渲染的时候只会执行第一次 dom ,但如果我们通过 setTimeout 产生一个宏任务,这样就会看到会先后渲染了。
document.getElementById("root").innerText = "hello";
setTimeout(() => {
document.getElementById("root").innerText = "hello2";
setTimeout(() => {
document.getElementById("root").innerText = "hello3";
setTimeout(() => {
document.getElementById("root").innerText = "liang";
}, 1000);
}, 1000);
}, 1000);
场景
回到我们的响应式系统中。
import { observe } from "./reactive";
import Watcher from "./watcher";
const data = {
text: "hello",
};
observe(data);
const updateComponent = () => {
document.getElementById("root").innerText = data.text;
};
new Watcher(updateComponent);
data.text = "liang";
data.text="liang" 触发 Wathcer 更新的时候,并不会立即更新,而是放到 Wathcer 队列中,在 setTimeout 中执行,代码如下。
export function queueWatcher(watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
// queue the flush
if (!waiting) {
waiting = true;
setTimeout(flushSchedulerQueue, 0);
}
}
}
再结合这张图:
第 1 次宏任务将 dom 更新为 hello ,然后执行第一次的渲染任务。
第 2 次宏任务是将第 1 次宏任务中的 setTimeout 取出进行执行,然后将 dom 更新为 liang ,执行渲染任务。
所以页面应该先是 hello 后是 liang 。
但运行上边的程序发现并不是这样,页面只看到了 liang ,没有看到 hello 。
小猜测
没有研究过 Chrome 的代码,这里不负责任的猜想一下,有问题欢迎讨论。
渲染线程不是像上边图中一样每次都接到 js 进程后边,相反渲染线程可以看做在间隔执行,比如每 10ms 执行一次,如果渲染线程准备执行的时候 js 线程还在执行就等待。
但如果第一次宏任务、微任务执行完毕后,时间小于了 10ms ,此时渲染线程还没有准备执行,所以 js 线程就直接去执行第二次宏任务了。
因此,我们可以强行增加第一次宏任务执行的时间,确保 js 线程执行完以后会去执行渲染线程。
import { observe } from "./reactive";
import Watcher from "./watcher";
const data = {
text: "hello",
};
observe(data);
const updateComponent = () => {
/****强行增加耗时***********/
let i = 1000000000;
while (i) {
i--;
}
/************************************/
document.getElementById("root").innerText = data.text;
};
new Watcher(updateComponent);
data.text = "liang";
这样的话就符合我们的认知了,首先会渲染出 hello ,然后再渲染出 liang 。
Kapture 2022-04-13 at 09.11.42
验证微任务先执行
为了继续了解下边图中的流程,我们再举个例子。
import { observe } from "./reactive";
import Watcher from "./watcher";
const data = {
text: "hello",
};
observe(data);
const updateComponent = () => {
let i = 1000000000;
while (i) {
i--;
}
document.getElementById("root").innerText = data.text;
};
new Watcher(updateComponent);
data.text = "liang";
const p = Promise.resolve();
p.then(() => {
document.getElementById("root").innerText = "promise";
});
先 1 分钟思考一下,屏幕会输出什么。
第一次宏任务的时候 dom 被修改成了 hello ,但此时还没有执行渲染线程。
接着执行微任务,将 dom 修改为 promise 。
接着执行第一次渲染线程,页面展示出 promise 。
第二次宏任务执行,将 dom 修改为 liang 。
此时没有微任务。
接着执行第二次渲染线程,页面展示出 liang 。
优化
响应式系统之异步队列文章中介绍的,如下代码:
export function queueWatcher(watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
// queue the flush
if (!waiting) {
waiting = true;
setTimeout(flushSchedulerQueue, 0);
}
}
}
因为我们是将 Watcher 队列的执行放到了 setTimemout 中,所以在第一次宏任务中把 data 的响应式数据更改后,dom 并不会立即去更新。
这就导致第一次的渲染线程轮空了,到了第二次宏任务的时候才会执行 Watcher 队列来更新 dom ,然后在第二次渲染线程中才会更新为改变后的视图。
最好的做法当然是将 dom 的更新放在第一次渲染线程执行之前,即第一次宏任务后的微任务。
Vue 中提供了 next-tick 供我们使用,下边看一下实现。
next-tick 实现思路
实现起来其实也很简单,只需要模仿 之前 Watcher 队列的实现。
自身维护一个队列,保存所有的回调函数。然后将队列的执行放到 Promise 中即可。
用 callbacks 数组保存所有的回调函数,提供一个方法执行 callbacks 所有的回调函数。
const callbacks = [];
let pending = false; // 代表是否将 `callbacks` 执行加入到了微任务队列中
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
提供一个函数,将 flushCallbacks 加到微任务队列,为了保证兼容性,如果不支持 Promise 我们依旧使用 setTimeout 。
import { isNative } from "./env";
/*
export function isNative(Ctor) {
return typeof Ctor === "function" && /native code/.test(Ctor.toString());
}
*/
let timerFunc;
if (typeof Promise !== "undefined" && isNative(Promise)) {
const p = Promise.resolve();
timerFunc = () => {
p.then(flushCallbacks);
};
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0);
};
}
然后就是 nextTick 的代码了,
export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
cb.call(ctx)
})
if (!pending) {
pending = true // 代表是否将 `callbacks` 执行已经加入到了微任务队列中
timerFunc() // 加入到微任务队列
}
}
当然,我们还可以支持一下 Promise 风格的调用,也就是支持下边的调用方式。
nextTick().then(() => {})
实现起来也比较简单,我们只需要判断没有 cb 的时候,生成一个 Promise ,然后将 resolve 的执行放到 callbacks 数组中。
export function nextTick(cb, ctx) {
let _resolve;
callbacks.push(() => {
if (cb) {
cb.call(ctx);
} else if (_resolve) {
_resolve(ctx); // 支持 Promise 风格调用,当执行到这里,就会执行用户的回调函数了
}
});
if (!pending) {
pending = true;
timerFunc(); // 只执行一次
}
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
_resolve = resolve; // 保存当前 resolve
});
}
}
优化异步队列
执行 Watcher 队列的更新我们就不使用了 setTimeout 了,直接使用 next-tick 即可。
export function queueWatcher(watcher) {
const id = watcher.id;
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
// queue the flush
if (!waiting) {
waiting = true;
// setTimeout(flushSchedulerQueue, 0); // 修改前
/******修改 *************************/
nextTick(flushSchedulerQueue);
/************************************/
}
}
}
回到最开始的代码中。
import { observe } from "./reactive";
import Watcher from "./watcher";
const data = {
text: "hello",
};
observe(data);
const updateComponent = () => {
let i = 1000000000;
while (i) {
i--;
}
document.getElementById("root").innerText = data.text;
};
new Watcher(updateComponent);
data.text = "liang";
此时页面就先展示初始值 hello 再展示直接 liang 了,会直接展示 liang ,原因的话还是下边这张图。
我们在第一次微任务的时候将 dom 更新为了 liang ,到了第一次渲染线程当然就会渲染出 liang 了。
nextTick 用法
import { observe } from "./reactive";
import Watcher from "./watcher";
import { nextTick } from "./next-tick";
const data = {
text: "hello",
};
observe(data);
const updateComponent = () => {
let i = 1000000000;
while (i) {
i--;
}
document.getElementById("root").innerText = data.text;
};
new Watcher(updateComponent);
const updateData = () => {
data.text = "liang";
console.log(document.getElementById("root").innerText);
const cb = () => {
console.log(document.getElementById("root").innerText);
};
nextTick(cb);
};
updateData();
有两次输出,1 分钟思考一下两次输出分别是什么。
... ...
updateData 函数中,当我们把 data.text 赋值为 liang 的时候,虽然触发了 Wacher,但此时并不会执行,而是将 Watcher 收集到 Watcher 队列中。
所以第一次输出的还是更新前的 dom ,也就是 hello 。
接下来 nextTick 会将回调函数加到微任务队列中。
当我们执行 cb 的时候,Watcher 队列已经执行完毕,所以此刻 dom 已经更新了,输出的自然是 liang 了。
另外,因为 nextTick 还支持 Promise 调用,所以还有一种骚操作。
import { observe } from "./reactive";
import Watcher from "./watcher";
import { nextTick } from "./next-tick";
const data = {
text: "hello",
};
observe(data);
const updateComponent = () => {
let i = 1000000000;
while (i) {
i--;
}
document.getElementById("root").innerText = data.text;
};
new Watcher(updateComponent);
const updateData = async () => {
data.text = "liang";
console.log(document.getElementById("root").innerText);
await nextTick();
console.log(document.getElementById("root").innerText);
};
updateData();
直接将 nextTick() 进行 await ,然后再输出,效果的话和上边是一样的。
总结
主要讲解了 nextTick 的原理,将 Watcher 的更新放到了微任务中,防止第一次渲染线程浪费掉。
平常 Vue 开发中,我们如果想要拿到更新后的 dom 值,就需要使用 nextTick 了,当然此刻只是 dom 更新了,页面还没有渲染。
留一个问题,如果在 nextTick 再改变响应式数据 data 中的值,那么是先渲染之前的值再渲染改变后的值,还是只渲染一次改变后的值,思考过后相信会对 nextTick 有更深刻的理解。