- 文档
- 技术博客
- 教程
【免费下载链接】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原因如下:
- 同步脚本执行优先级最高,
script start、script end先后打印; Promise的回调进入Microtasks,setTimeout的回调进入Tasks;- 一次事件循环中,Microtasks 优先于 Tasks 执行;
- 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 总结
理解事件循环只是第一步。原文档作者坦言,想写好稳健的业务代码,需要三层能力:
- 理解"内科"知识:Call Stack 只装同步代码,异步代码进入 Event Loop 队列,Microtask 优先于 Macrotask 消费;
- 读懂框架源码:例如 React 的
setState通过updater委托给渲染器实现,调度与合并行为由渲染器在事件循环的时机决定; - 保证不会忘:异步时序知识是"用到才想起来"型知识,建议通过类似本文第 4 节的可复现实验持续巩固。
最后回到实践层面,仓库中 Tasks, microtasks 精读 给出了两条务实建议:
- 业务逻辑不要"巧妙"依赖 Microtask 与 Task 执行顺序的微妙差异——不同浏览器(Chrome、Firefox、Safari、Edge)对任务顺序的实现存在差异,依赖顺序的代码非常脆弱;
- 不要死记硬背调用顺序——只要记住核心规则即可推导:一次事件循环中,同步代码优先,随后清空 Microtasks,再处理 Tasks;Microtasks 执行期间新插入的 Microtasks 会按序继续执行。
类似的异步思维贯穿仓库多个主题:处理异步异常的 捕获所有异步 error、并发场景下 async/await 是把双刃剑 中对"顺序 await 拖慢并发"的警示,都可与本文的事件循环模型互相印证。当你下次面对"为什么结果顺序不对""为什么 setState 没生效"时,不妨先回到事件循环这个"内科"诊断一遍。
- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
相关推荐
把静态图片变成动态视频:一文搞懂 ComfyUI-WanVideoWrapper
把静态图片变成动态视频:一文搞懂 ComfyUI WanVideoWrapper 第一次做 AI 视频时,我的目标很具体:手上有一张红衣男子的照片,想让它转头、
人工智能大模型媒体生成JavaScript事件循环终极指南:深入理解异步编程运行机制
JavaScript事件循环终极指南:深入理解异步编程运行机制 JavaScript事件循环是理解现代JavaScript异步编程的关键概念。作为一名JavaS
教程示例工程JavaScript事件循环终极指南:深入理解异步编程运行时机制
JavaScript事件循环终极指南:深入理解异步编程运行时机制 你是否曾经好奇为什么 setTimeout 并不总是准时执行?为什么 Promise 比回调更
文档教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考