☰
前端精读:深入 JavaScript 事件循环(Event Loop)与异步编程原理
2026/10/1 17:02:03 网站建设 项目流程
  • 文档
  • 技术博客
  • 教程

【免费下载链接】weekly

前端精读周刊。帮你理解最前沿、实用的技术。

项目地址:https://gitcode.com/GitHub_Trending/we/weekly
点击查看免费下载

事件循环(Event Loop)是 JavaScript 运行机制的核心"内科",它决定了setTimeout、Promise、fetch这些异步代码究竟在什么时机、以什么顺序被执行。本文以本期周刊精读的《Javascript 事件循环与异步》为骨架,结合仓库内 Tasks, microtasks 精读、setState 原理精读 等系列文章,帮你彻底打通 Call Stack、宿主环境、Microtask/Macrotask 之间的关系,并给出一个 React 生命周期中可复现的异步setState实验,读完即可在实际项目中写出更可靠、可预期的异步代码。

1 引言:为什么事件循环像一门"内科"

本期精读的原始文章出自 sessionstack 的 "How JavaScript works" 系列,该系列深入探讨 JS 内部原理,并指出:只有深入了解 JS 的工作方式,才可能写出更好的代码。

事件循环之于 JavaScript 开发者,就像内科之于医生:我们平时只关注"外科创伤"(表层 API 用法),却忽视"内科问题"——setTimeout、Promise这些基础概念如果只停留在浮层理解,代码往往会在意想不到的地方出问题。对前端新人来说,事件循环也几乎是面试的必考基础题。

本文不展开原文的 Promise、async/await 部分,聚焦 Event Loop 本身:它如何与 Call Stack、宿主环境协作,以及 Microtask 与 Macrotask 两种异步队列的差异。

2 Event Loop 与 Call Stack、Web APIs 之间的关系

2.1 三个角色各司其职

在浏览器与 Node.js 中,一次 JS 代码的执行涉及三个关键角色:

  • Call Stack(调用栈):所有同步代码的执行场所,遵循后进先出(LIFO)规则。函数调用时入栈,函数返回时出栈。
  • Event Loop(事件循环):本篇文章的主角,负责监控调用栈与异步队列,在合适时机把异步回调"推回"调用栈执行。
  • Web APIs(宿主环境):泛指浏览器提供的 API(DOM、fetch、定时器等)或 Node.js 中的底层 C++ 能力。异步时机的判定由宿主环境负责,而不由 JS 引擎负责。

原文档用 16 张图演示了 5 行代码的完整执行过程,核心结论可以概括为一句话:

任何同步代码只存在于 Call Stack 中;只有异步代码(不一定是回调)才会进入 Event Loop 的队列。

2.2 哪些代码属于异步

典型的异步代码包括:

setTimeout() setInterval() Promise.resolve().then() fetch().then()

这些异步代码在执行时不会直接进入 Call Stack,而是进入 Event Loop 队列。当 JS 主线程(Call Stack)执行完毕、且异步时机已到,Event Loop 才会把异步回调中的代码推入 Call Stack 执行。

2.3 执行时机由宿主环境决定

一个容易忽略的关键点:异步代码何时开始执行,是由宿主环境决定的,而不是 JS 引擎。

原因是当调用栈清空后,JS 引擎本身不会再主动执行任何代码;除非 Event Loop 队列中有内容被推送回 Call Stack。以fetch为例:JS 调用浏览器发送请求后,直到浏览器主动通知 JS"请求已完成"之前,JS 无法干预任何环节——网络 IO 的调度完全在宿主环境(浏览器/Node.js)内部完成。

这也解释了为什么setTimeout(fn, 0)并不是"立即执行":它只是把回调交给宿主环境的定时器线程计时,倒计时结束后再把回调送入事件循环队列,等待调用栈空闲。

3 Microtask 与 Macrotask:两种异步队列

3.1 一维队列是不够的

事件循环处理异步的方式分两种:

  • Macrotask(宏任务):setTimeout、setInterval之流。
  • Microtask(微任务):Promise之流。

原文档给出了一个非常直观的模型:异步队列是周而复始循环执行的,可以看作一个二维数组——横排是当前队列中的每一个函数,纵排是每一个队列。

  • Macrotask的方式是将执行函数添加到新的纵排(新开一个队列);
  • Microtask将执行函数添加到当前执行到队列的横排(插入到当前队列尾部)。

因此Microtask的插入是"轻量"的,能最快被执行到——它不需要等待整条宏任务队列轮转,只要当前宏任务结束后即可立刻消费。

3.2 用真实输出验证执行顺序

仓库内 Tasks, microtasks, queues and schedules 精读 给出了一个经典验证用例:

console.log("script start"); setTimeout(function () { console.log("setTimeout"); }, 0); Promise.resolve() .then(function () { console.log("promise1"); }) .then(function () { console.log("promise2"); }); console.log("script end");

正确输出顺序是:

script start script end promise1 promise2 setTimeout

原因如下:

  1. 同步脚本执行优先级最高,script start、script end先后打印;
  2. Promise的回调进入Microtasks,setTimeout的回调进入Tasks;
  3. 一次事件循环中,Microtasks 优先于 Tasks 执行;
  4. Microtasks 执行期间新插入的 Microtask(promise2)会继续按顺序消费,直到队列清空,才会轮到 Tasks 中的setTimeout。

更精细地看,一个事件循环内的大致流程是:执行同步代码 → 调用栈清空 → 清空 Microtasks 队列 → 浏览器可能执行渲染 → 执行下一个 Task。需要注意,这里讨论的是"一次 Event Loop 内立即执行的优先级",与定时器的延迟时间(如 3000ms、0ms)不要混淆。

4 实战精读:componentWillMount 中异步 setState 的"时灵时不灵"

理解了事件循环,才能真正看懂 React 生命周期中那些看似诡异的行为。原文档作者在编写 dob-react 测试时发现:componentWillMount函数在Microtask时机调用setState不会触发 rerender。

4.1 只 render 一次的错误写法

class Hello extends React.Component { async componentWillMount() { await immediate(() => { this.setState({ a: 1 }); }); } render() { /**/ } }

配套的immediate函数如下:

function immediate(fn) { return new Promise(resolve => { fn(); resolve(); }); }

这种写法下组件只会 render 一次。

4.2 render 两次的正确写法

如果再套一层Promise.resolve().then(),让fn真正推迟到微任务时机执行:

function immediate(fn) { return new Promise(resolve => Promise.resolve().then(() => { fn(); resolve(); }) ); }

此时组件会render 两次。

4.3 原理与勘误

原文档的 ps 补充非常重要:第一种immediate写法其实是错误的——fn()在new Promise的 executor 中同步执行了,await并没有真正推迟到微任务时机,所以setState仍然发生在 React 生命周期内的同步渲染过程中,setState被合并,只触发一次 render。正确做法应使用Promise.resolve().then()将fn的调用真正推迟到 Microtask 时机。

这个实验暴露了一个普遍问题:在生命周期函数中,异步setState的合并机制时而生效、时而不生效,取决于setState被调用的时机落在同步执行阶段还是微任务阶段。这正是事件循环知识在真实业务中的价值——只有理解了 Microtask 的消费时机,才能解释 React 为何"合并"状态更新,以及何时合并会失效。

4.4 从 React 实现看 setState 的调度入口

要彻底理解上述现象,可以顺藤摸瓜看 React 的状态更新链路。setState 做了什么精读 指出:react包本身不包含 DOM 更新逻辑,它只通过updater接口向渲染器(react-dom、react-native等)反向通信:

// A bit simplified setState(partialState, callback) { // Use the `updater` field to talk back to the renderer! this.updater.enqueueSetState(this, partialState, callback); }

也就是说,setState是否合并、何时触发 rerender,最终由react-dom的渲染器在事件循环的某个时机(同步任务还是微任务)决定——这与本文事件循环的分析天然衔接。

5 从事件循环到 React 调度与 Promise 工程实践

事件循环并非孤立的"面试知识点",它向下支撑了框架的调度设计,向上决定了业务异步代码的写法。

5.1 React 的调度本质是事件循环上的时间分片

Scheduling in React 精读 指出:浏览器同一时间只能做一件事,肉眼可识别的刷新频率约 60FPS,意味着渲染、动画、响应用户输入必须在约 16ms 内完成。React 16 的 Concurrent 模式把同步渲染拆解为可中断的异步渲染,利用浏览器事件循环的"空闲分片"分批执行任务,本质上是站在事件循环之上做任务优先级调度。

5.2 Promise 串行队列的底层原理

用 Reduce 实现 Promise 串行执行精读 从另一个角度印证了事件循环模型:reduce是同步执行的,在一个事件循环内完成——它只是在内存中快速构造了一条 Promise 执行链:

function runPromiseByQueue(myPromises) { myPromises.reduce( (previousPromise, nextPromise) => previousPromise.then(() => nextPromise()), Promise.resolve() ); }

reduce的作用就是把"每个 Promise 完成后执行下一个"的冗余队列代码,折叠成一行生成。更现代的做法是用async/await改写为顺序等待:

async function runPromiseByQueue(myPromises) { for (let value of myPromises) { await value(); } }

理解了两者的差异(前者同步构造队列、后者异步逐项等待),才能回答"为什么Promise.all是并行、而await逐个写是串行"这类问题。

6 总结

理解事件循环只是第一步。原文档作者坦言,想写好稳健的业务代码,需要三层能力:

  1. 理解"内科"知识:Call Stack 只装同步代码,异步代码进入 Event Loop 队列,Microtask 优先于 Macrotask 消费;
  2. 读懂框架源码:例如 React 的setState通过updater委托给渲染器实现,调度与合并行为由渲染器在事件循环的时机决定;
  3. 保证不会忘:异步时序知识是"用到才想起来"型知识,建议通过类似本文第 4 节的可复现实验持续巩固。

最后回到实践层面,仓库中 Tasks, microtasks 精读 给出了两条务实建议:

  • 业务逻辑不要"巧妙"依赖 Microtask 与 Task 执行顺序的微妙差异——不同浏览器(Chrome、Firefox、Safari、Edge)对任务顺序的实现存在差异,依赖顺序的代码非常脆弱;
  • 不要死记硬背调用顺序——只要记住核心规则即可推导:一次事件循环中,同步代码优先,随后清空 Microtasks,再处理 Tasks;Microtasks 执行期间新插入的 Microtasks 会按序继续执行。

类似的异步思维贯穿仓库多个主题:处理异步异常的 捕获所有异步 error、并发场景下 async/await 是把双刃剑 中对"顺序 await 拖慢并发"的警示,都可与本文的事件循环模型互相印证。当你下次面对"为什么结果顺序不对""为什么 setState 没生效"时,不妨先回到事件循环这个"内科"诊断一遍。

  • 文档
  • 技术博客
  • 教程

【免费下载链接】weekly

前端精读周刊。帮你理解最前沿、实用的技术。

项目地址:https://gitcode.com/GitHub_Trending/we/weekly
点击查看免费下载

相关推荐

上一篇:ncmdump使用手记:网易云NCM格式转换,从拖拽到自动批量一篇讲透
下一篇:3步搞定NCM格式转换:用ncmdump让加密音乐跨设备自由播放

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询