阅读此文之前先阅读一下这篇文章有助于你更好的理解浏览器事件循环。🔥🔥🔥初探浏览器事件循环是如何工作的
深入研究事件循环:任务优先级、处理长任务、使用浏览器空闲时间、调度微任务、计算页面上事件循环的数量。
在本文中,将更深入地了解事件循环的机制以及如何使用其功能编写高效的代码。
1. 深入任务
正如之前所说,任务是浏览器需要完成的一项工作。
每个任务都有一个附加属性 - source
。它定义了任务的类型。
例如,响应用户交互而发送的键盘、鼠标和其他事件都有用户交互源。与 DOM 操作相关的任务(例如将元素插入到文档中)具有 DOM 操作任务源。还有其他的:网络、导航等。
浏览器可以选择优先使用哪个源的任务。为此,浏览器实现的不是一个队列,而是多个队列。例如,浏览器可以为具有用户交互源的任务建立一个单独的队列并对其进行优先处理,从而提高用户交互操作的性能。(所有现代浏览器通常都会这样做。)
即使有很多任务,浏览器也会优先考虑重要的任务,因此性能不会受到影响。
2. 长任务
如果你的任务太长,仍然可能会弄乱用户体验。执行任务会阻止该线程的所有其他操作 - 例如,响应鼠标单击或滚动等用户操作。因此,较长的任务会对性能产生负面影响,应将其分解为较小的任务。
要找出如此长的任务,请使用 Chrome 开发工具。
转到 Chrome Dev tools
->Performance
选项卡 -> 单击Record
-> 在使用要测试的代码的页面上执行操作(例如页面重新加载或单击按钮) -> 停止记录并分析结果。
通过放大,可以区分各个任务。例如,一次调用如下所示setInterval
:
Chrome 用红色笔划和弹出窗口标记长任务,这表明任务的持续时间:
当发现长任务时,需要将其分解为小任务。怎么做?
3. 测试
重构了 3 个执行时间过长的函数。现在哪个函数可以让浏览器有时间响应用户输入?
js
function foo() {
a();
b();
c();
}
function bar() {
setTimeout(a);
setTimeout(b);
setTimeout(c);
}
function baz() {
Promise.resolve()
.then(a)
.then(b)
.then(c);
}
解释。
如果你的函数做了很多工作,即使它被分解成子函数,它也是一个单一的任务,在此期间浏览器不能被中断。例子:
js
function foo() {
a();
b();
c();
}
然而,这种重构不应被低估。分解一个大的函数仍然是富有成效的。浏览器对我们的代码进行了有用的优化。它们更容易在小功能上实现。此外,有时优化之后会进行去优化。对于同一函数,优化和反优化可能会发生多次。而且函数越大,这个过程需要的资源就越多。
回调Promise
已排队等候微任务。
js
function baz() {
Promise.resolve()
.then(a)
.then(b)
.then(c);
}
微任务在当前任务执行后立即执行,这意味着浏览器将无法中断来处理用户操作。
可以使用 来将长任务分解为较小的任务setTimeout
。这样,在一项大任务的各个部分之间,浏览器可以执行重要的工作,例如响应用户输入。
js
function bar() {
setTimeout(a);
setTimeout(b);
setTimeout(c);
}
然而,当我们需要打破一个长循环时,这种方法就不太方便了。
js
function foo() {
for (let i = 0; i < 1_000_000_000; i++) {
// some work here
}
}
在我的电脑上,此脚本运行 520 毫秒
在这种情况下,我们可以使用Promise
和的组合setTimeout
:
js
async function foo() {
for (let i = 0; i < 1_000_000_000; i++) {
if (!(i % 1_000_000)) {
await pause();
}
}
}
function pause() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
现在,我们不再是一项长任务,而是许多小任务,浏览器可以在这些小任务之间执行其他重要任务:
这段代码是如何工作的?
当pause
函数被调用时:
- 里面的代码
Promise
是同步执行的,导致setTimeout
回调被放到任务队列中。 - 该
pause
函数将从堆栈中弹出,并从调用它的地方继续执行。 - 此时,浏览器遇到了关键字
await
。它暂停函数的执行,函数的其余部分将在promise
解析后作为微任务执行。 - 现在浏览器返回到
foo
调用该函数的行。由于此函数调用没有await
,浏览器将执行其余代码。 - 任务完成后,浏览器会选择下一个任务。此时浏览器可以执行重要工作,例如响应单击事件。
- 当没有更重要的工作剩下时,浏览器将执行我们的
setTimeout
回调。 - 执行
setTimeout
回调将解析promise
,这将导致浏览器跳回执行该foo
函数。
还有一个用于相同目的的Scheduler API 。它更加灵活,允许您明确确定任务的优先级。但它还没有得到很大的支持。
使用Scheduler API,如果代码不是在一定次数的迭代之后被中断,而是在必要时(即当浏览器需要响应用户输入时)被中断,则可以进一步改进该代码。为此有一个特殊的方法,isInputPending:
js
async function foo() {
for (let i = 0; i < 1_000_000_000; i++) {
if (navigator.scheduling?.isInputPending()) {
await pause();
}
}
}
async function pause() {
return new Promise((res) => setTimeout(res));
}
foo()
现在,只有在必要时才会中断循环。例如,如果用户进行了需要处理的点击。
对于不支持此功能的浏览器,您可以通过计时器中断长循环。
4. 安排微任务
微任务和任务之间的主要区别在于,微任务在当前任务之后立即执行,并且它们之间不能执行其他代码。
例如,如果你的脚本遇到setTimeout
零延迟的,你不能保证它的回调会在脚本之后立即执行。如果在脚本执行期间触发事件,浏览器将优先处理事件处理程序。
另一方面,如果它不是setTimeout
微任务,那么它将首先执行。
因此,当您需要在某个任务之后立即执行操作时,您应该安排一个微任务。
为了安排微任务,开发人员有时会使用立即解决的 Promise。然而,这种方法有几个缺点:它是一个技巧,并且需要额外的开销来创建和清理承诺。
因此,如果您需要创建微任务,请使用该queueMicrotask()
函数。
MDN说:
该方法在Window或WorkerqueueMicrotask()
接口上公开,在控制权返回到浏览器的事件循环之前,将微任务排队以在安全时间执行。
句法:
js
queueMicrotask(() => {
// function contents here
});
为什么可能需要创建微任务?比如批量操作。请看MDN中的这个示例:
js
const messageQueue = [];
let sendMessage = (message) => {
messageQueue.push(message);
if (messageQueue.length === 1) {
queueMicrotask(() => {
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
fetch("url-of-receiver", json);
});
}
};
无论该sendMessage()
函数被调用多少次,消息只会在所有调用之后发送一次(在同一任务中进行)。
5. 空闲期
当所有任务和微任务完成后,浏览器进入所谓的空闲期。对于那些想要编写高效代码的人来说,您需要利用这一点。空闲期是执行低优先级工作的最佳时间。
要在浏览器空闲期间安排任务,请使用以下方法:
window.requestIdleCallback(callback, options)
在选项中,您可以传递超时参数 - 调用回调之前的最大毫秒数。如果在此时间之后尚未调用回调,则执行回调的任务将在事件循环中排队,即使这会对性能产生负面影响。
让我们看一些例子。
6. 测试
js
requestIdleCallback(() => console.log(1));
setTimeout(() => console.log(2));
setTimeout(() => console.log(3));
queueMicrotask(() => console.log(4));
解释:
- 脚本执行完毕后,就轮到微任务了。我们有一个微任务在等待------
Promise
回调。 - 然后是任务队列中的第一个任务。这是一个
setTimeout
输出的回调2
。 - 由于不再有微任务,浏览器会再次执行该任务------第二次
setTimeout
回调。
两个队列都是空的,这意味着该执行了requestIdleCallback
。结果如下:4, 2, 3, 1
js
new Promise((resolve) => setTimeout(() => resolve(), 1000))
.then(() => console.log('promise'));
setTimeout(() => {
console.log('timeout');
}, 1000);
requestIdleCallback(() => {
console.log('requestIdleCallback');
}, { timeout: 1000 });
解释:
- 脚本执行后第一秒事件循环处于空闲状态,因此有时间执行回调
requestIdleCallback
。 - 其主体
promise
是同步执行的。因此,在两者中setTimeouts
,浏览器将首先将setTimeout
其promise
放入任务队列中。这意味着它也将首先执行。 - 执行这个
setTimeout
回调会创建一个新的微任务------Promise
回调。 - 任务完成。是时候完成所有微任务了。
Promise
将执行上一步中创建的回调。 - 浏览器跳转到最后一个任务------剩余的
setTimeout
回调。所以结果是:requestIdleCallback
,promise
,timeout
7. 你知道在你的代码中有多少个事件循环吗?
iframe内的繁重同步代码会延迟父脚本中setTimeout cb的执行吗?
js
const iframe = document.createElement('iframe');
const html = `<body>
Hello from iframe
<script>
for (let i = 0; i < 10000000000; i++) {}
</script>
</body>`;
iframe.src = 'data:text/html;charset=utf-8,' + encodeURI(html);
iframe.type = 'text/javascript';
document.body.append(iframe);
setTimeout(() => console.log('setTimeout'), 100);
要回答这个问题,唯一需要知道的是 iframe 和父脚本是否共享相同的事件循环或具有不同的事件循环。
如果它们共享一个事件循环,那么繁重的同步操作会阻塞父脚本,就像它在其中一样。否则,此操作将仅阻塞 iframe 任务。
事实证明,具有相同起源的 iframe 与父级共享一个事件循环。此外,即使同源的不同选项卡也可以共享相同的事件循环。例如,如果第二个选项卡是用window.open()
.
在这种情况下,需要特别小心长时间运行的任务,因为它们可能会阻止其他窗口的渲染。
但是,你的页面仍然可以有多个事件循环。每个单独的线程都有自己的事件循环。Worklet 和 Workers 有自己的事件循环。
点赞收藏支持、手留余香、与有荣焉,动动你发财的小手哟,感谢各位大佬能留下您的足迹。
往期热门精彩推荐
面试相关热门推荐
实战开发相关推荐
移动端相关推荐
Git 相关推荐
更多精彩详见:个人主页