对于浏览器而言,有多个线程协同合作,如下图。具体细节可以参考一帧剖析。
对于常说的JS单线程引擎也就是指的 Main Therad。
注意以上主线程的每一块未必都会执行,需要看实际情况。
先把 Parse HTML -> Composite 的过程称为渲染管道流 Rendering pipeline。
浏览器内部有一个不停的轮询机制,检查任务队列中是否有任务,有的话就取出交给 JS引擎 去执行。
例如:
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");
bar();
foo();
baz();
过程:
任务队列 Tasks Queue
一些常见的 webapi 会产生一个 task 送入到任务队列中。
script标签XHR、addEventListener等事件回调setTimeout定时器
每个 task 执行在一个轮询中,有自己的上下文环境互不影响。也就是为什么,script 标签内的代码崩溃了,不影响接下来的 script 代码执行。
- 轮询伪代码如下(原视频中使用
pop,便于JSer的世界观改用shift)
while(true) {
task = taskQueue.shift();
execute(task);
}
- 任务队列未必维护在一个队列里,例如
input event、setTimeout的callback可能维护在不同的队列中。 代码如果操作DOM,主线程还会执行渲染管道流。伪代码修改如下:
while(true) {
+ queue = getNextQueue();
- task = taskQueue.shift();
+ task = queue.shift();
execute(task);
+ if(isRepaintTime()) repaint();
}
- 举个例子
button.addEventListener('click', e => {
while(true);
});
点击 button 产生一个 task,当执行该任务时,一直占用主线程卡死,该任务无法退出,导致无法响应用户交互或渲染动态图等。
改换执行以下代码
function loop() {
setTimeout(loop, 0);
}
loop();
看似无限循环执行 loop,setTimeout 到时后产生一个 task。执行完 loop 即退出主线程。使得用户交互事件和渲染能够得以执行。
正因为如此,setTimeout 和其他 webapi 产生的 task 执行依赖任务队列中的顺序。
即使任务队列没有其他任务,也不能做到 0秒 运行,setTimeout 定时器到时间 cb 入任务队列,在轮询取出 task 给引擎执行,最少大约 4.7ms。
requestAnimationFrame
- 举个例子,不停移动一个盒子向前1像素
function callback() {
moveBoxForwardOnePixel();
requestAnimationFrame(callback);
}
callback()
换成 setTimeout
function callback() {
moveBoxForwardOnePixel();
- requestAnimationFrame(callback);
+ setTimeout(callback, 0);
}
callback()
对比,可以发现 setTimeout 移动明显比 rAF 移动快很多(3.5倍左右)。
意味着 setTimeout 回调过于频繁,这并不是一件好事。
渲染管道流不一定发生在每个 setTimeout 产生的 task 之间,也可能发生在多个 setTimeout 回调之后。
由浏览器决定何时渲染并且尽可能高效,只有值得更新才会渲染,如果没有就不会。
如果浏览器运行在后台,没有显示,浏览器就不会渲染,因为没有意义。大多数情况下页面会以固定频率刷新,
保证 60FPS 人眼就感觉很流畅,也就是一帧大约 16ms。频率高,人眼看不见无意义,低于人眼能发现卡顿。
在主线程很空闲时,setTimeout 回调能每 4ms 左右执行一次,留 2ms 给渲染管道流,setTimeout 一帧内能执行大概 3.5次。
3.5ms * 4 + 2ms = 16ms。
setTimeout 调用次数太多 3-4次,多于用户能够看到的,也多于浏览器能够显示的,大约3/4是浪费的。
很多老的动画库,用 setTimeout(animFrame, 1000 / 60)来优化。
但 setTimeout 并不是为动画而生,执行不稳定,会产生飘移或任务过重会推迟渲染管道流。
requestAnimationFrame 正是用来解决这些问题的,使一切整洁有序,每一帧都按时发生。
推荐使用 requestAnimationFrame 包裹动画工作提高性能。它解决这个 setTimeout 不确定性与性能浪费的问题,由浏览器来保证在渲染管道流之前执行。
- 一个困惑的问题:以下代码能实现先从
0px移动到1000px处,再到500px处吗?
button.addEventListener('click', () => {
box.style.transform = 'translateX(1000px)';
box.style.transition = 'transform 1s ease-in-out';
box.style.transform = 'translateX(500px)';
});
结果:从 0px 移动到 500px 处。由于回调任务的代码块是同步执行的,浏览器不在乎中间态。
- 修改如下
button.addEventListener('click', () => {
box.style.transform = 'translateX(1000px)';
box.style.transition = 'transform 1s ease-in-out';
- box.style.transform = 'translateX(500px)';
+ requestAnimationFrame(() => {
+ box.style.transform = 'translateX(500px)';
+ });
});
结果:依然从 0px 移动到 500px 处。
这是因为在 addEventListener 的 task 中同步代码修改为 1000px。
在渲染管道流中的计算样式执行之前,需要执行 rAF,最终的样式为 500px。
- 正确修改,在下一帧的渲染管道流执行之前修改
500px。
button.addEventListener('click', () => {
box.style.transform = 'translateX(1000px)';
box.style.transition = 'transform 1s ease-in-out';
requestAnimationFrame(() => {
- box.style.transform = 'translateX(500px)';
+ requestAnimationFrame(() => {
+ box.style.transform = 'translateX(500px)';
+ });
});
});
- 不好的方式,但也能达到效果
button.addEventListener('click', () => {
box.style.transform = 'translateX(1000px)';
box.style.transition = 'transform 1s ease-in-out';
+ getComputedStyle(box).transform;
box.style.transform = 'translateX(500px)';
});
getComputedStyle 会导致强制重排,渲染管道流提前执行,多余操作损耗性能。
- bad news
Edge 和 Safari 的 rAF 不符合规范,错误的放在渲染管道流之后执行。
微任务 Microtasks
DOMNodeInserted 初衷被设计用来监听 DOM 的改变。
- 例如以下代码,会触发多少次
DOMNodeInserted。
document.body.addEventListener('DOMNodeInserted', () => {
console.log('Stuff added to <body>!');
});
for(let i = 0; i < 100; i++) {
const span = document.createElement('span');
document.body.appendChild(span);
span.textContent = 'hello';
}
理想 for 循环完毕后,DOMNodeInserted 回调执行一次。
结果:执行了 200 次。添加 span 触发 100 次,设置 textContent 触发 100。
这就让使用 DOMNodeInserted 会产生极差的性能负担。
为了解决此等问题,创建了一个新的任务队列叫做微任务 Microtasks。
常见微任务
- MutationObserver —— DOM变化事件的观察者。
- Promise
- process.nextTick (node 中)
微任务是在一次事件轮询中取出的 task 执行完毕,即 JavaScript 运行栈(stack)中已经没有可执行的内容了。
浏览器紧接着取出微任务队列中所有的 microtasks 来执行。
- 如果用微任务创建一个像之前的
loop会怎样?
function loop() {
Promise.resolve().then(loop);
}
loop();
你会发现,它跟之前的 while 一样卡死。
现在我们有了3个不同性质的队列
- task queue
- rAF queue
- microtask queue
- task queue 前面已知,事件轮询中取出一个
task执行,如果产生new task入队列。task执行完毕等待下一次轮询取出next task。 - microtask queue task 执行完毕后,执行队列中所有
microtask,如果产生new microtask,入队列,等待执行,直到队列清空。
while(true) {
queue = getNextQueue();
task = queue.shift();
execute(task);
+ while(microtaskQueue.hasTasks()) {
+ doMicrotask();
+ }
if(isRepaintTime()) repaint();
}
rAF queue每一帧渲染管道流开始之前一次性执行完所有队列中的rAF callback,如果产生new rAF等待下一帧执行。
while(true) {
queue = getNextQueue();
task = queue.shift();
execute(task);
while(microtaskQueue.hasTasks()) {
doMicrotask();
}
- if(isRepaintTime()) repaint();
+ if(isRepaintTime()) {
+ animationTasks = animationQueue.copyTasks();
+ for(task in animationTasks) {
+ doAnimationTask(task);
+ }
+
+ repaint();
+ }
}
- 思考,检验一下自己是否理解了
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 1'));
console.log('Listener 1');
});
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 2'));
console.log('Listener 2');
});
点击按钮会是怎么样的顺序呢?
来分析一下,以上代码块为一个 task 0。
task 0执行完毕后,webapi监听事件。- 用户点击按钮,触发
click事件,task queue中入队task 1、task 2。 - 轮询取出
task 1执行,Microtask queue入队Microtask 1。console输出Listener 1。task 1执行完毕。 - 执行所有的
microtask(目前只有Microtask 1),取出执行,console 输出Microtask 1。 - 轮询取出
task 2执行,Microtask queue入队Microtask 2。console输出Listener 2。task 2执行完毕。 - 执行所有的
microtask,取出Microtask 2执行,console 输出Microtask 2。
答案:Listener 1 -> Microtask 1 -> Listener 2 -> Microtask 2
如果你答对了,那么恭喜你,超越了 87% 的答题者。
- 如果是代码触发呢?
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 1'));
console.log('Listener 1');
});
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 2'));
console.log('Listener 2');
});
+ button.click();
思路一样分析
task 0执行到button.click()等待事件回调执行完毕。- 同步执行
Listener 1,Microtask queue入队Microtask 1。console输出Listener 1。 - 同步执行
Listener 2,Microtask queue入队Microtask 2。console输出Listener 2。 click函数return,结束task 0。- 执行所有的
microtask,取出Microtask 1执行,console 输出Microtask 1。 - 取出
Microtask 2执行,console 输出Microtask 2。
答案:Listener 1 -> Listener 2 -> Microtask 1 -> Microtask 2
在做自动化测试时,需要小心,有时会产生和用户交互不一样的结果。
- 最后来点难度的的题
以下代码,用户点击,会阻止a链接跳转吗?
const nextClick = new Promise(resolve => {
link.addEventListener('click', resolve, { once: true });
});
nextClick.then(event => {
event.preventDefault();
// handle event
});
如果是代码点击呢?
link.click();
暂不揭晓答案,欢迎评论区讨论。
node
- 没有脚本解析事件(如,解析 HTML 中的 script)
- 没有用户交互事件
- 没有
rAFcallback - 没有渲染管道(rendering pipeline)
node 不需要一直轮询有没有任务,清空所有队列就结束。
常见任务队列 task queue
- XHR requests、disk read or write queue(I/O)
- check queue (setImmediate)
- timer queue (setTimeout)
常见微任务 microtask queue
- process.nextTick
- Promise
process.nextTick 执行优先级高于 Promise。
while(tasksAreWaiting()) {
queue = getNextQueue();
while(queue.hasTasks()) {
task = queue.shift();
execute(task);
while(nextTickQueue.hasTasks()) {
doNextTickTask();
}
while(promiseQueue.hasTasks()) {
doPromiseTask();
}
}
}
web worker
- 没有
script tag - 没有用户交互
- 不能操作
DOM
类似 node