Skip to content
On This Page

JavaScript事件循环

1. 为什么需要事件循环

1.1 JavaScript 的单线程特性

JavaScript 最初被设计为浏览器脚本语言,核心用途是操作 DOM。为了避免多线程同时修改 DOM 带来的复杂同步问题,JavaScript 被设计为单线程,即同一时间只能做一件事。

1.2 阻塞与非阻塞

如果某段代码执行时间很长(例如循环、网络请求、文件读取),就会阻塞后续代码,导致页面“卡死”。为了解决这个问题,JavaScript 引入了异步机制:将耗时的任务交给浏览器或 Node.js 的其他线程处理,等结果返回后再执行回调。

那么问题来了:单线程的 JS 如何知道异步任务何时完成、如何安排回调的执行?答案就是 事件循环

2. 事件循环的核心组件

为了更好地理解事件循环,需要先了解几个重要的概念:

2.1 调用栈(Call Stack)

调用栈是一个后进先出(LIFO)的数据结构,用来记录当前代码的执行位置。每个函数调用都会创建一个栈帧(frame)压入栈顶,函数执行完毕后出栈。

javascript
function foo() {
  console.log('foo');
}
function bar() {
  foo();
}
bar(); // 调用栈: bar -> foo -> console.log -> 依次出栈

2.2 任务队列(Task Queue / Callback Queue)

当异步任务(如 setTimeout、点击事件、网络请求)完成后,其回调函数并不会立即执行,而是被放入一个先进先出(FIFO)的任务队列中,等待主线程腾出空来执行。

2.3 事件循环(Event Loop)

事件循环是一个不断运行的过程:它监视着调用栈和任务队列。当调用栈为空时,事件循环会从任务队列中取出第一个任务,推入调用栈执行。

javascript
// 伪代码
while (true) {
  if (callStack.isEmpty()) {
    const nextTask = taskQueue.dequeue();
    if (nextTask) {
      callStack.push(nextTask);
    }
  }
}

3. 宏任务(MacroTask)与微任务(MicroTask)

任务队列实际上并不是单一的。ES6 之后,Promise 的出现引入了微任务的概念,从而将任务分为两大类:

类型常见 API / 场景执行时机
宏任务setTimeout, setInterval, setImmediate (Node), I/O, UI rendering, MessageChannel每个事件循环周期执行一个宏任务
微任务Promise.then/catch/finally, MutationObserver, queueMicrotask, process.nextTick (Node)当前宏任务执行完后、下一个宏任务之前执行 全部 微任务

执行顺序总结

  1. 执行一个宏任务(从任务队列中取出)
  2. 执行过程中产生的所有微任务(清空微任务队列,包括微任务产生的新微任务)
  3. 必要时进行 UI 渲染(浏览器)
  4. 开始下一个宏任务(回到步骤 1)

注意:Promise 本身是同步的,它的 then 回调才是微任务。async/await 底层也是基于 Promise,因此 await 后面的代码相当于微任务。

4. 事件循环的完整流程(浏览器环境)

下图(ASCII 描述)展示了事件循环的简化过程:

~
     ┌─────────┐
     │  开始   │
     └────┬────┘

 ┌────────────────┐
 │ 执行一个宏任务  │ ←─ 来自宏任务队列
 └────────┬───────┘

 ┌────────────────┐
 │ 清空微任务队列  │ ←─ 所有微任务(包括微任务中产生的新的微任务)
 └────────┬───────┘

 ┌────────────────┐
 │  必要时渲染UI   │
 └────────┬───────┘

     (循环回到开始)

5. 其它版本

5.1 基本概念

事件循环(event loop)又叫做消息循环(message loop),是浏览器渲染主线程的工作方式,是浏览器处理异步的一种调度策略

在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。

5.2 浏览器的进程与线程

进程是计算机操作系统资源分配的最小单位。现代浏览器,以 Chrome 为例,广泛采用的是多进程架构。为了减少连环崩溃的几率,启动浏览器后,会将各个任务拆分为多个进程。

  • 浏览器进程(Browser process):我负责界面显示、用户交互、子进程管理等业务。
  • 网络进程(Network process):我负责加载网络资源业务。
  • 渲染进程(Rendering process):我是渲染的主角,负责执行 HTML、CSS、JS 代码。默认情况,浏览器会为每一个标签页签开启一个渲染进程,以确保相互不影响。
  • 。。。

频繁开启进程会造成内存占用过多的情况,Chrome 后续会进行改进,考虑相同站点(相同顶级域名和二级域名)共用一个渲染进程。(官方说明文档

渲染进程会开启一个渲染主线程,用于无阻塞渲染任务。其余线程协助主线程完成任务。

渲染主线程(Main thread) 负责执行代码、样式与布局(通过优先级、百分比换算、宽高等几何信息高动态计算、相对位置等计算最终样式表)、处理图层、控制 60 帧刷新、执行各种回调函数等,是最忙的一个,一刻也没有停歇。

此时就有一个问题,主渲染任务都交给一个线程,效率会高吗?为什么不多线程一起渲染?

这是因为浏览器渲染和 JavaScript 执行共用一个线程,而默认情况下, JavaScript 执行又会影响 CSSOM/DOM 树的渲染和结构,所以必须阻塞渲染,他们也必须是单线程操作。在构建 DOM 时,HTML 解析器若遇到了 JavaScript,它会暂停构建DOM,将控制权移交给 JavaScript 引擎,等 JavaScript 引擎运行完毕,浏览器再从中断的地方恢复 DOM 构建。如果使用多线程处理可能会导致渲染DOM的冲突,复杂度会大幅度提升。

5.3 消息循环

使用单线程,使得在编写代码时无需考虑复杂的线程同步和互斥问题,从而降低了开发的复杂性,但是终归效率是上不去的。为了解决这个问题,这里巧妙地引入了消息循环来统一解决这个问题。

工作原理

核心思想:渲染主线程永久轮询执行任务;单独开辟一个消息队列(Message queue)空间用于存放各个线程加入的任务;渲染主线程只是简单的拿取任务执行,而不用操心这个任务是谁送过来的。在 Chrome 的源码中,使用的是一个死循环来完成的:

javascript
for(;;) {}

消息队列的无限轮询就叫消息循环,消息循环就发生在渲染主线程里

  1. 最开始,渲染主线程开始轮询
  2. 每一次循环检查消息队列是否有任务,有则取出第一个任务执行。
  3. 其他所有线程都可以追加任务进消息队列的末尾。

注意事项:

  • 绘制任务出现在循环的每一次迭代之后
  • 最需要注意的细节是,事件循环每一次只执行一个“宏”任务

优先级

任务本身没有优先级,渲染主线程依次从对头拿取任务执行。有优先级的是消息队列

在目前 Chrome 的实现中,消息队列其实不是一个队列,而是多个,他们有优先级,这个优先级决定了谁先进入渲染主线程被执行。至少包含了下面的队列:

  • 延时队列(Delay queue):用于存放计时器到达后的回调任务,优先级「中」。
  • 交互队列(Interactive queue):用于存放用户操作后产生的事件处理任务,优先级「高」。
  • 微队列(Micro queue):用户存放需要最快执行的任务,优先级「最高」。

随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法。

5.4 总结

  • 事件循环又叫消息循环,是浏览器渲染主线程的工作方式,是浏览器处理异步的一种调度策略。
  • 渲染主线程会反复轮询(for 循环),不断拿取消息队列的任务来执行;其他线程获取到任务后,在合适的时机将其追加在消息队列末尾。
  • 任务队列分为延时队列、交互队列、微队列;不同任务队列有不同的优先级。

测试

javascript
console.log('script start')

async function async1() {
  await async2() 
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end')
}
async1()

setTimeout(function() {
  console.log('setTimeout')
}, 0)

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
.then(function() {
  console.log('promise1')
})
.then(function() {
  console.log('promise2')
})

console.log('script end')

// 新版输出(新版的chrome浏览器优化了,await变得更快了,输出为)
// script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout
// 注意一个点await async2() 执行完后面的任务才会注册到微任务中

// 旧版输出如下,但是请继续看完本文下面的注意那里,新版有改动
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
javascript
//第一个宏任务
setTimeout(() => {
  console.log(1); //宏任务中的同步任务
  Promise.resolve().then(() => { console.log(7) }) //宏任务中的微任务
}, 0);  //异步任务 - 宏任务

console.log(2);   //同步任务

Promise.resolve().then(() => { console.log(3) }) //异步任务 - 微任务

//第二个宏任务
setTimeout(() => { 
  console.log(8); //宏任务中的同步任务
  setTimeout(() => { console.log(5) }, 0)  //宏任务中的宏任务 第四个宏任务
}, 0);

//第三个宏任务
setTimeout(() => { 
  Promise.resolve().then(() => { console.log(4) })  //宏任务中的微任务
}, 0);

console.log(6);   //同步任务

// 执行结果:2 6 3 1 7 8 4 5