JavaScript 是单线程的,但提供了很多异步调用方式比如 setTimeout,setInterval,setImmediate,Promise.prototype.then,postMessage,requestAnimationFrame, I/O,DOM 事件等。 这些异步调用的实现都是事件循环,但根据插入的队列不同和取任务的时机不同他们的表现也不同。 尤其在涉及与页面渲染的关系时。
TL;DR
任务与队列的概念 JavaScript 的异步机制由 事件循环 实现,这些 API 的不同表现在进入和离开任务队列的时机。 为了讨论方便,先解释几个概念。
上述异步 API 的分类依据的是最新标准或最新实现。存在一些例外,比如:Node < 9 的 process.nextTick 实现的是 Task 语义(而非 Microtask);IE8 中的 postMessage 是同步的;Edge 浏览器在点击事件处理函数之间不会清空 Microtask Queue。
无论是 Task Queue 还是 Microtask Queue,其中的 task 和 microtask 的执行都是异步的。 为了对上述两个队列有更直观的认识,这里举个例子:
setTimeout(() => console.log('setTimeout'));
Promise.resolve().then(() => {
console.log('Promise');
Promise.resolve().then(() => console.log('Promise queued by Promise'));
});
console.log('stack');
上述代码片段中有两个Task(stack, setTimeout),两个 Microtask(Promise、Promise queued by Promise)。 stack 是当前任务会先执行;setTimeout 是第二个任务,在它执行前会清空 Microtask Queue。 这时 Microtask Queue 只有一个 Microtask(Promise), 在它执行的过程中会插入第二个 Microtask(Promise queued by Promise)。 这些 Microtask 都会在下一个 Task(setTimeout)之前执行。因此输出为:
stack
Promise
Promise queued by Promise
setTimeout
UI 渲染和交互的处理是通过 Task Queue 来调度的,因此耗时任务会导致渲染和交互任务得不到调用,也就是页面“卡死”。 典型的浏览器会在每秒插入 60 个渲染帧,也就是说每 16ms 需要一次渲染。 如果存在一个任务在 16ms 内未能执行结束,页面就会掉帧给人卡顿的感觉。
在 “Loop for 10 seconds” 部分我们写了 4 种不同的循环,它们的表现如下:
循环 API | 队列类型 | 期间页面能否交互 * | 每秒执行次数 |
---|---|---|---|
while(true) | 当前任务 | 否 | 701665.8 |
Promise | Microtask Queue | 否 | 609555.4 |
setTimeout | Task Queue | 是 | 208.3 |
requestAnimationFrame | Task Queue | 是 | 59 |
页面不可交互是指:无法点击其他按钮、无法操作输入控件、无法选择/赋值页面文本。 以 PC Chrome 为例,iOS Safari 尤其是 UIWebview 的表现可能会不同。 单个的耗时任务和 Microtask Queue 都会阻塞页面交互,Task 则不影响。 因为 Task 之间浏览器有机会会插入 UI 任务。 这里还可以观察到 setTimeout 虽然设置了 0 延时但调用次数远小于 while,甚至远小于 Microtask。 下文 setImmediate 章节会详细讨论原因。
有时我们希望精确地控制浏览器在每一帧的绘制,这时就要了解浏览器绘制的时机。 首先举个例子,我们希望页面背景闪现一下红色:
document.body.style.background = 'red';
document.body.style.background = 'white';
上述代码一定达不到效果,背景会稳定地呈现白色。 因为 JavaScript “run-to-completion” 的特性,在上述两行代码之间不可能插入渲染任务。 这时可能有人想到 setTimeout:
document.body.style.background = 'red';
setTimeout(function () {
document.body.style.background = 'white';
})
这样两次背景设置会在不同的任务中执行,如果这两个任务之间插入了渲染任务背景就会发生闪动。 但渲染任务是 16ms 一次,你怎么知道浏览器会正好插入在这两个任务之间? 因此上述代码只会几率性起作用,背景闪动的几率大概 4/16.67 = 25%。 16.67 是渲染帧间隔,那为什么是 4ms 呢?请看下文 setImmediate。
想要增大几率到 100% 怎么办?setTimeout 100ms 呗… 其实 HTML5 中给出了 requestAnimationFrame API,使得脚本有机会精确地控制动画:
requestAnimationFrame(function () {
document.body.style.background = 'red';
requestAnimationFrame(function () {
document.body.style.background = 'white';
})
})
所以 setImmediate 是啥 setImmediate 是由 IE 提出的, 目前尚未形成标准。当前状态是 Proposal 且只有 IE 有实现。 setImmediate 是为了让脚本更快地执行,与 setTimeout 一样都使用 Task Queue。 为了解 setImmediate 的用途,我们先看 setTimeout 为什么不够快。 下面的文本来自 HTML5 Living Standard 的 timer initialization steps:
If timeout is less than 0, then set timeout to 0. If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4. Increment nesting level by one.
其中 nesting level 是指 timer nesting level, 每一级可能是 setTimeout 也可能是 setInterval。也就是说在嵌套 5 层以上时,会设置最小 4ms 的延迟。 setImmediate 意在让脚本有机会在 UA 事件和渲染发生后立即得到调用,从渲染的角度上类似于渲染之后调用的 requestAnimationFrame。 由于没有广泛实现,使用 setImmediate 需要引入 Polyfill。请参考:
https://github.com/YuzuJS/setImmediate/blob/master/README.md 插入的任务会在每次渲染任务之前执行,因此等待渲染之后需要调用两次来插入到第二次渲染之前。 这样背景一定会闪现红色。