JS异步机制与Event Loop解析
搞懂 JS 异步内核从 Call Stack、Event Loop 到 DOM 渲染与 await 的假等待文章目录搞懂 JS 异步内核从 Call Stack、Event Loop 到 DOM 渲染与 await 的假等待一、 起点调用栈Call Stack为什么是“后进先出”1. 为什么必须是“后进先出”二、 核心引擎Event Loop 的四方协作架构1. 四大组件职责2. 事件循环的标准执行轮询Loop Rule三、 网络请求 pending 时JS 到底在做什么四、 破除迷思await 到底是不是在“等待”1. 用代码验证2. await 的底层本质Generator 协程 Promise 语法糖五、 进阶真相DOM 什么时候渲染是微任务还是宏任务1. 完整的 Loop 与 DOM 渲染管线2. 实验验证DOM 修改 vs 真实上屏六、 总结全景图七、 疑惑点讲解1、 出栈Yield 遇到 await 时引擎会保存当前函数的上下文变量、作用域然后直接将该函数弹出调用栈把控制权还给主线程。 形象比喻看书打书签核心拆解这句话里的 3 个动作1. “保存当前函数的上下文变量、作用域”2. “直接将该函数弹出调用栈”3. “把控制权还给主线程” 代码 调用栈动态变化图调用栈Call Stack的变化过程总结对于许多前端开发者来说JavaScript 的异步机制一直充满“貌似矛盾”的迷局单线程的 JavaScript 凭什么能一边处理耗时的网络请求一边保持页面流畅遇到fetch的pending状态时主线程不轮询、不死等它是怎么知道数据什么时候回来的写下await时函数看起来明明卡在那里等结果为什么浏览器没有死机DOM 渲染到底是在什么时候发生的它属于宏任务还是微任务本文将从最底层的调用栈Call Stack讲起层层剥离带你构建一个关于 JS 异步与浏览器渲染运行机制的完整知识拓扑。一、 起点调用栈Call Stack为什么是“后进先出”要理解异步首先要理解同步。JS 引擎用调用栈来追踪代码的执行位置。调用栈遵从LIFOLast In, First Out后进先出原则。这种数据结构并非为了异步设计而是所有编程语言处理“函数嵌套调用”的天然逻辑。1. 为什么必须是“后进先出”当函数A在执行过程中调用了函数BA必须暂停自身等待B执行完毕拿回返回值后才能继续往下走。functionfuncC(){console.log(C 执行);}functionfuncB(){funcC();// 暂停 B先去执行 Cconsole.log(B 执行);}functionfuncA(){funcB();// 暂停 A先去执行 Bconsole.log(A 执行);}funcA();压栈Push执行funcA→ \rightarrow→入栈funcA调用funcB→ \rightarrow→funcB入栈funcB调用funcC→ \rightarrow→funcC入栈。出栈PopfuncC最先执行完最先被弹出返回funcB继续执行funcB完成后弹出最后返回funcA执行完毕弹出。结论“后进先出”确保了内层函数执行完毕后能准确返回到外层函数的上下文断点处。二、 核心引擎Event Loop 的四方协作架构当遇到了耗时任务如定时器、网络请求单个调用栈就不够用了。此时运行环境浏览器/Node.js引入了另外三个角色配合工作---------------------------- | Call Stack (调用栈) | --------------------------- | (遇到异步任务委托出去) v ---------------------------- | Web APIs (网络线程/定时器) | --------------------------- | (底层事件就绪后放入) v ---------------------------------- | Microtask Queue (微任务队列) | --- 优先全额清空 ---------------------------------- | Macrotask Queue (宏任务队列) | --- 每次循环只取一个 ----------------------------------1. 四大组件职责Call Stack调用栈唯一负责执行 JS 代码的主线程。Web APIs浏览器后台线程池C 编写的多线程模块专门在后台跑耗时的异步任务网络请求、Timer 计时、DOM 事件监听。Microtask Queue微任务队列存放微任务如Promise.then、queueMicrotask、MutationObserver优先级最高。Macrotask Queue宏任务队列存放宏任务如script整体代码、setTimeout、setInterval、I/O。2. 事件循环的标准执行轮询Loop Rule执行主线同步代码这本身就是第一个宏任务直到调用栈清空。清空所有微任务检查微任务队列。若不为空依次弹出所有微任务推入调用栈执行。如果在执行微任务时又合成了新的微任务会接着加入当前队列并在本轮直接清空渲染更新检查Rendering Pipeline检查屏幕是否到了刷新周期如 60Hz 屏幕约 16.6ms。若需要进行 UI 绘制详见后文。取出一个宏任务从宏任务队列取出一个排队最久的任务推入调用栈执行。重复步骤 2如此周而复始。三、 网络请求 pending 时JS 到底在做什么当你发起fetch([https://api.example.com])Promise 处于pending状态时JS 主线程有没有在“等待”如果没等它怎么知道数据什么时候回来答案是JS 主线程完全没有管这回事。等待的是操作系统和网卡。[JS 主线程] [浏览器网络线程 / 操作系统内核] | | |--- 1. 发起 fetch 请求 ------------------| | |--- 2. 进行 DNS 解析/TCP 握手/发送 HTTP |--- 3. 拿到 Pending 的 Promise | | |--- 4. 线程休眠/依靠 OS epoll 机制监听 Socket |--- 5. 继续跑后续同步代码 (无卡顿) | | |--- 5. 网卡收到响应数据包 (触发硬件中断) | |--- 6. OS 唤醒网络线程状态变 Resolved |-- 7. 将 .then() 回调推入【微任务队列】--| | |--- 8. 主线程空闲从微任务队列取出回调执行非阻塞委托主线程执行fetch()向浏览器网络线程下达指令后立刻拿到一个初始状态为pending的 Promise 对象接着去跑后续的同步代码。内核级异步 I/O浏览器的网络线程发起请求后利用操作系统底层的多路复用机制如 Linux 的epoll、Mac 的kqueue挂起监听不需要占用 CPU 资源死循环检查。硬件中断与通知当数据包到达网卡时网卡触发硬件中断操作系统收到信号后通知浏览器网络线程。网络线程将 Promise 状态修改为Resolved并将对应的.then()回调函数推入微任务队列。微任务被调度主线程空闲时Event Loop 发现微任务队列有回调取出压入调用栈执行。四、 破除迷思await到底是不是在“等待”许多开发者会迷茫“写了await之后后面的代码确实没跑啊这难道不是卡住等待吗”这里的核心在于厘清“谁在等”与“谁没在等”。在等待的只有当前这个async函数本身它的局部执行上下文被暂停并压入后台。没在等待的JS 主线程它立刻跳出了这个函数去执行外部的其他同步代码或响应用户点击。1. 用代码验证asyncfunctiontest(){console.log(2. 进入 test 函数);// 遇到 await暂停 test 函数的后续执行让出主线程控制权awaitfetch(https://api.example.com);// 这里的代码在 await 完成前【确实不会执行】console.log(4. await 结束恢复 test 函数);}console.log(1. 主线程开始);test();// 调用 async 函数console.log(3. 主线程结束);控制台输出顺序1. 主线程开始2. 进入 test 函数3. 主线程结束← 关键点主线程根本没有等 await 返回直接跑完了外部代码4. await 结束恢复 test 函数(网络请求返回后)2.await的底层本质Generator 协程 Promise 语法糖当你写下asyncfunctionexample(){console.log(A);awaitdoSomething();console.log(B);}在 JS 引擎底层await后面的代码会被打包成Promise.then()中的微任务回调。这段代码会被等解为functionexample(){console.log(A);// 同步执行// await 相当于把【后面的所有代码】打包成了 .then() 中的微任务回调returndoSomething().then((){console.log(B);// 注册为微任务});}出栈Yield遇到await时引擎会保存当前函数的上下文变量、作用域然后直接将该函数弹出调用栈把控制权还给主线程。入队与恢复当doSomething()的 Promise 完成时之前打包的“后半段代码”被作为微任务推进队列被 Event Loop 重新压栈继续执行。工程避坑提醒串行 vs 并行理解了await会挂起当前函数就要避免将无依赖关系的请求写成连续await// ❌ 串行阻塞耗时 T1 T2constres1awaitfetch(/api/user);constres2awaitfetch(/api/posts);// ✅ 并行触发耗时 Max(T1, T2)const[res1,res2]awaitPromise.all([fetch(/api/user),fetch(/api/posts)]);五、 进阶真相DOM 什么时候渲染是微任务还是宏任务先给出一个确切结论DOM 渲染既不是微任务也不是宏任务它是处于微任务清空之后、下一个宏任务开始前的“独立浏览器管线阶段”。1. 完整的 Loop 与 DOM 渲染管线修改 DOM如div.style.color red只是同步更新了内存中的 DOM 树节点并不意味着屏幕像素发生了变化。真正的“绘制上屏Paint”由渲染引擎在特定节点统一触发很多人的误区在于以为浏览器里的一切都是任务队列里的回调函数。但实际上渲染Rendering是浏览器渲染引擎如 Blink/WebKit的绘制行为而不是 JS 引擎的任务。完整的 Event Loop 轮询生命周期是这样的------------------------------------------------------------- | 1. 执行一个宏任务 (如 script 全局代码, setTimeout 回调) | ------------------------------------------------------------- | v ------------------------------------------------------------- | 2. 清空所有的微任务 (Microtasks: Promise.then, await 后续代码) | ------------------------------------------------------------- | v ------------------------------------------------------------- | 3. 渲染更新检查 (Rendering Pipeline) | | - 检查屏幕是否需要刷新 (通常 60Hz 屏幕约 16.6ms 刷新一次) | | - 若需要刷新 | | a. 执行 requestAnimationFrame (rAF 回调) | | b. Refine/Layout (重新计算样式与布局) | | c. Paint (像素绘制到屏幕即真正肉眼可见的 DOM 渲染) | ------------------------------------------------------------- | v ------------------------------------------------------------- | 4. 取出下一个宏任务执行 (进入下一轮 Loop) | -------------------------------------------------------------关于requestAnimationFrame(rAF)它既不是宏任务也不是微任务而是专为动画设计的帧前回调精准运行在 Style 计算与 Layout 之前的步骤 1。检查屏幕是否需要刷新1修改了 DOM 或 CSS 样式 2注册了动画帧回调 调用了requestAnimationFrame(callback)。 3页面上有正在播放的动画 如 CSS Animation、Transition、video播放、canvas绘图等。4发生了用户交互 页面发生滚动Scroll、鼠标移动引发 :hover 状态改变等。2. 实验验证DOM 修改 vs 真实上屏document.body.style.backgroundred;// 1. 改变内存中的 DOMPromise.resolve().then((){// 2. 微任务阶段console.log(微任务执行中...);// 如果在此处加上 alert(画面变红了吗)// 答案是没有页面依然是白色的因为 Paint 渲染阶段还没到。});setTimeout((){// 3. 宏任务阶段console.log(下一个宏任务...);},0);从上述流程可以看出在微任务执行完毕前屏幕上绝对不会呈现最新的 DOM 变化。浏览器会在微任务清空后根据帧率控制通常 60fps集中将内存中所有的 DOM 变更一次性进行 Style/Layout/Paint 处理以此获得最高效的渲染性能。六、 总结全景图我们将整个 JS 异步与渲染运行机制整合为一张总结对比表阶段 / 概念发生时机与触发源执行特点与规则典型代表调用栈 (Call Stack)主线程同步执行LIFO 后进先出函数嵌套调用的天然上下文管理普通函数调用、new Promise()构造函数Web APIs浏览器后台 C/Rust 线程 pool不阻塞 JS 主线程由 OS 网卡中断或 Timer 硬件驱动fetch请求、setTimeout计时、DOM 事件微任务 (Microtask)语言层面的异步承诺回调宏任务结束或同步代码跑完后立刻一次性全额清空Promise.then、await后续代码、queueMicrotaskDOM 渲染 (Paint)浏览器渲染引擎 (Rendering Engine)发生在微任务清空后、下一个宏任务前负责绘制像素requestAnimationFrame、Style/Layout/Paint宏任务 (Macrotask)宿主环境浏览器/Node发起的任务每次 Event Loop 轮询只取出一个执行script整体代码、setTimeout、I/O一句话记住 JS 异步模型主线程永不阻塞异步耗时交给底层数据到期推入队列遇到 await 暂停函数出栈让路微任务清空后渲染上屏最后轮询下一个宏任务。七、 疑惑点讲解1、 出栈Yield 遇到 await 时引擎会保存当前函数的上下文变量、作用域然后直接将该函数弹出调用栈把控制权还给主线程。这句话确实是理解async/await最烧脑的地方因为它同时涉及到了内存保存和代码执行权的让出。我们可以把这句话拆成三个动作用一个“看书暂停”的比喻来彻底讲透它 形象比喻看书打书签假设你在看一本小说执行async函数看到第 10 页时发现这一页有一道谜题await fetch(...)你需要等朋友查完资料告诉你答案才能继续看第 11 页。这时候你有两种选择死等同步阻塞你双手死死按着书不放眼睛盯着门外啥也不干后面的所有工作全停下线程卡死。Yield 挂起await的做法夹书签保存上下文你在第 10 页夹了一张书签记下当前的笔记和思路保存当前的变量a, b和代码执行位置。把书放回书架弹出调用栈你把书合上放一旁双手腾出来了调用栈清空了该函数。去干别的控制权还给主线程你去烧水、扫地、接电话主线程去跑外部的其他同步代码。朋友把答案送来了Promise 完成你把书从书架拿下来翻到书签页恢复上下文压入调用栈继续读第 11 页。核心拆解这句话里的 3 个动作理解了比喻后我们再看这句话里的三个关键概念1. “保存当前函数的上下文变量、作用域”普通函数结束函数执行完弹栈时函数内部的变量会被直接销毁被垃圾回收。await暂停函数虽然暂停了但还没执行完。所以 JS 引擎会把这个函数当前的所有局部变量、this、以及“代码执行到了哪一行”拍一张快照存到堆内存里。等请求回来时能原封不动地恢复现场。2. “直接将该函数弹出调用栈”为什么一定要弹出调用栈Call Stack的规则是只有栈顶的任务才能执行而且栈顶任务不结束下面的任务全被卡住。如果不把async函数弹出去调用栈就被占着后续的任何代码包括用户的点击事件、UI 渲染都进不来页面就会无响应。所以引擎必须强制把当前async函数移出调用栈让调用栈空出来。3. “把控制权还给主线程”当async函数被弹出调用栈后主线程CPU就空闲了。它会顺着之前的调用链路继续向下执行调用该async函数后面的同步代码。 代码 调用栈动态变化图用一段简单代码来看看调用栈里具体发生了什么asyncfunctionfoo(){letx10;awaitfetch(...);// -- 执行到这里console.log(x);}functionmain(){foo();console.log(主线程继续跑);}main();调用栈Call Stack的变化过程时刻 1主线程启动 | | | main() | -- main 压栈 ------------ 时刻 2main 调用 foo | foo() | -- foo 压栈开始执行到 await | main() | ------------ 时刻 3遇到 await【关键时刻】 1. 引擎把 foo 的变量 (x10) 和执行进度存入堆内存打书签。 2. 把 foo() 弹出调用栈 | | | main() | -- foo 弹出后栈顶变回了 main() ------------ 时刻 4主线程恢复自由 main() 继续执行打印 主线程继续跑执行完毕后 main 也出栈。 | | -- 调用栈完全清空主线程可以去处理点击、渲染等其他事 ------------ 时刻 5很久以后fetch 完成了 网络线程把 foo() 的后半段放进微任务队列。 主线程从微任务队列取出恢复变量 (x10)重新压栈执行 | foo(恢复) | -- 重新压栈打印 x (10) ------------总结这句话的核心意思就是await并不是真的让 CPU 在那里死等而是把当前函数“打包存档”然后立刻踢出 CPU让 CPU 去跑别的代码等到异步有了结果再把存档解包重新放回 CPU 继续跑。 感谢阅读想了解更多 我的博客网站 | 记录思考分享干货 我的个人主页 | 关于我、开源项目

相关新闻

最新新闻

日新闻

周新闻

月新闻