:)
Listener B弹出紧接着click()方法同步执行完毕也宣告弹出。控制权交还给外层脚本控制台同步打印After dispatch。当前 JS 执行栈状态[app.js]依然不为空第七步最外层宣告落幕延迟的大清算最终最外层的app.js也终于运行到了最后一行代码完美弹出执行栈。当前 JS 执行栈状态[]经历了漫长的等待执行栈终于彻底归零在这一瞬间始祖级任务彻底终结。脚本清理算法终于检测到执行栈回归为空被压制了整整一力场的微任务检查点在任务结束的边界上爆发主线程一口气冲进队列将积压多时的微任务 A和微任务 B顺次全部处决。最终控制台输出顺序Before dispatch---监听器 A---监听器 B---After dispatch---微任务 A---微任务 B这叫“必须把整座山头的人都揍完才能统一分战利品”。因为最外层的主力部队app.js还在阵地上压着主线程判定宏观的任务没有结束所以中途任何脚本清理点都无法强跑微任务。微任务通常会被整体拖到最外层脚本结束之后的清理阶段统一执行。两种场景的核心差异对比表对比维度场景 1用户交互触发场景 2代码手动触发触发方式物理设备输入JavaScript 代码调用dispatchEvent任务类型独立的宏任务同步函数调用无新任务创建执行栈状态监听器执行完毕后栈为空监听器执行完毕后栈仍有外层帧微任务检查点时机每个监听器执行完毕后立即触发整个dispatchEvent完成且外层脚本结束后触发微任务执行时机穿插在多个监听器之间所有监听器执行完毕后批量执行行为一致性所有浏览器完全一致所有浏览器完全一致我们来看一个更复杂的嵌套场景const button document.querySelector(button); // 监听外层事件 button.addEventListener(outer-event, () { console.log(Outer Listener); Promise.resolve().then(() console.log(Outer Microtask)); // 在监听器内部同步派发一个不同的事件以避免死循环。 button.dispatchEvent(new Event(inner-event)); console.log(After inner dispatch); }); // 监听内层事件 button.addEventListener(inner-event, () { console.log(Inner Listener); Promise.resolve().then(() console.log(Inner Microtask)); }); console.log(Start); // 派发外层事件启动嵌套引擎 button.dispatchEvent(new Event(outer-event)); console.log(End);输出结果Start Outer Listener Inner Listener After inner dispatch End Outer Microtask Inner Microtask步骤解析console.log(Start)压栈执行输出Start。button.dispatchEvent(new Event(outer-event))压栈同步调用绑定的外层监听器。外层监听器执行输出Outer Listener并将Outer Microtask挂入微任务队列。关键点执行button.dispatchEvent(new Event(inner-event))派发逻辑直接在当前栈顶继续压入内层监听器。内层监听器执行输出Inner Listener将Inner Microtask挂入微任务队列。内层出栈。回到外层监听器输出After inner dispatch。外层出栈。最外层脚本继续执行console.log(End)。整个始祖级脚本终于执行完毕执行上下文栈Call Stack真正被彻底清空。触发 HTML 规范的 Clean up 步骤闸门打开积压的微任务依次执行输出Outer Microtask和Inner Microtask。通过区分outer-event和inner-event我们可以得知无论事件的名字叫什么只要它们是通过dispatchEvent嵌套触发的它们就共享同一个同步的执行上下文栈微任务就必须老老实实等到最外层调用结束才能执行。注意点错误的观点不要认为事件回调都是宏任务。事件本身不是任务触发事件的方式决定了它是否在一个新任务中执行。嵌套 dispatchEvent 的情况如果在一个监听器中再次调用dispatchEvent触发另一个事件那么内层事件的所有监听器也会同步执行微任务会被积压到最外层任务结束。特殊情况冒泡与捕获阶段无论事件处于捕获、目标还是冒泡阶段上述规则都完全适用。微任务检查点只会在每个监听器执行完毕后检查栈是否为空。微任务在脚本清理步骤中触发当执行栈为空时就可能执行。任务边界只是最常见的触发点之一。DOM 事件派发是同步的但每个监听器返回后只要栈空HTML 清理步骤就可能插入微任务检查点。要注意规范和实现的区别不同浏览器、不同事件类型和不同回调结构下微任务插入时机可能存在实现差异不能简单把某一种表现当成唯一规范结论。5.4 微任务与渲染为什么它又快、又危险微任务的优点是“快”因为它紧贴着执行栈的尾翼但它的代价也正是“太快”。HTML 标准在对queueMicrotask()的开发者说明里特别发出了警告如果你安排了过多的微任务它们的性能副作用会和编写大量的同步代码高度相似都会阻止浏览器去做自己的工作。这涉及到微任务与浏览器渲染管线Update the rendering的优先级博弈。在事件循环的处理模型中微任务清算的优先级是高于视觉渲染的。这意味着微任务是“不让出控制权的前提下尽快完成收尾”。如果你的目标只是“在下一次屏幕刷新重绘前运行一些代码”微任务绝对不是合适的工具因为微任务会在渲染机会评估之前被强行全部处决此时requestAnimationFrame()才是更契合视觉管线周期的天然拦截器。微任务在设计上应当专注于做“收口型工作”而不适合做“长链路计算”。所以微任务的完美适用场景把同一轮同步执行里产生的多笔状态变化进行合并批处理、在 DOM 变动后统一读写以防频繁重排、在 Promise 链的尾部做统一的异常收束逻辑。6.异步在经历了前面对任务Task与微任务Microtask的深入了解以后我们终于来到了整个 JavaScript 运行时中最容易让人产生时空错觉的认知转折点——异步Asynchrony。很多初学者常常将“异步”挂在嘴边在他们的脑海深处似乎有个幻觉当代码执行到异步操作时主线程里仿佛突然撕裂开了一个平行宇宙或者分身出了一个新的小弟在后台默默地帮他搬砖。我们打碎这个幻觉真正认识异步。6.1 单线程的并发要学透异步首先必须在学术和工程层面上理解两个底层概念——并行Parallelism与并发Concurrency。并行Parallelism指的是在同一个绝对时间点上有多颗 CPU 核心在物理上同时执行多段不同的代码。这需要真正的硬件“分身术”。并发Concurrency指的是在一段宏观的时间周期内程序通过极其高频的切换调度让多段任务交替执行从而在宏观上伪造出一种“多件事情同时在运行”的丝滑幻觉。JavaScript 的主线程是典型的单线程协作式并发。在 V8 引擎的真实世界里主线程从来没有分身术。如果有一步操作需要发起网络请求去获取一个 10MB 的大文件或者去等待磁盘读取一块厚重的数据在以前的同步阻塞思维里主线程就必须在原地干等着。此时整个执行上下文栈死死卡住程序停摆这就是“阻塞Blocking”。而异步则不同主线程引擎一看这个网络响应或定时器到期不知道要等到猴年马月在原地干等就是对 CPU 算力的犯罪。于是它运用了时间维度的拆分策略把当下不能立刻完成、或者代价极其高昂的活儿先在宿主环境的通讯录上登记下来接着立刻把当前的主线程执行权让出来去跑后面能跑的同步代码。异步的精髓绝不是“在物理上同时干两件事”而是“发起登记 --- 移交控制权 --- 后台等待 --- 未来触发”。它保证了单线程永远在做有意义的运算而不是死等。6.2 异步的“三段式”旅程无论异步在业务层面的写法多么千变万化从古老的 Callback到 Promise再到现代的 async/await在底层的真实调度中它们一般都会经历一场“三段式”的历史旅程阶段一发起与卸载首先需要记住一个事实所有异步操作的发起本身都是百分之百同步执行的。当你写下setTimeout(fn, 1000)或者是fetch(url)的这一瞬间主线程会立刻压栈、调用这个 API。调用发生的几微秒内主线程会迅速做两件事第一在宿主环境浏览器 C 层的后台线程账本上登记要干的活和回调函数fn的指针第二立刻将该 API 弹出执行栈卸载控制权。主线程绝不在原地停留大撒手之后立刻衔接执行后面的同步代码。阶段二宿主托管与静默监听当主线程在网页里跑别的业务同步逻辑时被卸载出去的异步大活正式进入了宿主环境浏览器的多线程异构托管区如果是定时器浏览器的定时器线程开始在底层精确走表如果是网络请求浏览器的网络进程开始拉动网卡进行底层的 TCP/IP 数据包传输这个阶段外部世界热火朝天但 JavaScript 主线程对这一切一无所知也毫不关心。阶段三包装入队与未来归还当外部的后台线程把活儿干完了如定时器归零、网络数据下载完毕宿主环境的总调度师就会把当时登记的那个 JS 回调函数包装起来如果是原生的宏观任务就作为标准的Task扔进对应的任务源队列排队如果是内部的微观反应就作为Microtask挂进微任务队列。当主线程好不容易把眼前的同步调用栈彻底空出来、事件循环的传送带旋转到这个边界时调度器才终于把这个在门外等候多时的回调重新接引回主线程的执行栈里。至此异步才终于完成了它的“未来回归”。6.3 异步生态的“双轨制”有了前面对事件循环、任务与微任务的底层了解我们现在可以用规范视角对整个 JavaScript 异步生态里的所有常见形态进行一次严谨的分类轨道一宿主外部宏观驱动轨Task 队列这类异步形态的特征是宿主环境在处理完外部事件后将用户编写的回调函数直接作为一个独立的任务Task推入事件循环。它们在执行时会独占一轮循环节拍结束后会拉动微任务检查点并允许浏览器选择是否插播视觉重绘。定时器回调setTimeout/setInterval宿主定时器线程托管到期后回调作为任务进入 Timer 任务队列。原生网络事件回调XHR的onload/onreadystatechange宿主网络进程托管整个网络事件的就绪与用户回调的触发作为标准的任务送入 Network 任务队列排队。消息投递任务源Posted message task source典型代表如postMessage跨域通信或使用MessageChannel接口进行多上下文通信目标端接收到的回调都会作为标准的 Task 进行排队调度。文件与资源加载onload/onerror浏览器文件 I/O 与资源渲染线程托管完成后作为任务入队。轨道二JS 引擎内部微观驱动轨Microtask 队列这类异步形态的特征是它们由 JavaScript 引擎如 V8的微任务队列直接管理。它们的执行点在当前任务的内侧边缘采用“清到见底”的机制除非被清空否则绝不让出控制权给下一个任务也绝不允许浏览器在中途插播重绘。Promise.then/catch/finally的后续反应ECMAScript 规范要求将其包装成内部作业Job在 Web 平台上直接映射入微任务队列。queueMicrotask()官方定义的标准 API唯一的职责就是绕过任何外部逻辑直接将一个回调函数投递进当前的微任务队列。MutationObserver监听 DOM 树变动的批量记账通知机制。xhr和fetch的辨析同为网络请求XHR的回调与fetch的回调在重回主线程时发生了本质的分裂。很多开发者会认为“既然它们都是去网络上下载文件那它们回来的时机和轨道应该是一模一样的吧”答案是否定的。假设在代码里同时发起了一个XHR请求和一个fetch请求。在未来的某一瞬间宿主环境的网络进程同时下载完了这两个请求的数据一.XHR的归宿Task 轨外部网络线程通知事件循环调度器“活干完了” 接着把xhr.onload这个回调函数包装成一个全新的任务Task扔进主线程的网络任务队列中排队。它必须等待当前任务结束、等待当前微任务清空在未来某一轮新的事件循环节拍中才能出队执行。二.fetch的改道Microtask 轨外部网络线程下载完数据后根据 Fetch 规范宿主环境会向主线程投递一个内部的Fetch Task。这个内部任务在执行时的唯一职责就是去拉动 Promise 的决议开关将fetch返回的那个内置Promise状态变更为 resolved。而根据 ECMAScript 规范Promise状态的改变会立刻就地向微任务队列Microtask Queue注入一个微任务。当这个短小的内部Fetch Task宣告结束的瞬间由于执行栈变空HTML 规范的微任务检查点Microtask Checkpoint开启瞬间开始处决写在fetch().then()或await后面的业务代码。这种区别在现实中会造成什么时序差在同等网络就绪条件下假设宿主环境的内部调度将两者的网络就绪通知同时推入事件循环fetch的后续回调往往会比XHR的回调抢先一步爆发。因为fetch的业务回调是微任务它会在内部接引任务结束的边界被直接“清到见底”强行处决。而XHR的回调是独立宏任务它必须在门外眼睁睁地看着fetch的微任务全部执行完、甚至看着浏览器走完一遍重绘管线之后才有机会在下一轮事件循环中出队。这也是为什么在同等就绪的微观时序里fetch能够展现出跨越任务边界的速度。这就是异步的生态双轨制——相同的起点却因为接引机制的规范语义不同产生出了宏观与微观、跨越任务边界与死守内侧边缘的巨大区别。6.4 async/await很多教程上说async/await只是 Promise 的语法糖写起来像同步而已。这种说法在 ECMAScript 规范的逻辑语义上完全没错但我们可以继续深入的了解一下。这部分是V8的活。我们从 V8 引擎执行上下文栈说起async/await并不是简单地在主线程new了一个 Promise 然后挂载then回调它的底层启动的是 V8 内核的协程Coroutine与生成器挂起机制async function uploadData() { console.log(开始上传); const result await fetch(/api); console.log(上传成功); // 这一行到底什么时候执行 } uploadData(); console.log(主线程继续);就地切断与挂起先交出去当主线程同步执行到uploadData()控制台打印开始上传。紧接着代码遭遇了await fetch(/api)。这时V8 引擎在底层开启“就地切断指令”它会同步触发fetch请求然后直接将uploadData整个函数的执行上下文从当前的调用栈Call Stack里剥离出来转移到逻辑堆内存中进行隐形挂起Yield栈空返回此时对于主线程而言uploadData的栈帧已经消失了调用栈回归为空它立刻顺畅地向下执行打印主线程继续。微任务作为“接引票”后回来当未来的某个时刻网络请求结束对应的 Promise 被 resolve 了V8 引擎并不会自己凭空跳回刚才被挂起的代码。它的机制是将该await之后剩余的所有代码即打印上传成功这一行包装成一个标准的微任务Microtask塞进微任务队列。无缝状态恢复当本轮同步代码结束、执行栈变为空、微任务检查点爆发时引擎取出这个特制的微任务。它根据里面保留的内部指针直接将刚才挂在堆内存里的uploadData上下文重新拽回执行栈顶Resume无缝恢复现场控制台终于打印出上传成功。async/await的本质就是用 V8 内核的生成器挂起能力 把剩余的代码就地打包、隐形交出去在未来用微任务作为引接车再完好无损地接回来。异步的本质不是“多线程的同时执行”而是“把当前不必立刻完成、也不应阻塞主线程的事拆给未来去处理”。它不是空间上的并行分身而是时间上的错峰调度。从发起时的同步卸载到宿主环境的托管再到事件循环传送带上的精准接引单线程的 JavaScript 用这种“先交出、后拿回”的方式硬生生在单核的约束里织出了整个 Web 世界看起来丝滑运转的并发幻觉。7.Promise 与 async/await在上一章中我们通过“异步的三段式旅程”确立了一个宏观的认知异步的本质是交出控制权并在未来通过事件循环重新拿回。然而当控制权真正“回归”到主线程时JavaScript 引擎到底是如何精细化地管理这些复杂的未来承诺的当我们写下甜蜜的糖块async/await时底层的内存栈到底发生了什么事情这一章我们将从 ECMAScript 规范与 V8 引擎的深处去了解Promise 与协程。7.1 纯正的js语言级血统首先我们必须认识到Promise 根本不是宿主环境如浏览器提供的 Web API像setTimeout或fetch那样它是 ECMAScript 语言原生的内置对象。不管外部的宿主环境是浏览器、Node.js 还是鸿蒙的 ArkTS只要是符合 ECMAScript 规范的 JS 引擎Promise 的语义行为就绝对不会有任何偏差。它在底层的真身是一个极其严密、一旦启动就绝对无法逆转的“微观状态机”。当我们new Promise()时V8 引擎在底层的 C 堆内存里实例化了一个对象这个对象身上带着三个“内部插槽Internal Slots”[[PromiseState]]状态锁它的初始值永远是pending待定。一旦它的状态被拨动为fulfilled成功或rejected失败这把锁就会在物理层面彻底焊死再也无法改变它的状态。[[PromiseResult]]终值/拒因就像一个保险箱用来存放成功拿到的数据或者失败抛出的错误对象。一旦[[PromiseState]]焊死保险箱也会同步锁定。[[PromiseFulfillReactions]]/[[PromiseRejectReactions]]反应队列这是最容易被误解的核心它是一个挂载在 Promise 实例自己身上的数组而不是全局的微任务队列它的作用是用来存放所有通过.then()或.catch()注册进来的“后续反应动作Reaction Records”。理解了这三个插槽你就会明白Promise 并没有什么魔法它本质上就是一个自带防篡改锁的数据容器外加一个静默的订阅者名单。7.2.then到底什么时候入队那么问题来了当你写下.then(fn)的那一瞬间这个fn到底有没有立刻进入微任务队列为了彻底了解这个知识点我们必须把“同步的注册”与“异步的激发”撕裂开来看。同步的 Executor首先当写下new Promise((resolve, reject) { ... })时传入的那个executor函数是绝对的、百分之百的同步代码V8 引擎在创建完实例后会当场毫无延迟地执行它。这里没有任何异步的成分。状态决定.then()的走向当接着在实例后面调用p.then(fn)时V8 引擎会瞬间去查看那个底层插槽[[PromiseState]]然后根据状态的不同走向两条完全不同的时空分岔路场景 A尚未决议pending状态如果此时的 Promise 还在等一个 5 秒后的网络请求状态是pending。那么调用.then(fn)时引擎只是机械地把fn这个函数打包成一个记录默默塞进了该 Promise 实例自己身上的[[PromiseFulfillReactions]]数组里存起来。关键点此时此刻全局的微任务队列里什么都没有没有任何微任务产生直到 5 秒后网络请求回来你的代码调用了resolve(data)。此时[[PromiseState]]瞬间锁定为fulfilled引擎立刻拉响警报遍历自己身上的[[PromiseFulfillReactions]]数组触发底层的HostEnqueuePromiseJob机制这才是真正的“注水”时刻——那些沉睡了 5 秒的回调在此刻才被真正作为微任务推进了全局微任务队列Microtask Queue中排队。场景 B已经决议fulfilled/rejected状态如果你拿到的是一个Promise.resolve()状态已经焊死了。当你调用.then(fn)的那一瞬间引擎一看“好家伙保险箱都已经打开了”于是它根本不往实例的数组里存了直接当场调用HostEnqueuePromiseJob立刻将fn包装成微任务当面放进全局的微任务队列里。小结.then从来不负责执行它只负责“挂载”。是立刻推入微任务队列还是暂存在实例身上等待未来的resolve唤醒完全取决于调用.then那一瞬间的内部状态锁[[PromiseState]]。7.3 链式调用的微观交错Tick-Tock 交织机制理解了内部插槽我们就可以来看一道经典题目多 Promise 链的交错执行。JavaScriptPromise.resolve() .then(() console.log(A1)) .then(() console.log(A2)) .then(() console.log(A3)); Promise.resolve() .then(() console.log(B1)) .then(() console.log(B2)) .then(() console.log(B3));所有凭直觉做题的人都会认为输出是A1 A2 A3 B1 B2 B3。然而真正的输出是极其诡异的交织排列A1 - B1 - A2 - B2 - A3 - B3。为什么会产生这种像时钟滴答Tick-Tock一样完美的交错步调这里藏着 Promise 链式调用的终极规范每一次调用.then()引擎在底层都会为你当场new一个全新的、隐形的 Promise 实例并返回。让我们开启微任务检查点的慢镜头回放初始注水第一行的Promise.resolve().then(A1)和 第五行的Promise.resolve().then(B1)遇到了已经决议的 Promise。根据场景 B它们立刻把A1和B1推进了全局微任务队列。当前微任务队列[A1, B1]注意此时挂在A1后面的.then(A2)看到的是A1返回的那个全新的、且状态为pending的隐形 Promise。所以A2只是暂存在了隐形 Promise 的身上场景 A根本没进微任务队列B2同理。执行 A1微任务检查点爆发取出A1执行。打印A1。当A1函数顺利return结束的瞬间V8 引擎在底层秘密地把那个隐形的 Promise 给resolve()了这一动作瞬间激活了暂存在它身上的A2将A2推入了微任务队列的末尾。当前微任务队列[B1, A2]执行 B1取出B1执行。打印B1。同理B1结束的瞬间秘密resolve了自己的隐形 Promise激活并把B2推入了队列的末尾。当前微任务队列[A2, B2]循环往复接着执行A2激活A3执行B2激活B3……小结Promise 的链式调用就是一场完美的接力赛。当前一个.then的微任务彻底跑完时它手中交出的接力棒隐形的resolve才会把下一个.then放入排队的长龙末尾。7.4 async/await 协程与执行栈很多开发者习惯把async/await称为 Promise 的“语法糖”。这种说法从宏观上并没有错但如果你再往下看一层就会发现它远不只是“写法更顺手”这么简单。它真正展示的是一种很有协程味道的执行模型当前函数在遇到await时先挂起把后半段逻辑保存下来等被等待的异步结果就绪后再从断点处继续执行。这也是为什么async/await看起来像同步代码实际上却不会阻塞主线程。它没有把 JavaScript 变成多线程语言也没有真的让函数“睡死在那里”而是把原本一口气跑到底的过程切成了前半段和后半段两次完成。前半段先让出控制权后半段通过微任务机制重新接回。整个过程非常像协程有挂起点有恢复点有连续的叙事外观但底层仍然是单线程调度。看这段代码async function upload() { console.log(同步执行区); const result await fetch(/api/data); console.log(微任务恢复区, result); } upload(); console.log(全局同步结束);如果从表面看这只是一个普通函数里夹了一个await但从执行过程看它其实已经被切成了两个阶段。第一阶段负责同步执行和发起异步操作第二阶段则在未来某个时机被重新唤醒。右侧表达式先同步执行当执行流走到await fetch(/api/data)这一行时await右侧的表达式会先被同步求值。也就是说fetch(/api/data)这一动作并不是“等到后面再说”而是当场开始。浏览器会立刻发起网络请求并同步返回一个状态为pending的 Promise。这里非常重要的一点是await并不会阻塞主线程去死等结果它只是接住这个 Promise然后决定把当前 async 函数的后半段先暂时放下。从这个时刻开始网络 I/O 已经交给宿主环境处理JavaScript 代码本身不需要继续占着执行栈不放。当前 async 函数在挂起点暂停当await看到的 Promise 还没有完成时当前 async 函数就会在这里进入挂起状态。这个挂起不是线程阻塞而是当前函数的后续执行被暂时保存起来。可以把这一步理解成函数跑到一半先把“接下来还要做什么”折起来放到一边等未来条件满足再打开继续。这也是 async/await 最有协程感的地方。它并没有把整个函数销毁而是把后半段逻辑的“继续权”保留下来。至于这个“继续权”在不同引擎内部到底怎么存、怎么挂、怎么恢复具体实现可以有差异但从规范语义上看结论是稳定的函数暂停状态保存之后恢复。函数退栈但对外返回一个 Promise这里需要注意一个时序细节并不是函数挂起后才返回 Promise而是async函数在被调用的那一瞬间就已经同步向外部返回了一个 Promise。随后当函数在内部遇到await并挂起时它真正做的是把主线程的控制权交还回去。这意味着外层调用者拿到的不是一个“卡住的函数”而是一个“未来会结算”的结果容器。函数内部那一半还没跑完但函数外部已经可以继续往下执行了。于是你会看到upload(); console.log(全局同步结束);这里的console.log(全局同步结束)会先打印出来因为upload()在await处已经把控制权交还给了外层。upload函数本身在逻辑上还活着只是它当前那一半被挂起了并没有继续占用主线程。从这个角度看async函数更像一个“会分两次上场”的执行体前半场先跑后半场等 Promise 结算后再回来继续。Promise settled 后恢复任务进入微任务流程当fetch(/api/data)最终拿到网络响应时原先那个 Promise 会进入fulfilled状态。这个状态变化会触发后续的恢复逻辑。接下来发生的事情不是“立刻回到原位置继续执行”而是把恢复动作排进微任务队列等待合适的微任务检查点再处理。也就是说await后半段的恢复并不是同步插队而是遵循事件循环和微任务调度的规则在当前同步代码完成后、合适的清理时机再接上。这一步可以理解成前半段已经把“后续执行现场”保存好了现在只是等一个规范允许的时机把它重新接回来。恢复时从断点处继续等微任务检查点到来时会把之前挂起的 async 函数继续恢复执行。于是result变量会拿到网络响应值代码也会接着执行console.log(微任务恢复区, result);对upload()内部的代码来说这一切并不是“重新执行了一遍函数”而是从上一次停下来的位置继续往下跑。这也是async/await最像协程的地方它不是重新开头而是从断点续写。整个流程可以这样理解await右侧表达式先同步求值。如果得到的是一个尚未完成的 Promise当前 async 函数暂停。函数向外返回一个 Promise控制权交还给主线程。当被等待的 Promise 以后完成恢复逻辑进入微任务流程。async 函数从断点继续执行后半段代码。整个过程没有引入新的线程也没有让 JavaScript 变成真正的并行模型。它只是把“原本必须一口气执行到底的逻辑”拆成了前半段同步执行、后半段异步恢复的两段式流程。有些教程用“把执行栈搬到堆里”来形容await这个说法作为比喻是好用的因为它能帮助初学者迅速抓住“暂停后还会回来”这个事实。但更严谨一点说真正发生的不是把整个函数粗暴复制一份而是把函数继续执行所需要的状态保存起来包括当前跑到哪里了、下一步应该接哪一行、相关局部状态该如何恢复。所以可以把它理解成一种“状态封存”