整个过程没有引入新的线程
把执行栈搬到堆里”来形容await这个说法作为比喻是好用的因为它能帮助初学者迅速抓住“暂停后还会回来”这个事实。但更严谨一点说真正发生的不是把整个函数粗暴复制一份而是把函数继续执行所需要的状态保存起来包括当前跑到哪里了、下一步应该接哪一行、相关局部状态该如何恢复。所以可以把它理解成一种“状态封存”当前的执行先退下来后半段逻辑被保留未来再通过微任务重新接入。这套机制让 JavaScript 在单线程条件下也能写出非常像同步流程的异步代码。它不是“多线程的同时执行”而是“把未来才能完成的部分先收起来等时机到了再继续”。普通函数遵循的是严格的调用栈规则进栈、执行、出栈一口气完成中途不会自己暂停再回来。而async/await打破了这种“一次性跑完”的直线逻辑允许函数在某个点主动退场等异步条件满足后再回来续写。这就是它和普通函数最大的不同普通函数必须一路跑到底。async函数可以在await处先停一下。Promise负责把“未来的结果”包装起来。微任务负责把“恢复执行”安排在合适的时机。async/await不是线程模型的变化而是执行控制权的重新编排。小结如果说 Promise 解决的是“如何把未来结果变成一个可观察、可组合的对象”那么async/await解决的就是“如何把这种异步等待写得像同步流程一样顺滑”。它的底层本质不是开启额外线程也不是把函数冻结成静态副本而是在await处先挂起当前执行把后半段逻辑保存下来等 Promise 完成后再通过微任务把这段逻辑从断点处恢复。正因为有了这套机制JavaScript 才能在单线程的约束下既保持代码的可读性又维持异步处理的灵活性。7.5 async/await 使用要点这一小节主要偏应用避坑主要是考虑有不少朋友使用async/await时不是那么的自如。async/await最容易让人误判的地方不在语法本身而在时序。很多初学者一看到await就会下意识地把它理解成“这里会停一下外层也会一起等一下”。其实不是。async函数一旦被调用就已经对外返回一个 Promiseawait只是在函数内部制造了一个挂起点让后半段代码稍后再恢复。外层世界并不会因为你写了await就自动进入等待状态。也正因为如此async/await的坑往往不是“不会写”而是“把它当成了同步代码去理解”。一旦这个前提错了循环、回调、错误捕获、并发顺序都会跟着出问题。这一小节作为使用要点首先需要记住三件事async函数一调用就返回 Promiseawait只负责当前 async 函数内部的挂起与恢复很多常见 API本来就不是为“等待异步”设计的。一、forEach里的await黑洞这是最常见也最容易把人带偏的写法const userIds [1, 2, 3]; userIds.forEach(async (id) { const data await fetchUser(id); console.log(拿到用户 ${id}); }); console.log(循环结束可以继续后续逻辑了);很多人第一次看到这段代码时会以为forEach会一个个执行回调等前一个回调里的await结束后再进入下一轮。实际上forEach根本不是这个语义。forEach的职责非常单纯同步遍历数组并逐个调用回调函数。它不会等待回调返回的 Promise也不会因为回调里写了async/await就改变自己的行为。换句话说forEach只负责“调用”不负责“等待”。所以这段代码真正发生的事情是forEach很快把所有回调同步调用一遍每个回调在await处挂起外层代码继续往下执行每个异步结果在未来的某个微任务阶段陆续回来。于是循环结束可以继续后续逻辑了先打印而拿到用户 1、拿到用户 2、拿到用户 3则可能在后面慢慢出现。它并不是“顺序等待”而是“顺手全发出去然后谁先回来谁先处理”。这种写法最容易出问题的地方是你本来想串行却误写成了“表面上看起来像串行实际上却是并发发起”。常见场景包括需要按顺序请求接口需要前一个任务完成后再开始下一个需要保证日志、状态更新、资源释放的先后顺序需要某一步失败后立刻中断后续流程。在这些场景里forEach(async ...)都不合适。如果你要的是串行那就用for...of或者传统的for循环for (const id of userIds) { const data await fetchUser(id); console.log(拿到用户 ${id}); }for (let i 0; i userIds.length; i) { const data await fetchUser(userIds[i]); console.log(拿到用户 ${userIds[i]}); }这类写法的特点非常明确上一轮不结束下一轮就不会开始。如果你要的是并发应该把 Promise 收集起来const results await Promise.all(userIds.map(fetchUser));这样写表达的意思就很清楚一起发起一起等待最后一次性拿结果。forEach同步遍历不等待回调for...of适合串行awaitmap Promise.all适合并发await。二、map(async ...)得到的不是结果而是一堆 Promise和forEach一起出现的还有另一个高频误区把map(async ...)的返回值当成最终结果数组。const results userIds.map(async (id) { return await fetchUser(id); }); console.log(results);很多人会期待results里装的是用户数据。实际上它更可能是一个Promise 数组。原因很简单map只是做映射不负责等待。而你传进去的回调又是async所以它的返回值天然就是 Promise。于是map(async ...)的结果不是“已经拿到的数据”而是“还在路上的承诺”。正确的方式const results await Promise.all( userIds.map(async (id) { return await fetchUser(id); }) );这时Promise.all才是那个真正负责“结算”的对象。它会把所有 Promise 一起等待最后给你一个完整的结果数组。map(async ...) Promise.all适合多个任务彼此独立希望并发发起希望最后一次性拿结果。不适合需要逐个顺序执行中间步骤彼此依赖单个失败不能影响全部流程。三、Promise.all很快但它是“失败即全失败”对于map Promise.all不要把它当成万能答案。const results await Promise.all([ fetchUser(1), fetchUser(2), fetchUser(3) ]);这段代码的特点是并发发起统一等待。它很快因为所有请求几乎是同时出去的但是只要其中任意一个 Promise rejected整个Promise.all就会直接失败。如果你的目标是“一组任务只要有一个失败整组就算失败”Promise.all很合适如果你的目标是“允许部分失败部分成功只要尽量收集完整结果”那就不合适。更适合容错批处理的方案const results await Promise.allSettled([ fetchUser(1), fetchUser(2), fetchUser(3) ]);Promise.allSettled会等所有 Promise 都结束然后返回每一项的最终状态。这在批量请求、批量上报、批量任务收集里特别实用。Promise.all快但失败就整体失败Promise.allSettled稳但需要你自己拆成功和失败。四、try/catch的使用限制这是第二个特别容易误判的点async function badFetch() { try { return fetch(/api/error); } catch (e) { console.log(内部捕获失败); } }很多人会以为既然外面套了try/catch那网络失败一定能抓住。实际上这个判断往往是不成立的。关键不在于catch写没写而在于你捕获的是不是同一层时序里的错误。fetch(/api/error)这一行先返回的是 Promise而不是一个已经抛出来的同步异常。如果你只是return fetch(...)那么当前 async 函数很快就结束了try/catch也跟着退出了。后面的失败是在 Promise 的异步阶段发生的很多时候已经不在这个catch的保护范围里。这就是为什么很多人会遇到一种很熟悉的错觉明明已经写了try/catch为什么错误还是跑出去了因为它跑出去的不是“异常对象”而是时间点。那么仔什么时候该用return await呢如果希望当前函数内部就把这个异步错误接住那就应该写成async function goodFetch() { try { return await fetch(/api/error); } catch (e) { console.log(内部捕获到了错误); throw e; } }这里await的作用是把 Promise 的失败点拉回到当前函数的try/catch语境里。这样一来错误就不是“已经飞到函数外面去了”而是在当前保护范围内被观察到。如果只是想把 Promise 原样交给外层去处理而当前函数自己并不需要兜底那么直接return fetch(...)完全可以。主要的考虑点是你希望错误在哪一层被观察到你希望谁来承担这次异步失败的处理责任。想让当前函数内部的catch接住错误常常要return await只是转交 Promise 给外层直接return就行。五、await不会自动把外层流程也停住还有一个常见误解是把await看成“全局暂停按钮”。实际上它只暂停当前 async 函数内部。外层调用者并不会因为你在内部写了await就自动跟着停下来。例如async function test() { console.log(A); await someAsyncTask(); console.log(B); } console.log(C); test(); console.log(D);有朋友会以为test()会把外层也拖住。但真实情况是test()一调用就先返回 Promise外层继续执行所以D会先打印test()里面则会在await处暂停等未来再恢复。所以await的影响范围始终是函数内部不是整个调用链自动冻结。六、await放在循环里不一定错但要知道爱的代价很多人一看到循环里有await就立刻觉得“这是不是不对”。其实不是。await放在循环里既可能是合理的也可能是错误的关键看你到底要什么。串行场景很合理for (const id of userIds) { await fetchUser(id); }这里的意思很明确前一个完成再做下一个。如果本来就需要这种顺序那它完全正确。适合这种写法的情况包括依赖前一步结果需要严格顺序需要控制请求速率需要避免并发过高。如果是并行场景那么久不该这么写如果本来是想同时发起多个请求那一个个await就会把它们强行串起来反而拖慢整体速度。const results await Promise.all(userIds.map(fetchUser));这种写法更符合并发意图一次性发起一次性收口。所以问题不在“循环里能不能写 await”而是在于到底想让这些任务串行还是并发。七、异步回调里的错误不一定能被外层同步try/catch接住还有些问题发生在回调式 API 里。try { setTimeout(async () { throw new Error(boom); }, 0); } catch (e) { console.log(这里通常抓不到); }明明外层套了try/catch为什么还是没抓住因为外层的try/catch只包住了当前这段同步调用栈。而setTimeout、事件回调、Promise 后续执行这些内容很多时候都已经发生在未来的另一个调度阶段不在这次同步栈里了。所以异步错误处理的原则很简单同步抛错用同步try/catchPromise 失败用awaittry/catch或.catch()定时器、事件、回调里的错误要在对应回调内部处理。不要期待一个外层try/catch能罩住整个未来。八、await不会吞掉错误它只是把错误带到你能看见的地方await很容易让人误会成“帮我把错误处理好了”。其实不是。它只是把 Promise 的结果展开成功就拿到值失败就把异常重新抛出来。const data await fetchData();这句的意思不是“保证安全”而是成功时data拿到结果失败时异常会在这里抛出交给上层处理。这也是为什么await往往会让人感觉错误“突然出现在某一行”。它不是制造了错误而是把原本藏在 Promise 里的失败搬到了你眼前。九、不要把并发和串行混为一谈这几乎是所有 async/await 误用的根本来源。串行const a await taskA(); const b await taskB();这种写法的含义是taskA完成后再做taskB。并发const [a, b] await Promise.all([taskA(), taskB()]);这种写法的含义是两个任务一起发起最后一起等结果。很多异步问题表面上看像语法错实际上都是时序理解错。你以为自己在并发结果写成了串行你以为自己在串行结果写成了并发。async/await只是把这件事写得更像线性代码但它并不会替你决定并发还是串行。十、不要让 Promise 悬空这是一个很隐蔽、但很常见的问题。async function foo() { doSomethingAsync(); // 忘了 await console.log(继续执行); }这里doSomethingAsync()返回了一个 Promise但你既没有await也没有.catch()也没有把它交给统一收口逻辑。结果就是这个 Promise 可能被“扔在半空中”后面的错误没人接后续的逻辑也可能以为它已经完成了。这类问题的本质是Promise 被创建了但没有被认真接住。所以当调用一个异步函数时至少要清楚自己是在做哪一种事要等它await要转交return要统一收口Promise.all / allSettled要显式忽略你得非常清楚自己为什么要这么做千万不要“无意中忽略”。十一、async本身不是问题问题是不要无意中制造不必要的等待很多人对await会有一种心理负担觉得它是不是“很慢”。其实真正拖慢代码的通常不是async这个语法本身而是你把本来可以并行的事情写成了串行或者把不必要的等待叠加在了一起。例如const a await taskA(); const b await taskB();如果taskA和taskB根本没有依赖关系这样写就把它们强行串起来了。更合适的方式往往是const [a, b] await Promise.all([taskA(), taskB()]);所以await不是性能问题的源头不必要的顺序等待才是。这一小节比较详细而琐碎在实际应用中可以记住下面条第一forEach不适合等待异步。它只同步调用回调不等待 Promise。要串行用for...of要并发用Promise.all。第二map(async ...)的结果是 Promise 数组。别把它当最终数据必要时要用Promise.all统一收口。第三Promise.all快但失败即全失败。要容错批处理考虑Promise.allSettled。第四try/catch不会自动穿透所有异步边界。如果你想在当前函数里接住异步错误return await常常是必要的。第五先想清楚是串行还是并发。这比先写代码更重要。第六任何 Promise 都要明确“谁来接”。要么await要么.catch()要么交给统一收口逻辑别让它悬空。8.任务的插队主线程的运转往往伴随着各种不可思议的“时序错觉”明明是先写下的定时器为什么被后发生的鼠标点击给无情地压制了明明主线程已经因为一段死循环彻底卡死为什么页面依然能够丝滑地滚动这部分我们将进入浏览器内核以 Chromium/Blink 调度器为主的世界了解一下任务的插队和控制权的争夺。8.1 从“表层的插队感”看透本质setTimeout(() console.log(定时器宏任务), 0); Promise.resolve().then(() console.log(Promise微任务));无论把setTimeout写在多么靠前的位置控制台始终是Promise抢先输出。在初学者看来这造成了极其强烈的“插队感”。它的底层机理正是微任务对宏观任务轨道展开的“队列优先级打击”。前面我们讲了Promise.then派生的是微任务而setTimeout(0)派生的是标准的任务。因为微任务检查点Microtask Checkpoint被脚本清理算法所守卫一旦执行上下文栈变空浏览器还来不及去拿任何下一个任务、也不考虑画面渲染之前必须立即把微任务队列“清到见底”。这种降维般的时空特权差异让 Promise 总是比setTimeout(0)先执行。这种时序差在宏观上就演变成了“降维插队”。真正的插队是“多任务源的博弈”然而严格从规范层面来说微任务的就地爆发一清到底属于生命周期内的“一种确定性延伸”它在标准里是注定的严格来说不算真正的插队。浏览器内核中真正的“插队”发生在同属任务Task的不同轨道之间。我们在前面也讲过HTML 规范允许浏览器拥有多个不同的任务队列如 Timer Queue、Network Queue、Input Event Queue。规范给出的规则只有一条同一个任务源内部的任务必须先进先出FIFO绝对不许乱序。但规范留给浏览器最大的自由度在于在面对不同的任务队列时事件循环下一圈到底去挑哪一个队列里的任务来执行完全由浏览器自行决定。这就给浏览器内核留下了巨大的调度优化空间。在浏览器的生存哲学里“响应性Responsiveness”和“视觉丝滑”拥有非常大的特权。为了维持这种特权浏览器内核的调度器在后台展开了高效率的特权划分。8.2 即便同属任务也有特权调度在真实的浏览器如 Chrome 的 Blink Engine内部当主线程同时面对一堆已经就绪的任务时底层的核心调度会将它们划分进不同的动态优先级层级Priority Tiers。这就是调度的偏心——为了防止 UI 冻结宿主环境在底层构建了一套“特权调度”机制。高优先级层级 —— 输入与交互队列Input Priority的特权当用户的鼠标在屏幕上划过、键盘在疯狂敲击、表单在同步提交时这些由原生交互产生的回调任务会被无条件地判定为最高特权。调度器哪怕看到定时器队列和网络队列里已经排了大量的任务它也会卡住其他所有轨道优先提审、连续执行用户交互相关的任务虽然调度器对单次连续执行的输入任务数量有一定限制但在持续的高频输入下低优先级任务依然可能面临长时间的等待。 这种特权调度就是为了保证用户在打字或点击时主线程能在几毫秒内给出反馈从而守住 UI 的流畅度防止界面产生肉眼可见的“UI 冻结”。默认/普通优先级层级 —— 网络与正常任务源