整个过程没有引入新的线程
把执行栈搬到堆里”来形容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函数一旦被调用,就已经对外返回一个 Promise;await只是在函数内部制造了一个挂起点,让后半段代码稍后再恢复。外层世界并不会因为你写了await,就自动进入等待状态。
也正因为如此,async/await的坑,往往不是“不会写”,而是“把它当成了同步代码去理解”。一旦这个前提错了,循环、回调、错误捕获、并发顺序,都会跟着出问题。
这一小节作为使用要点,首先需要记住三件事:
async函数一调用就返回 Promise;await只负责当前 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:适合串行await;map + 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/catch; - Promise 失败,用
await+try/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 冻结”。
默认/普通优先级层级 —— 网络与正常任务源:
