15. 异步:事件循环

  1. 怎么理解同步与异步?

JavaScript 在执行时有 同步 方式和 异步 方式之分。异步方式的执行主要是为了解决 阻塞 问题。

同步方式执行时,一条语句执行完毕,下一条语句接着执行,顺序就是从上到下。

  1. const t0 = performance.now();
  2. console.log(t0);
  3. // -> 45.293099999427795
  4. const t1 = performance.now();
  5. console.log(t1);
  6. // -> 49.464800000190735

在这个例子中,打印 t0 的语句完毕之后,接着打印 t1。这就是我们常见的同步执行方式。

但是同步执行方式容易发生 阻塞 问题。所谓阻塞问题就是一段同步代码的执行时间过长,进而使得这段阻塞代码下方的代码得不到执行。

  1. const t0 = performance.now();
  2. console.log(t0);
  3. // -> 36.92530000209808
  4. let t1 = performance.now();
  5. while (t1 - t0 < 2000) {
  6. t1 = performance.now();
  7. }
  8. console.log(t1);
  9. // -> 2036.925300002098

在这个例子中,执行 while 循环的时间为 2000 ms,这是个很长的时间。等到 while 循环结束之后,打印 t1 的语句才得到执行。

JavaScript 提供了 setTimeout API 可以使得 setTimeout 的回调代码在其他同步代码执行之后执行。

  1. const t0 = performance.now();
  2. console.log(t0);
  3. // -> 49.40900003910065
  4. setTimeout(() => {
  5. const t2 = performance.now();
  6. console.log(t2);
  7. // -> 56.87050002813339
  8. }, 0);
  9. const t1 = performance.now();
  10. console.log(t1);
  11. // -> 55.80070000886917

在这个例子中,虽然 setTimeout 的代码块在 t1 之前,然而它打印出的 t2 时间却是在 t1 之后。这就是异步的执行方式。

  1. 怎么理解事件循环模型?

然而异步方式执行代码背后到底是什么原理呢?这就不得不讲到浏览器的事件循环模型。

众所周知,JavaScript 是一个单线程语言,同一时间只能做一件事。这句话是不是正确的呢?

我们再来看上一节的例子。

  1. const t0 = performance.now();
  2. console.log(t0);
  3. // -> 49.40900003910065
  4. setTimeout(() => {
  5. const t2 = performance.now();
  6. console.log(t2);
  7. // -> 56.87050002813339
  8. }, 0);
  9. const t1 = performance.now();
  10. console.log(t1);
  11. // -> 55.80070000886917

在这个例子中,setTimeout 代码块在一定时间后得到执行,而在 setTimout 计时的同时,其他的同步也在执行。但是根据 JavaScript 是单线程的,计时和执行其他同步代码,这 2 件事根本不能同时执行。所以,合理的解释是还有其他线程参与了 setTimeout 代码块的计时工作。

实际上在执行 JavaScript 脚本时,浏览器中有各种线程参与了这个过程。其中主线程负责 JavaScript 的执行以及网页的渲染工作。而还有一些其他线程,这些线程负责 setTimeout 的计时工作,网络请求的工作等。而提供这些非主线程工作的浏览器组件称为 Web API。

而提供 JavaScript 执行的组件包括堆内存和调用栈。调用栈我们在讲解递归时,已经详细讲过了。堆内存则负责对象的存储。

然而还存在另外一个组件: 事件队列。这个组件的作用我们接下来会讲到。

现在再来看看上面这个例子。

当执行到 setTimout 代码块时,主线程将 setTimeout 代码块移交给了 Web API 中的 setTimeout 计时线程。之后,主线程继续执行其他同步代码,同时 setTimeout 计时线程正在计时,当时间到了之后,setTimeout 将回调转移至事件队列。直到调用栈为空时,事件队列中的 setTimeout 回调才进入调用栈执行。

如此我们看到,尽管 setTimeout 的延迟为 0,其回调也不会立即执行,而是等到调用栈为空时才进入调用栈执行。

  1. function foo() {
  2. setTimeout(foo);
  3. }
  4. foo();

以上代码虽然无限制地递归调用 foo 函数,但是由于 setTimeout 回调只有在调用栈为空时才进入调用栈。因此可以保证调用栈最多只有 1 个执行上下文,因此不会超出调用栈容量。

  1. 宏任务和微任务是什么?

事件队列中的回调实际上细分为 宏任务微任务。这两者都会等待调用栈为空时才进入调用栈。但是两者的优先级不同。

宏任务回调来源有定时任务,网络请求,脚本解析等。而微任务包括 promise.then() 中的回调,mutationObsever 的回调,queueMicrotask() 的回调,以及 node 中 process.nextTick() 回调等。

事件循环首先执行一个宏任务,这个任务就是执行脚本。之后依次执行所有的微任务。接着开启下一轮事件循环,执行一个宏任务,执行所有的微任务…。并循环下去。

  1. setTimeout(() => console.log(4));
  2. new Promise((f) => {
  3. // promise 初始化为同步执行
  4. console.log(0);
  5. f();
  6. }).then(() => {
  7. console.log(2);
  8. Promise.resolve().then(() => console.log(3));
  9. });
  10. console.log(1);
  11. // 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。

希望读者可以仔细揣摩这个例子。