浏览器上JavaScript 的执行流程和 Node.js上一样,都是基于事件循环的。
理解事件循环如何工作对于性能优化非常重要,对于正确的架构设计有时也是。
在本文,我们首先介绍有关事件循环是如何工作的理论细节,然后看看这些知识的实际应用。
事件循环
事件循环的概念非常简单。这是一个无休止的循环,JavaScript 引擎等待任务,执行它们,然后休眠,等待更多的任务。
引擎的通用算法:
- 当有任务:从最早的任务开始执行他们
- 休眠到新任务出现,然后转到1.
这是我们浏览页面所看到的内容的形式化。JS引擎大部分时候什么也不干,只在script/handler/事件激活时运行。
任务实例:
- 当加载一个外部脚本
- 当用户移动鼠标时,任务是派发
mousemove事件和执行事件处理程序 - 当预定的时间到期时
setTimeout, 任务是运行它的回调 - 等等
任务是集合——引擎处理它们——然后等待更多的任务(在休眠时接近于零 CPU消耗 )。
可能会发生这样的情况: 在引擎忙的时候来了一个任务,那它就需要排队了。
这些任务形成一个队列,即所谓的“宏任务队列”(v8术语):
例如,当引擎忙于执行一个脚本时,用户可能移动他们的鼠标触发 mousemove,setTimeout 可能到期,等等,这些任务形成一个队列,如上图所示。
队列中的任务按照“先到先得”的原则处理。当引擎浏览器加载完脚本时,它将处理 mousemove 事件,然后setTimeout 处理程序,以此类推。
到目前为止,很简单,对吧?
还有两个细节:
- 当引擎执行任务时,渲染不会发生。任务是否需要很长时间并不重要。只有在任务完成后,才会绘制对 DOM 的更改。
- 如果一个任务花费的时间很长,浏览器就不能执行其他任务,比如处理用户事件。因此,过一段时间后,它会发出一个类似“页面未响应”的警告,并建议删除整个页面上的任务。当有大量复杂的计算或编程错误导致死循环,就会发生这种情况。
理论就是这些。现在让我们看看我们如何应用这些知识。
用例1: 分割 cpu 繁忙的任务
假设我们有一个非常需要 cpu 的任务。
举例,代码语法高亮,js引擎执行分析,创建许多有颜色的元素,并将它们添加到文档中——因为文本量大,需要花费大量的时间。
当引擎忙于语法高亮时,它不能做其他与 dom 相关的事情,处理用户事件等等。它甚至可能导致浏览器“打嗝” ,甚至“挂起”一会儿,这些是不可接受的。
我们可以通过将大任务分解来避免问题。突出显示前100行,然后为后100行调用 setTimeout (有零延迟) ,以此类推。
为了简单演示这种方法,我们不使用文本高亮显示,而使用一个从1到1000000000计数的函数。
如果您运行下面的代码,引擎将“挂起”一段时间。对于服务器端的 JS,这是显而易见的,如果你在浏览器中运行它,然后尝试点击页面上的其他按钮-你会看到没有其他事件得到处理,直到计数结束。
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
浏览器甚至可能会显示“脚本需要太长时间”的警告。
让我们使用嵌套的 setTimeout 调用来分割作业:
let i = 0;
let start = Date.now();
function count() {
// do a piece of the heavy job (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // schedule the new call (**)
}
}
count();
现在浏览器界面在“计数”过程中是完全可用的。
单次运行的 count 执行作业的一部分() ,然后根据需要重新调度自身( *)
- 第一次运行计数:
i=1...1000000 - 第二轮计数:
i=1000001..2000000 - 以此类推
现在,如果在引擎忙于执行第1部分时出现了一个新的副任务(例如 onclick 事件) ,它会进入队列,然后在第1部分完成时执行,在下一部分之前。在计数执行之间定期返回事件循环,为 JavaScript 引擎提供足够的“空气”来执行其他操作,对其他用户操作做出反应。
值得注意的是,这两个变体——有 setTimeout 分割作业和没有分割作业——在速度上相当。总的计数时间没有多大差别。
为了让他们更接近,让我们做一个改进。
我们将把日程安排移到count() 的开始部分:
let i = 0;
let start = Date.now();
function count() {
// move the scheduling to the beginning
if (i < 1e9 - 1e6) {
setTimeout(count); // schedule the new call
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
}
}
count();
现在,当我们开始count(),发现我们需要count()更多时,我们会立即安排,然后再开始工作。
如果你运行了,很容易注意到它花费的时间明显更少。
为什么?
这很简单:你知道,对于许多嵌套的 setTimeout 调用,浏览器内的最小延迟为4毫秒。即使我们设置0,也是4毫秒(或者更多一点)。所以我们安排得越早,它就运行得越快。
最后,我们已经将一个 cpu 饥渴的任务分解为几个部分——现在它不会阻塞用户界面。而且它的总执行时间也不会太长。
用例2: 进度指示
为浏览器脚本分割繁重任务的另一个好处是,我们可以显示进度。
正如前面提到的,只有在当前运行的任务完成后,才会绘制对 DOM 的更改,而不管当前任务他需要多长时间。
一方面,这很好,因为我们的函数可能会创建许多元素,将它们逐一添加到文档中,并改变它们的样式——访问者不会看到任何“中间”的未完成状态。这很重要,不是吗?
下面是demo,在函数完成之前 i 不会显示,所以我们只能看到最后一个值:
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
但是我们可能想要在过程中显示一些东西 ,比如进度条。
如果我们用setTimeout切割任务为碎片,那么变化会被渲染在碎片之间。
如下:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
现在< div > 显示 i 的增加值,类似于进度条。
用例3: 在事件之后做一些事情
在事件处理程序中,我们可能决定推迟某些操作,直到事件冒泡并在所有级别上得到处理。我们可以通过将代码封装为零延迟 setTimeout 来实现这一点。
在“调度自定义事件”一章中,我们看到了一个示例: 在 setTimeout 中调度自定义事件menu-open,以便在完全处理“ click”事件之后发生。
menu.onclick = function() {
// ...
// create a custom event with the clicked menu item data
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
// dispatch the custom event asynchronously
setTimeout(() => menu.dispatchEvent(customEvent));
};
宏任务和微任务
除了本章描述的宏任务外,还有微任务,在微任务一章中提到过。
微任务完全来自我们的代码。它们通常是由promises创造出来的:.then/catch/finally处理程序变成了一个微任务。微任务也被await“掩盖”,因为它是另一种形式的promise处理。
还有一个特殊函数 queueMirotask (func) ,它将 func 排队以便在微任务队列中执行。
在每个宏任务之后,引擎立即执行来自微任务队列的所有任务,然后运行任何其他宏任务或渲染或其他任何事情。
例如:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
- code 先显示,因为这是一个常规的同步调用。
- promise 第二个显示,因为.then 通过微任务队列,在当前代码后运行。
- timeout 最后显示,因为这是宏任务。
更丰富的事件循环图像是这样的(顺序是从上到下,即: 先是脚本,然后是微任务,再是渲染等) :
所有微任务在任何其他事件处理或渲染或任何其他宏任务发生之前完成。
这样很重要,因为它保证了微任务之间的应用程序环境基本上是相同的(没有鼠标坐标更改,没有新的网络数据,等)。
如果我们希望异步执行一个函数(在当前代码之后) ,但是在呈现更改或处理新事件之前,我们可以使用 queueMicrotask 对其进行调度。
下面是一个示例,使用了“ counting progress bar” ,与前面所示的类似,但是使用了 queueMicrotask 而不是 setTimeout。你可以看到它在最后呈现。就像同步代码一样:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e6) {
queueMicrotask(count);
}
}
count();
</script>
小结
一个更详细的事件循环算法(尽管与规范相比仍然简化了) :
-
从宏任务队列,出队并运行最早的任务 (e.g. “script”)
-
执行全部microtasks微任务:
-
若微任务队列不是空的:
-
出队列并运行最早的微任务
-
-
-
如果有变化,渲染变化
-
如果宏任务队列为空,则等待直到出现宏任务
-
进入第一步
安排一个新的宏任务:
-
使用零延迟`setTimeout(f)`
这可以用来将计算量大的任务拆分成若干部分,使浏览器能够对用户事件做出反应,并显示它们之间的进度。
也可以用于事件被全部处理后,事件处理安排调度操作(冒泡结束)。
安排一个新的微任务
- 使用queueMicrotask(f)
- promise 处理程序也遍历微任务队列
微任务之接没有UI 或 网络事件处理:他们一个挨着一个地立即执行。
因此,可能想要用queueMicrotask去异步执行某函数,在环境状态下。
Web Workers
不让长时间重量级计算阻碍事件循环,我们用Web Workers。
这是在另一个并行线程中运行代码的一种方式。
Webworkers 可以与主进程交换消息,但是它们有自己的变量和事件循环。
Web Workers 不能访问 DOM,因此它们主要用于计算,可以同时使用多个 CPU 内核。