前言
浏览器中 JavaScript 的执行流程和 Node.js 中的流程都是基于 事件循环 的。
理解事件循环的工作方式对于代码优化很重要,有时对于正确的架构也很重要。
并发模型与事件循环
JavaScript 有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。
- 执行代码(
<script></script>中的代码会立即执行) - 收集和处理事件(如:
mousemove) - 执行队列中的子任务(如:
setTimeout)
js引擎(如:V8,jscore)是单线程的,如何并发执行,并发模型又是什么呢? 解答这个问题,大家得先了解并发和并行的概念,以及浏览器的多进程架构。
并发指的是代码交替执行,js引擎同一时刻还是只能执行一个任务。而JS异步处理的能力,是由浏览器渲染进程(定时器线程和HTTP请求线程——执行异步事件,并将回调加入到事件队列中)提供的。
MDN提供的并发模型:
如图,js在执行过程中会创建:
- 调用栈(或执行栈):调用函数时,调用栈会创建帧(存储函数的执行上下文),并加入到栈中。
- 消息队列:JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
- 堆内存:对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
对象被分配在堆中,怎么理解?
js在执行代码前,会有一个预编译的过程,即JIT(Just-in-time compilation)。预编译过程中,会对全局作用域中的所有变量和函数的声明进行初始化(这一过程也叫变量提升),包括函数参数。代码开始执行时,会依次进行赋值,函数赋值的是一个堆内存中地址(函数体存放在堆内存中),引用数据类型(数组,{})也一样,闭包中被内部函数使用的私有变量也是。
事件循环
之所以称之为 事件循环,是因为它经常按照类似如下的方式来被实现:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
queue.waitForMessage() 会同步地等待消息到达 (如果当前没有任何消息等待被处理)。
如果消息队列有消息,执行栈会等待消息到达并执行。而消息队列也叫任务队列,分为宏任务队列和微任务队列,它们加入执行栈的时机是怎样的呢?
在此之前,我们先了解下宏任务和微任务。
- 宏任务
- 事件触发的回调函数,例如
DOM Events、I/O、requestAnimationFrame setTimeout、setInterval的回调函数- 一段新程序或子程序被直接执行时(比如从一个控制台,或在一个
<script>元素中运行代码)。
- 事件触发的回调函数,例如
- 微任务
- promises:
Promise.then、Promise.catch、Promise.finally - MutationObserver:提供了监视对 DOM 树所做更改的能力。
- queueMicrotask:将回调加入微任务队列。
- process.nextTick:Node独有
- promises:
更详细的事件循环图示如下(顺序是从上到下,即:首先是脚本,然后是微任务,渲染等):
官网解释:每当一个任务存在,事件循环都会检查该任务是否正把控制权交给其他 JavaScript 代码。如若不然,事件循环就会运行微任务队列中的所有微任务。接下来微任务循环会在事件循环的每次迭代中被处理多次,包括处理完事件和其他回调之后。
微任务会在 执行任何其他事件处理、或渲染、或执行任何其他宏任务 之前完成。
这很重要,因为它确保了微任务之间的应用程序环境基本相同(没有鼠标坐标更改,没有新的网络数据等)。
如果我们想要异步执行(在当前代码之后)一个函数,但是要在更改被渲染或新事件被处理之前执行,那么我们可以使用 queueMicrotask 来对其进行安排(schedule),避免使用promise。
通过引入
queueMicrotask(),由晦涩地使用 promise 去创建微任务而带来的风险就可以被避免了。举例来说,当使用 promise 创建微任务时,由回调抛出的异常被报告为 rejected promises 而不是标准异常。同时,创建和销毁 promise 带来了事件和内存方面的额外开销,这是正确入列微任务的函数应该避免的。
我们通过示例来解释宏任务和微任务执行的时机:
console.log('1');
setTimeout(function() {
console.log('2');
window.queueMicrotask(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
window.queueMicrotask(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
window.queueMicrotask(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
结果为:1 7 6 8 2 4 3 5 9 11 10 12
具体执行步骤如下:(假定从宏任务队列中取一次消息为一次事件循环的开始)
- 加载完js之后,它也会被当做一个异步任务加入宏任务队列中,此时执行栈为空,第一次事件循环开始。取出其中的代码执行,遇到setTimeout, queueMicrotask, Promise.then时,统统加入浏览器的定时器线程或其他异步线程中,执行完毕后将回调加入到各自的消息队列。所以执行栈中第一次执行完毕打印:
1 7。此时微任务队列中有queueMiscrotask,Promise.then执行完成后的回调;宏任务队列中有二个setTimeout的回调。
- 执行栈执行完毕首次为空后,会执行微任务中的所有任务,再开始下一次事件循环:6 8
- 进行第二次事件循环,取setTimeout执行:2 4
- 执行完成后,取所有微任务执行: 3 5
- 进行第三次事件循环,取setTimeout执行:9 11
- 执行完成后,取所有微任务执行:10 12
通过以上结果可以知道宏任务是一个接一个取出来执行的,微任务是一次全部执行完的。
了解了宏任务和微任务,如何通过它们来优化代码呢?
代码优化
由于js代码和浏览器的用户界面(即UI渲染)共享一个事件循环。假如你的代码阻塞了或者进入了无限循环,则浏览器将会卡死。无论是由于 bug 引起还是代码中进行复杂的运算导致的性能降低,都会降低用户的体验。
当来自多个程序的多个代码对象尝试同时运行的时候,一切都可能变得很慢甚至被阻塞,更不要说浏览器还需要时间来渲染和绘制网站和 UI、处理用户事件等。
如何解决阻塞问题呢?
上述代码使用setTimeout可以实现类似进度条的效果,而queueMicrotask却像同步代码一样会造成卡顿。
为何setTimeout可以避免卡顿?count执行过程中,浏览器还可以正常使用?
这是因为setTimeout将count任务拆分之后,会将回调加入到宏任务队列中,只有在js引擎空闲时才会执行,所以不会引起阻塞。setTimeout是一个接一个被执行的,每个执行完之后页面都会进行渲染(或执行同步代码,微任务,用户事件回调等),进而你才可以看到类型进度条的效果,以及鼠标点击事件的正常执行。 而queueMicrotask虽然也对任务进行了拆分,但是它们需要在下一次事件循环之前全部完成,所以看起来和同步代码一样。
知道了setTimeout的用法,何时使用微任务呢?
何时使用微任务
通过上述示例,我们知道微任务是一次全部执行完的,如果大量使用微任务,将会带来性能方面的问题,应避免过多使用微任务。
我们来看看微任务特别有用的场景。通常,这些场景关乎捕捉或检查 结果、执行清理等;其时机晚于一段 JavaScript 执行上下文主体的退出,但早于任何事件处理函数、timeouts 或 intervals 及其他回调被执行。
何时是那种有用的时候?
使用微任务的最主要原因简单归纳为:确保任务顺序的一致性,即便当结果或数据是同步可用的,也要同时减少操作中用户可感知到的延迟而带来的风险。
保证条件性使用 promises 时的顺序
微任务可被用来确保执行顺序总是一致的一种情形,是当 promise 被用在一个 if...else 语句(或其他条件性语句)中、但并不在其他子句中的时候。考虑如下代码:
customElement.prototype.getData = url => {
if (this.cache[url]) {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
)};
}
};
这段代码带来的问题是,通过在 if...else 语句的其中一个分支(此例中为缓存中的图片地址可用时)中使用一个任务而 promise 包含在 else 子句中,我们面临了操作顺序可能不同的局势;比方说,像下面看起来的这样:
element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data...");
element.getData();
console.log("Data fetched");
连续执行两次这段代码会形成以下两种结果:
数据未缓存
Fetching data
Data fetched
Loaded data
数据已缓存
Fetching data
Loaded data
Data fetched
我们可以通过在 if 子句里使用一个微任务来确保操作顺序的一致性,以达到平衡两个子句的目的:
customElement.prototype.getData = url => {
if (this.cache[url]) {
queueMicrotask(() => {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
)};
}
};
批处理操作
你还可以使用微任务将来自不同来源的多个请求收集到一个批次中,避免了多次调用处理同类工作可能涉及的开销。
下面的片段创建了一个函数,将多条消息分批放入一个数组中,当上下文退出时,使用一个微任务将其作为一个单一对象发送。
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() 时,会将消息放入 messageQueue 中,并执行微任务。后续调用只会将消息继续插入 messageQueue 中,而微任务并不会执行,因为 messageQueue.length > 1 。当执行栈为空,开始执行微任务时,注意微任务回调函数,它会将 messageQueue 转为JSON字符串,而此时 messageQueue 中存放了多条消息,然后通过fetch发送出去。
上述示例多次调用 sendMessage ,却只执行了一次微任务(queueMicrotask),但消息一直在收集中。等到微任务开始执行时,才发送出去,达到了批处理的要求。