15. 异步:事件循环
- 怎么理解同步与异步?
JavaScript 在执行时有 同步 方式和 异步 方式之分。异步方式的执行主要是为了解决 阻塞 问题。
同步方式执行时,一条语句执行完毕,下一条语句接着执行,顺序就是从上到下。
const t0 = performance.now();
console.log(t0);
// -> 45.293099999427795
const t1 = performance.now();
console.log(t1);
// -> 49.464800000190735
在这个例子中,打印 t0 的语句完毕之后,接着打印 t1。这就是我们常见的同步执行方式。
但是同步执行方式容易发生 阻塞 问题。所谓阻塞问题就是一段同步代码的执行时间过长,进而使得这段阻塞代码下方的代码得不到执行。
const t0 = performance.now();
console.log(t0);
// -> 36.92530000209808
let t1 = performance.now();
while (t1 - t0 < 2000) {
t1 = performance.now();
}
console.log(t1);
// -> 2036.925300002098
在这个例子中,执行 while 循环的时间为 2000 ms,这是个很长的时间。等到 while 循环结束之后,打印 t1 的语句才得到执行。
JavaScript 提供了 setTimeout API 可以使得 setTimeout 的回调代码在其他同步代码执行之后执行。
const t0 = performance.now();
console.log(t0);
// -> 49.40900003910065
setTimeout(() => {
const t2 = performance.now();
console.log(t2);
// -> 56.87050002813339
}, 0);
const t1 = performance.now();
console.log(t1);
// -> 55.80070000886917
在这个例子中,虽然 setTimeout 的代码块在 t1 之前,然而它打印出的 t2 时间却是在 t1 之后。这就是异步的执行方式。
- 怎么理解事件循环模型?
然而异步方式执行代码背后到底是什么原理呢?这就不得不讲到浏览器的事件循环模型。
众所周知,JavaScript 是一个单线程语言,同一时间只能做一件事。这句话是不是正确的呢?
我们再来看上一节的例子。
const t0 = performance.now();
console.log(t0);
// -> 49.40900003910065
setTimeout(() => {
const t2 = performance.now();
console.log(t2);
// -> 56.87050002813339
}, 0);
const t1 = performance.now();
console.log(t1);
// -> 55.80070000886917
在这个例子中,setTimeout 代码块在一定时间后得到执行,而在 setTimout 计时的同时,其他的同步也在执行。但是根据 JavaScript 是单线程的,计时和执行其他同步代码,这 2 件事根本不能同时执行。所以,合理的解释是还有其他线程参与了 setTimeout 代码块的计时工作。
实际上在执行 JavaScript 脚本时,浏览器中有各种线程参与了这个过程。其中主线程负责 JavaScript 的执行以及网页的渲染工作。而还有一些其他线程,这些线程负责 setTimeout 的计时工作,网络请求的工作等。而提供这些非主线程工作的浏览器组件称为 Web API。
而提供 JavaScript 执行的组件包括堆内存和调用栈。调用栈我们在讲解递归时,已经详细讲过了。堆内存则负责对象的存储。
然而还存在另外一个组件: 事件队列。这个组件的作用我们接下来会讲到。
现在再来看看上面这个例子。
当执行到 setTimout 代码块时,主线程将 setTimeout 代码块移交给了 Web API 中的 setTimeout 计时线程。之后,主线程继续执行其他同步代码,同时 setTimeout 计时线程正在计时,当时间到了之后,setTimeout 将回调转移至事件队列。直到调用栈为空时,事件队列中的 setTimeout 回调才进入调用栈执行。
如此我们看到,尽管 setTimeout 的延迟为 0,其回调也不会立即执行,而是等到调用栈为空时才进入调用栈执行。
function foo() {
setTimeout(foo);
}
foo();
以上代码虽然无限制地递归调用 foo 函数,但是由于 setTimeout 回调只有在调用栈为空时才进入调用栈。因此可以保证调用栈最多只有 1 个执行上下文,因此不会超出调用栈容量。
- 宏任务和微任务是什么?
事件队列中的回调实际上细分为 宏任务 和 微任务。这两者都会等待调用栈为空时才进入调用栈。但是两者的优先级不同。
宏任务回调来源有定时任务,网络请求,脚本解析等。而微任务包括 promise.then() 中的回调,mutationObsever 的回调,queueMicrotask() 的回调,以及 node 中 process.nextTick() 回调等。
事件循环首先执行一个宏任务,这个任务就是执行脚本。之后依次执行所有的微任务。接着开启下一轮事件循环,执行一个宏任务,执行所有的微任务…。并循环下去。
setTimeout(() => console.log(4));
new Promise((f) => {
// promise 初始化为同步执行
console.log(0);
f();
}).then(() => {
console.log(2);
Promise.resolve().then(() => console.log(3));
});
console.log(1);
// 1, 2, 3, 4
在这个例子中。第 1 轮事件循环的宏任务为执行整个脚本。在执行脚本时,setTimeout(() => console.log(4));
语句的回调进入宏任务队列。而接下来实例化了一个 promise 对象,它的执行器函数同步执行,因此先打印出 0。后面 then 语句接着执行,它的作用是使得回调进入微任务队列,于是 console.log(2);Promise.resolve().then(() => console.log(3));
进入了微任务队列。接着执行,打印出 1。此时,宏任务,即执行整个脚本已经完毕。
此时的微任务队列只有一个回调: console.log(2); Promise.resolve().then(() => console.log(3));
于是执行这个回调,打印出 2,接着使用 Promise.resolve() 方法创建了一个 promise,接着它的 then 回调使得回调 () => console.log(3)
进入微任务队列。微任务队列的第 1 个回调执行完毕,但是又多出一个回调: () => console.log(3)
,于是接着执行,打印出 3。至此,第 1 轮事件循环已经完毕。
第 2 轮事件循环中,发现只有一个宏任务回调: () => console.log(4)
,于是执行这个回调,打印出 4。而微任务队列为空。于是第 2 轮事件循环完毕。最终结果就是: 1, 2, 3, 4。
希望读者可以仔细揣摩这个例子。