Skip to content
TOC

渲染进程的主线程需要处理各种各样的任务,JS 脚本,DOM 渲染,布局,CSS 计算,事件等等,V8 引擎也是在主线程上运行的。

这就要一套系统来维持主线程的运转,这就是事件循环。

事件循环和消息队列

如果是你,你会如何设计这个系统?

1、第一版

通常来说,主线程会按照顺序执行任务,如果是一些确定的任务还好,但是这就有一个缺点了,就是无法执行临时新到的新任务?如何才能执行?这就需要事件循环机制了。

2、第二版

加入循环的机制,等待任务的到来。来一个,执行一个。

缺点:其他的线程无法把任务给主线程执行?

3、第三版

引入「消息队列」,主线程接收到的 IO 线程的任务,装入消息队列,主线程循环从消息队列中取任务执行。

宏任务,微任务

加入到消息队列的任务种类很多,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

但是由于消息队列先进先出的特性,导致一些实时性比较高的任务,如果加入的晚,就不能及时执行。比如一个 DOM 的变化,如果前面任务执行的很长时间,界面就不能得到及时的渲染。

解决办法:微任务。

通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,这就保证了 DOM 的变化,页面能够及时响应。

能放进微任务的都是异步执行的任务。

每个宏任务都关联了一个微任务队列。

那么接下来,我们就需要分析两个重要的时间点——微任务产生的时机和执行微任务队列的时机。

微任务产生的方式:

  1. 使用 MutationObserver 监控某个 DOM 节点的变化
  2. 使用 Promise

执行时机:通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

如何理解 js 的事件循环机制?

JavaScript 的事件循环机制(Event Loop)是 JavaScript 处理异步操作的核心。理解这个机制有助于你更好地理解 JavaScript 的执行模型,尤其是如何处理异步代码(如 setTimeoutPromiseasync/await 等)。下面是对事件循环机制的详细解释:

1、JavaScript 单线程模型

JavaScript 是单线程的,这意味着同一时间内只能执行一个任务。这带来了一个问题,如果遇到耗时的操作(如网络请求、文件读取),就会阻塞其他代码的执行。为了解决这个问题,JavaScript 引入了异步编程和事件循环机制。

2、执行栈(Call Stack)

执行栈是 JavaScript 引擎用来存储函数调用的结构。当一个函数被调用时,它会被推入栈中;当函数执行完毕后,它会从栈中弹出。这个过程是同步的,栈中的任务必须一个接一个地完成。

3、异步任务与任务队列(Task Queue)

当 JavaScript 执行异步任务时(如 setTimeoutPromise 等),这些任务不会立即进入执行栈,而是被放入一个任务队列中。当执行栈中的同步任务完成后,事件循环会从任务队列中取出最早加入的任务,并将其推入执行栈中执行。

任务队列分为两种主要类型:

  • 宏任务队列(Macro Task Queue):包括 setTimeoutsetIntervalI/O 操作等。
  • 微任务队列(Micro Task Queue):包括 Promise.thenprocess.nextTick(Node.js)等。

4、事件循环(Event Loop)

事件循环是 JavaScript 用来协调执行栈和任务队列的机制。它会不断检查执行栈是否为空,如果为空,就会检查任务队列中是否有待执行的任务。如果有,事件循环会将任务队列中的第一个任务推入执行栈中执行。

事件循环的顺序如下:

  1. 执行栈中的所有同步代码。
  2. 如果执行栈为空,查看微任务队列并执行所有微任务。
  3. 执行一个宏任务(如 setTimeout)。
  4. 重复上述过程。
举个例子 1:宏任务和微任务执行顺序(简答)
javascript
console.log('Start');

setTimeout(() => {
  console.log('Timeout');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise');
});

console.log('End');

这个例子的执行过程是:

  1. console.log('Start') 进入执行栈并立即执行,输出 Start
  2. setTimeout 是一个宏任务,它会被放入宏任务队列中,并不会立即执行。
  3. Promise.resolve().then 是一个微任务,它会被放入微任务队列中,并且会在所有同步代码执行完后立即执行。
  4. console.log('End') 进入执行栈并立即执行,输出 End
  5. 同步代码执行完毕,执行微任务队列中的任务,输出 Promise
  6. 最后,宏任务队列中的任务执行,输出 Timeout

输出结果为:

Start
End
Promise
Timeout
举个例子 2:宏任务和微任务执行顺序(复杂)

举个例子 2:

js
setTimeout(() => {
  console.log('A');
  Promise.resolve().then(() => {
    console.log('B');
  });
}, 1000);

Promise.resolve().then(() => {
  console.log('C');
});

new Promise((resolve) => {
  console.log('D');
  resolve('');
}).then(() => {
  console.log('E');
});

async function sum(a, b) {
  console.log('F');
}

async function asyncSum(a, b) {
  await Promise.resolve();
  console.log('G');
  return Promise.resolve(a + b);
}

sum(3, 4);
asyncSum(3, 4);
console.log('H');

最后输出:

D
F
H
C
E
G
A
B

其他都好分析,难点在于await Promise.resolve();会干扰输出顺序,后面的代码会被放入微任务队列。

整体的执行顺序分析:

这个代码包含了一些异步操作,如setTimeoutPromiseasync/await。为了理解打印的顺序,我们需要首先明确事件循环的机制,即同步代码优先执行,微任务队列(例如Promise.then)其次,最后是宏任务队列(例如setTimeout)。

我们逐步分析代码执行的顺序:

  1. 同步代码首先执行:
javascript
new Promise((resolve) => {
  console.log('D');
  resolve('');
}).then(() => {
  console.log('E');
});
  • 首先,new Promise 是同步执行的,所以 console.log('D') 会立即执行,输出 D
  • 接着,resolve 会触发 .then,此时的 .then 会被放入微任务队列,稍后执行。
  1. 同步调用的函数:
javascript
async function sum(a, b) {
  console.log('F');
}

sum(3, 4);
  • sum 是普通的同步函数,虽然它是 async,但因为没有 await,所以它的内容是同步执行的。
  • 调用 sum(3, 4),立即输出 F
  1. 处理 asyncSum
javascript
async function asyncSum(a, b) {
  await Promise.resolve();
  console.log('G');
  return Promise.resolve(a + b);
}

asyncSum(3, 4);
  • asyncSum 是异步函数,调用 asyncSum(3, 4) 时,函数会立即执行到 await Promise.resolve(),但因为 await 会暂停执行,console.log('G') 不会立即执行。此时,await 之后的部分(console.log('G'))会被放入微任务队列。
  1. 同步代码继续执行:
javascript
console.log('H');
  • console.log('H') 是同步代码,直接执行并输出 H
  1. 微任务队列(第一个事件循环末尾):
  • 此时,微任务队列中有三个任务:
    1. Promise.resolve().then(() => { console.log('C'); }),会输出 C
    2. new Promise.then(() => { console.log('E'); }),会输出 E
    3. asyncSumawait 后的部分,即 console.log('G'),会输出 G
  1. 宏任务队列(1 秒后):
  • 1 秒后,setTimeout 的回调函数执行,输出 A
  • setTimeout 的回调函数内,有一个微任务:Promise.resolve().then(() => { console.log('B'); })。所以 A 打印完后,立即执行微任务队列中的任务,输出 B

最终输出顺序:

  1. D:来自同步执行的 new Promise
  2. F:来自同步执行的 sum
  3. H:来自同步代码 console.log('H')
  4. C:来自第一个 Promise.resolve().then() 微任务。
  5. E:来自 new Promise.then() 微任务。
  6. G:来自 asyncSumawait 之后部分的微任务。
  7. (1 秒后) A:来自 setTimeout 的回调。
  8. B:来自 setTimeout 回调中的微任务。
举个例子 3:微任务队列的执行顺序
js
// 我们称这为 Chain A
Promise.resolve()
  .then(() => {
    console.log(0);
    return Promise.resolve(4);
  })
  .then((res) => {
    console.log(res);
  });

// 我们称这为 Chain B
Promise.resolve()
  .then(() => {
    console.log(1);
  })
  .then(() => {
    console.log(2);
  })
  .then(() => {
    console.log(3);
  })
  .then(() => {
    console.log(5);
  })
  .then(() => {
    console.log(6);
  });

输出:0 1 2 3 4 5 6

分析:

  1. Promise 链的特性: 每个 .then() 会创建一个新的 Promise,这个 Promise 的解决会被推迟到下一个微任务周期。

  2. 微任务队列的处理: JavaScript 引擎在每个微任务周期中处理当前队列中的所有任务,然后才开始下一个周期。

让我们逐步分析:

  1. Chain A 的第一个 then 执行,输出 0,返回 Promise.resolve(4)。

  2. Chain B 的第一个 then 执行,输出 1。

  3. 第一个微任务周期结束。

  4. 新的微任务周期开始:

    • Chain B 的第二个 then 执行,输出 2。
    • Chain A 中 Promise.resolve(4) 尚未解决,其 then 还不能执行。
  5. 又一个新的微任务周期:

    • Chain B 的第三个 then 执行,输出 3。
    • 此时,Promise.resolve(4) 解决,其 then 回调被添加到当前微任务队列的末尾。
  6. 同一个微任务周期继续:

    • Chain A 的第二个 then 执行,输出 4。
  7. 下一个微任务周期:

    • Chain B 的剩余 then 执行,输出 5 和 6。

关键点在于 Promise.resolve(4) 的解决时机。它是一个微任务,但它的解决并不是立即的,而是在下一个可用的微任务周期中发生。这就是为什么它在 3 之后而不是 2 之后解决。

为什么不是在 5 之后?因为一旦 Promise.resolve(4) 解决,它的 then 回调立即被添加到当前的微任务队列中,而不是等待下一个周期。所以它在同一个周期中,在输出 3 之后就立即执行了。

这个行为展示了 JavaScript 的事件循环如何精确地按顺序处理微任务,以及 Promise 的解决如何影响这个顺序。每个 Promise 的解决都可能在当前微任务周期中添加新的任务,这就是为什么执行顺序可能看起来有些反直觉。

总结

  • JavaScript 采用单线程模型,通过事件循环机制来处理异步任务。
  • 异步任务被分为宏任务和微任务,微任务优先于宏任务执行。
  • 事件循环会不断检查执行栈和任务队列,确保代码按顺序执行。

理解事件循环机制对于编写高效的异步 JavaScript 代码非常重要,特别是在处理复杂的异步逻辑时。

✅ 怎么样算是一个宏任务?一句代码就算一个宏任务吗?

一个宏任务(Macro Task)是指在 JavaScript 事件循环中执行的一整块任务,通常是由宿主环境(如浏览器或 Node.js)调度的。宏任务并不是单纯的一行代码,而是由宿主环境调度并执行的较大的任务块。

1、 什么是宏任务?

宏任务通常包括但不限于以下几类:

  • 整个脚本执行(例如,当浏览器加载并执行一个 <script> 标签中的代码时,这是一个宏任务)。
  • setTimeoutsetInterval 的回调函数。
  • 用户交互事件的回调(如点击事件)。
  • I/O 操作的回调(如网络请求的响应)。
  • postMessage 的回调。
  • setImmediate 的回调(Node.js 特有)。

2、宏任务的执行过程

在事件循环的每一个循环迭代中,通常会:

  1. 执行一个宏任务中的所有代码。
  2. 在当前宏任务完成后,处理微任务队列中的所有微任务。
  3. 然后,再执行下一个宏任务。

3、 一句代码算一个宏任务吗?

一句代码并不等同于一个宏任务。一个宏任务通常是一个更大的执行块,比如一个完整的脚本或一个定时器回调函数。在这个宏任务内,可能包含多行代码。只有当这个宏任务中的代码全部执行完毕后,才会检查并执行微任务,然后再继续执行下一个宏任务。

举个例子:

javascript
console.log('Task 1'); // 这行代码是当前宏任务中的一部分
setTimeout(() => {
  console.log('Task 2'); // 这是下一个宏任务
}, 0);
console.log('Task 3'); // 这行代码也是当前宏任务中的一部分

在上面的代码中:

  • 整个脚本加载并执行,是一个宏任务。
  • setTimeout 的回调是另一个宏任务。

执行顺序

  1. console.log('Task 1')console.log('Task 3') 都是在当前宏任务中执行。
  2. 当前宏任务执行完后,事件循环会处理微任务(如果有),然后执行 setTimeout 的回调函数,这是一个新的宏任务。

Promise 和微任务有啥关系?

Promise 解决的是回调地狱的问题。

Promise 的一般使用如下:

js
function executor(resolve, reject) {
  resolve(100);
}
// 将 Promise 改成我们自己的 Bromsie
let demo = new Bromise(executor);

function onResolve(value) {
  console.log(value);
}
demo.then(onResolve);

当执行到 resolve(100)的时候,实际上会触发 demo.then 设置的回调函数 onResolve,但是根据上面的代码的执行的顺序,很显然,当执行到 resolve(100)的时候,demo.then(onResolve) 还没有执行,就是还没有绑定 onResolve 方法,也就没法打印出 100.

但是最后居然打印了 100,那么在 Promise 的内容到底做了什么魔法处理?

下面是 Promise 简单的实现原理:

js
function Bromise(executor) {
  var onResolve_ = null;
  var onReject_ = null;
  // 模拟实现 resolve 和 then,暂不支持 rejcet
  this.then = function (onResolve, onReject) {
    onResolve_ = onResolve;
  };
  function resolve(value) {
    onResolve_(value);
  }
  executor(resolve, null);
}

如果是这样的话,在执行上面代码的时候就会报错:

Uncaught TypeError: onResolve_ is not a function
    at resolve (<anonymous>:10:13)
    at executor (<anonymous>:17:5)
    at new Bromise (<anonymous>:13:5)
    at <anonymous>:19:12

哎,跟我们的分析是一致,那么要怎么修改一下?这就用到微任务了。

我们需要让 resolve 中的 onResolve* 函数延后执行,可以在 resolve 函数里面加上一个定时器,让其延时执行 onResolve* 函数,你可以参考下面改造后的代码:

js
function resolve(value) {
  setTimeout(() => {
    onResolve_(value);
  }, 0);
}

上面采用了定时器来推迟 onResolve 的执行,不过使用定时器的效率并不是太高.

好在我们有微任务,所以在 Promise 实现原理中,把这个定时器改为微任务了,这样既可以让 onResolve_ 延时被调用,又提升了代码的执行效率。这就是 Promise 与微任务的关系了。

async 和 await

虽然 Promise 极大地改善了异步编程的可管理性,但在某些情况下,使用.then()和.catch()方法来管理复杂的异步流程仍然可能导致代码冗长和难以理解。asyncawait 的引入正是为了满足这种需求,它们允许开发者以一种近乎同步的方式编写异步代码,同时保持非阻塞的优势。

要解决的问题:

asyncawait 主要解决以下问题:

  1. 代码可读性:使异步代码看起来和同步代码相似,这样开发者就可以以线性和更直观的方式理解代码流程,而不需要跟踪 Promise 链。
  2. 错误处理:允许使用传统的 try-catch 语法来捕捉异步代码中的错误,这比在 Promise 中使用.catch()更自然和一致。
  3. 简化代码结构:减少了因为链式 Promise 而导致的嵌套和复杂结构,简化了代码编写。

实现原理:

asyncawait 是基于 Promise 和生成器(generators)的概念实现的。

生成器:

参考链接:05-Generator,async,Class

生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。

js
function* genDemo() {
  console.log('开始执行第一段');
  yield 'generator 2';

  console.log('开始执行第二段');
  yield 'generator 2';

  console.log('开始执行第三段');
  yield 'generator 2';

  console.log('执行结束');
  return 'generator 2';
}

console.log('main 0');
let gen = genDemo();
console.log(gen.next().value);
console.log('main 1');
console.log(gen.next().value);
console.log('main 2');
console.log(gen.next().value);
console.log('main 3');
console.log(gen.next().value);
console.log('main 4');

调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象。

下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。

❓ 那么,Generator 函数可以实现函数的暂停和恢复,是怎么做到的呢?

要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。

协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程

当使用 yield 暂停协程的时候,就会将控制权转交给父协程,也就是调用 Generator 的进程。

❓async 和 await 是如何由 Generator 函数和 Promise 来实现的呢?

看下面示例代码:

js
async function foo() {
  console.log(1);
  let a = await 100;
  console.log(a);
  console.log(2);
}
console.log(0);
foo();
console.log(3);

打印的结果为:0,1,3,100,2

我们站在协程的视角看下为啥是这个顺序:

  1. 首先,执行console.log(0)这个语句,打印出来 0。
  2. 紧接着就是执行 foo 函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,首先执行 foo 函数中的console.log(1)语句,并打印出 1。
  3. 执行到 foo 函数中的await 100这个语句了,当执行到await 100时,会默认创建一个 Promise 对象,代码如下所示:
js
let promise_ = new Promise((resolve,reject){
  resolve(100)
})
  1. 上一节我们知道,resolve(100)会放在微任务列表,然后协程就把控制权转交给父协程,此时打印console.log(3)
  2. 随后父协程将执行结束,在结束之前,执行微任务队列,微任务队列中有resolve(100)打印 100
  3. 最后,打印 2。

❓ 下面思考题融合了宏任务,微任务,定时器,请问下面代码输出什么?

js
async function foo() {
  console.log('foo');
}
async function bar() {
  console.log('bar start');
  await foo();
  console.log('bar end');
}
console.log('script start');
setTimeout(function () {
  console.log('setTimeout');
}, 0);
bar();
new Promise(function (resolve) {
  console.log('promise executor');
  resolve();
}).then(function () {
  console.log('promise then');
});
console.log('script end');

分析:

  1. 首先在主协程中初始化异步函数 foo 和 bar,碰到 console.log 打印 script start;
  2. 解析到 setTimeout,初始化一个 Timer,创建一个 task,并加入延时队列任务
  3. 执行 bar 函数,将控制权交给协程,输出 bar start,碰到 await,执行 foo,输出 foo,创建一个 Promise 返回给主协程,并添加到微任务队列
  4. 向下执行 new Promise,输出 promise executor,返回 resolve 添加到微任务队列
  5. 输出 script end
  6. 当前 task 结束之前检查微任务队列,执行第一个微任务,将控制器交给协程输出 bar end
  7. 执行第二个微任务 输出 promise then
  8. 当前任务执行完毕进入取出延时队列任务,输出 setTimeout。

事件循环案例:setTimeout 如何设计?

setTimeout 的回调不能放到消息队列,因为消息队列是一个个按顺序执行的。那咋办?

在 Chrome 中,除了正常使用的消息队列之外,还有另外一个消息队列延迟队列,这个队列中维护了需要延迟执行的任务列表。

所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务(包含了回调函数 xxx、当前发起时间、延迟执行时间)添加到延迟队列中。

然后,当一个宏任务执行完成后,会去延迟队列查找延时到期的任务去执行。执行完了,再去消息队列执行后续的宏任务。

❓ 但,你会发现一个问题?如果一个宏任务要执行很久,那么 setTimeout 不就不准了?

另外,setTimeout 还有一些其他注意事项:

  1. 如果 setTimeout 存在嵌套调用,那么延时时间最短为 4 ms。 (一般如果使用 setTimeout 来做动画的时候,都是需要 setTimeout 嵌套调用,那么对于实时性很高的动画就不适用了。)
  2. 在未激活的页面中的 setTimeout 执行的最小间隔为 1000ms ,这是浏览器干预的,为了欧化后台页面的加载损耗以及降低耗电量。
  3. 延时时间有最大值。( Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,这导致定时器会被立即执行)
  4. 如果 setTimeout 的回调函数是一个对象中的一个方法属性,那么这个方法中的 this 会变成 window。

事件循环案例:XMLHttpRequest 如何设计?

上面,setTimeout 是直接将延迟任务添加到延迟队列中,而 XMLHttpRequest 发起请求,是由浏览器的其他进程或者线程去执行,然后再将执行结果利用 IPC 的方式通知渲染进程,之后渲染进程再将对应的消息添加到消息队列中。

也就是绕了一下,最后还是回归事件循环。

Released under the CC BY-NC-ND 3.0