大家可能都经历过这种时刻:一段业务逻辑需要先拉用户信息,再根据用户角色拉菜单,接着还要拉权限列表,最后才能渲染页面。如果用老式回调去写,代码会一层套一层,屏幕越写越歪,后来有了 Promise,用.then()链可以拉直一点,但每一层还是写着then(res => { return ... }),好像戴着手套数硬币,别扭,还容易漏写 return。直到 ES2017 引入了async/await,我个人的体感是:异步代码终于可以写得像同步一样,平铺直叙,该等待就等待,该报错就报错,读代码的人终于不用在.then和.catch之间跳来跳去了。
这篇博文我想从一个多年写 JavaScript 的“老油条”视角,把async/await的底层逻辑、常见用法、致命误区还有排查技巧从头到尾捋一遍。不管你是刚接触异步编程的新手,还是已经用了很久但偶尔被诡异行为坑一把的开发者,都可以对照着看。我会尽量用“人话”解释原理,并给出可以直接抄进项目的示例代码。
1. 为什么需要 async/await:异步编程的演进与痛点
1.1 回调时代的“不可读代码”与错误处理灾难
在async/await出现之前,最朴素的异步写法就是回调函数。比如发起一个网络请求,等数据回来后执行下一步,代码长这样:
requestUserInfo(function (userInfo) { requestMenu(userInfo.role, function (menu) { requestPermission(menu.id, function (permission) { renderPage(userInfo, menu, permission); }); }); });这种代码被形象地称为“回调地狱”。它的问题不只是难看,更重要的是控制流难以把握。一旦你需要在多个异步操作之间做条件判断、循环、并行、失败重试,回调写法会迅速退化成一坨无法维护的意大利面。之后的 Promise 把回调展平了:
requestUserInfo() .then((userInfo) => requestMenu(userInfo.role)) .then((menu) => requestPermission(menu.id)) .then((permission) => renderPage(userInfo, menu, permission)) .catch((error) => handleError(error));比回调清晰一些,但每个.then()里如果还有分支逻辑,你依然要用嵌套的async函数或者再套一层 Promise,链条越长,越容易在返回值传递上出错。比如你忘记return,下一个.then()拿到的是undefined,调试起来让人抓狂。
1.2 Promise 解决了什么,又留下了什么
Promise 确实解决了两件事:状态管理和错误传播。它让异步操作有了三种确定的状态(pending、fulfilled、rejected),并且一旦状态改变就不会再变。.catch()可以捕获链条上任意一环的异常,这比回调里手动传递error参数规范得多。
但 Promise 还有两个痛点没解决。第一,冗长的样板代码:每次异步操作都要写.then()和.catch(),尤其当多个异步步骤之间有强依赖时,代码依然有点“噪”。第二,心理模型不统一:你明明是在写“先做 A,然后做 B,然后做 C”的线性逻辑,硬要拆成一堆链式调用,大脑还需要在“同步思维”和“异步思维”之间反复切换。
1.3 async/await 如何把异步写成了同步
async/await并不是推翻了 Promise,而是建立在 Promise 之上的一层语法糖。一个async函数内部,所有的await表达式都会被解释器暂停执行,直到右侧的 Promise 完成。这个“暂停”不是阻塞线程,而是让出控制权,事件循环继续处理其他任务,等 Promise resolve 后再回到函数内部继续执行。所以你的代码可以这样写:
async function loadPage() { const userInfo = await requestUserInfo(); const menu = await requestMenu(userInfo.role); const permission = await requestPermission(menu.id); renderPage(userInfo, menu, permission); }这几乎就是同步代码的语序,读起来非常自然。而且async函数内部可以放心使用try/catch,异常处理也回到了命令式风格。
2. async/await 的核心机制与语法细节
2.1 async 函数的返回值:你以为返回什么,它就包装什么
很多人只知道async函数内部可以用await,但忽略了它本身的返回规则。有个非常实用的规律:async函数一定会返回一个 Promise 对象。哪怕你写async function foo() { return 1; },调用foo()得到的也不是1,而是一个 fulfilled 的Promise<1>。如果你在async函数里throw new Error(),那么这个函数返回的 Promise 会变成 rejected。
这意味着,当你在事件监听器或者需要同步返回值的地方使用async函数时,要特别小心。比如:
const button = document.querySelector('#btn'); button.addEventListener('click', async () => { // 这个回调返回的是一个 Promise,但 addEventListener 不关心返回值 // 如果里面有异常且没有捕获,会变成一个未处理的 Promise rejection });所以我的习惯是:事件监听器里的async函数,内部一定要有完整的try/catch,否则错误会“静默丢失”或被全局错误处理捕获,表现成让人摸不着头脑的报错。
2.2 await 到底在等什么:非 Promise 值会有怎样的行为
await右侧可以是任意表达式。如果它不是 Promise,JavaScript 会把值包装成Promise.resolve(value),然后继续往下执行。这时它相当于一个“微任务延迟”,但实际效果和直接同步执行几乎一样。真正需要留心的是await会暂停当前函数的执行,但它不会阻塞其他代码。看这个例子:
console.log(1); awaitPromise(); // 异步函数 console.log(2); async function awaitPromise() { await Promise.resolve(); console.log(3); }执行顺序是 1、2、3 还是 1、3、2?答案是 1、2、3。因为await是把函数后续代码放入微任务队列,主线程先继续执行后面的同步代码。这也解释了为什么async/await不能真正“阻止”一段外部逻辑,它只是让函数内部的执行顺序看起来像同步。
2.3 串行与并行的取舍:不要让 await 排队等无关任务
新手最容易犯的错就是把没有依赖关系的异步任务写成串行。比如同时要拿用户信息和系统配置,两者互不相关:
const user = await getUser(); const config = await getConfig();这样虽然好读,但两次请求是串行的,总耗时是两次请求的和。正确做法是用Promise.all并行触发,然后再await聚合结果:
const [user, config] = await Promise.all([getUser(), getConfig()]);这里特别想说一个细节:Promise.all是“全有或全无”,如果其中一个失败,整个await会直接抛出,但这并不意味着其他请求会被取消,它们仍然会在后台继续执行,只是结果没有被使用。如果你希望“部分成功也能继续”,要用Promise.allSettled。理解了这个,你就明白为什么有些场景接口报了错,网络面板里却还看到其他请求成功返回——因为 Promise 本身没有“取消”的概念,all失败只是拒绝聚合结果,底层请求已经被发出去了。
3. async/await 与 JavaScript 核心特性的搭配技巧
3.1 事件处理程序中使用 async 的三大坑
前面提到事件监听器不能直接消费 async 函数的返回值。实操中还有两个高频坑:
第一,防抖失效。如果你在mousemove或input事件里使用async,回调本身会立刻返回,你无法通过return false来阻止默认行为,而且event.preventDefault()虽然可以调用但要尽早。更重要的是,异步回调里的event对象可能已经被事件队列清理,访问event.target.value时不一定是你预期的值。我建议先把需要的数据同步取出来保存在局部变量里,再进入异步逻辑。
第二,重复触发导致的竞态。用户快速点击提交按钮,多次触发async事件,多个异步请求会同时进行。正确处理是使用“开关锁”:建立一个isPending变量,如果为true则直接返回,最后在finally里复位。或者使用带有AbortController的请求库,后发请求取消前一个。这些都属于工程化处理,async/await本身不会帮你防止这种情况。
3.2 循环里使用 await:forEach 的陷阱与 for...of 的正确姿势
我们要遍历一个数组,对每一项都做异步操作。很多新手会写:
[1, 2, 3].forEach(async (item) => { await fetchData(item); }); console.log('done');结果 “done” 立刻打印,因为forEach不会等待回调里的await。它只负责调用回调,不负责等待回调返回的 Promise。所以这个循环里的请求实际上并发发出去了,而且你没有办法在所有请求完成后做汇总操作。
正确做法是用for...of,它配合 await 可以逐项等待:
for (const item of [1, 2, 3]) { await fetchData(item); } console.log('done');这里的逻辑变成:等第一个请求完成,再发第二个,依次串行。如果你希望并行处理全部,同时又能等待所有完成,用Promise.all配合map:
await Promise.all([1, 2, 3].map((item) => fetchData(item)));这三种方式的区别可以用表格总结:
| 写法 | 执行行为 | 适用场景 |
|---|---|---|
forEach + async | 并发启动,无法等待全部完成 | 不建议使用 |
for...of + await | 严格串行 | 后一步依赖前一步结果 |
map + Promise.all | 并发执行,统一等待 | 任务之间不依赖,需要全部结果 |
3.3 判断数据类型与 async 函数之间的交互细节
JavaScript 判断数据类型有四种常见姿势:typeof、instanceof、Object.prototype.toString.call和Array.isArray。在使用 async 函数时,我遇到过两个容易混淆的场景。
第一个是判断async函数的类型。typeof (async () => {})返回的是"function",这没问题;但如果你想判断一个对象是否是 Promise,直接用instanceof Promise会有边界问题,因为不同 iframe、不同 realm 下的 Promise 构造函数不互通。更稳妥的做法是用thenable判断:obj && typeof obj.then === 'function'。async/await内部本来就是用 thenable 机制处理await,它能兼容不少跨环境场景。
第二个是Object.prototype.toString对async函数的描述。它在规范中是[object AsyncFunction]。如果你的代码需要精确判断,可以这样写:
function isAsyncFunction(fn) { return Object.prototype.toString.call(fn) === '[object AsyncFunction]'; }这种判断在封装日志系统、统一错误处理中间件时很有用,因为 async 函数的特点是你无法同步得到结果,必须await或.then()。
3.4 与原生环境交互(OC/JS 互相调用)的异步处理思路
在跨端开发里,JavaScript 经常要和原生代码交互,比如 iOS 的 Object-C(OC)通过 WebView JavaScriptCore 调用 JS 函数,或者 JS 调起原生能力。这类接口设计成异步居多,常见做法是原生提供一个回调方法,JS 侧包装成 Promise,再用 async/await 调用。
一个典型的封装步骤:
function nativeGetDeviceInfo() { return new Promise((resolve) => { window.webkit.messageHandlers.getDeviceInfo.postMessage({}); // 原生返回后调用 JS 侧暴露的全局回调 window.nativeCallback = (info) => { resolve(info); }; }); } async function init() { const info = await nativeGetDeviceInfo(); console.log('设备信息', info); }这里有几个注意点。第一,原生和 JS 的通信存在“桥”的开销,频繁创建 Promise 反而让性能下降,通常建议批量调用。第二,要防止回调没有触发导致的 Promise 永远 pending,最好加超时机制:
function withTimeout(promise, ms = 3000) { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)), ]); }这个withTimeout是一个非常万能的小工具,不只适用于原生交互,所有不可控的异步源(WebSocket、定位、第三方SDK)都可以用它兜底。
4. 错误处理与调试实战:从 try/catch 到全局监控
4.1 捕获所有异常:try/catch 的正确使用范围
在async函数内,await前面最好能预判哪些操作会失败。把try/catch放到包含多个await的整个函数体外层,虽然能捕获所有异常,但也意味着只要中间一个请求失败,后面所有逻辑都会跳过。有时候我们并不希望这样,比如三个请求核心数据,其中一个失败就整体失败,适合外层捕获;但有的时候某一个可选数据失败不应该影响主流程,那么就要在单独的子块里捕获:
async function loadDetail() { let adsBanner; try { adsBanner = await loadAds(); } catch { adsBanner = null; // 广告加载失败不阻塞详情 } const detail = await loadDetailData(); // 这个失败则整体上抛 }一个很实用的心得是:尽量在错误发生的地方就近处理,只把“真正需要上报或兜底”的错误抛给上层。否则全局错误监听里会收到大量低级别噪声,真正的核心故障反而被淹没。
4.2 全局未处理的 Promise 拒绝:监听与定位技巧
即便你加了try/catch,仍然可能出现未被捕获的rejection,因为某些 Promise 根本没有进入await流程。例如一个 async 函数被当作事件回调直接调用,内部没有捕获,它的 rejection 就会逃逸。浏览器环境可以监听unhandledrejection事件:
window.addEventListener('unhandledrejection', (event) => { console.error('未处理的 Promise 拒绝:', event.reason); // 上报监控系统 });在 Node.js 环境则是process.on('unhandledRejection', callback)。这个监听器能帮助你在开发阶段发现问题,但不要依赖它作为唯一的错误处理手段,因为一旦rejection发生且被监听,响应式逻辑可能已经处于不一致状态。
定位这类错误有一个技巧:在抛出的错误对象上补充上下文栈信息。比如你封装请求方法时,可以在 catch 后再抛一个新错误,把请求 URL、参数、耗时都附上去:
async function request(url) { const start = Date.now(); try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } catch (error) { throw new Error(`请求 ${url} 失败, 耗时 ${Date.now() - start}ms, 原因: ${error.message}`); } }这样在控制台看到的错误信息一线到底,非常省心。
4.3 超时、竞态与取消:await 无法直接解决的边界场景
await让代码像同步,但底层依然是异步机制,所以有几个同步思维容易忽视的问题。
首先是超时。await fetch()如果没有特殊处理,默认没有超时限制,网络挂起时你的函数会一直等在那里。上面提到的Promise.race是解决思路,但它有个副作用:竞速输掉的一方仍然会继续执行,只是它的结果被丢弃。如果被丢弃的是一个网络请求,它不会被自动 abort;如果被丢弃的是定时器,它会继续触发。所以严谨一点,最好使用带AbortController的 fetch 接口,在超时分支里主动调用abort()。
其次是竞态。比如搜索框输入关键字,用户先输入“abc”,后输入“abd”,如果第一次请求比第二次慢,后发先至的结果可能覆盖先发起的结果。解决思路是使用一个自增请求 ID 或在入口判断当前请求是否过期:
let requestSeq = 0; async function search(keyword) { const seq = ++requestSeq; const results = await fetch(`/search?q=${keyword}`); if (seq !== requestSeq) return; // 说明有更新的请求,丢弃本次结果 render(results); }这个方法虽然朴素但很实用,本质上是为每次异步流程做一个版本标记,防止旧响应污染新状态。
5. 常见问题排查与避坑清单
5.1 “await 后代码不执行了”是怎么回事
遇到“await 之后的 console.log 没输出”时,优先排查几件事:
- Promise 是否永远 pending:比如某个回调函数没被调用,
resolve没执行。可以给 Promise 加超时或直接打印pending状态。 - 是否被 catch 吞掉了:
try块里await抛错,如果 catch 里没有打印日志,你只会看到后面代码没执行。 - 是否被
Promise.all拒绝但没处理:all中任何一个失败,整个 Promise 进入 rejected,你的await后面代码就不会跑,必须加catch。
我还遇到过一种很隐蔽的情况:await右侧的 Promise 是自己写的一个类,但then方法里忘了调用回调,导致一直 pending。所以排查时可以直接看开发者工具的 Sources 面板里 Promise 的状态,或者临时写一行console.log('before await', await promise)观察输出。
5.2 内存泄漏与事件监听器:async 函数中的“隐形负担”
当你在浏览器里为 DOM 元素绑定一个 async 事件监听器,却没有在合适的时机移除时,它内部引用的对象会被一直保留,容易造成内存泄漏。另外,异步函数中如果使用了setInterval或长耗时定时器,组件卸载或页面跳转后定时器还在跑,继续触发里面的await操作,结果自然报错。
一个规范的做法是:凡是创建了定时器、监听器、WebSocket 连接等资源,都要在finally中清理。拿 React 的函数组件举例(其他框架类似):
useEffect(() => { let cancelled = false; const timer = setInterval(async () => { if (!cancelled) { const data = await fetchData(); // 更新状态前检查 cancelled if (!cancelled) setState(data); } }, 5000); return () => { cancelled = true; clearInterval(timer); }; }, []);这里的cancelled标志是防止异步操作跨周期更新状态,也是一种竞态防护。很多内存泄漏不直接表现为卡顿,而是在页面长时间运行后逐渐发卡,排查工具也未必能一眼定位,所以养成清理习惯远比事后优化重要。
5.3 async/await 的性能影响:注意微任务排队
有人担心async/await比原始 Promise 慢。从单次调用来看,多出的开销微乎其微,可以忽略不计。但有一种情况需要注意:如果你在超高频回调(比如requestAnimationFrame、mousemove)里滥用await,它会创建大量微任务,这些微任务要等主线程空闲时才执行,可能造成明显的帧率下降。
正确策略是:高频事件里不要用await,而是用节流/防抖+标记位。即使要使用,也要保证await后面是已经 resolve 的值,避免无谓的微任务排队。从宏观角度看,代码的可读性和可维护性带来的收益远大于这点性能损耗,所以我一般不会刻意把async/await改成原始 Promise 去优化,除非分析确认它是瓶颈。
5.4 编译与兼容性: transpile 时需要注意的细节
老项目一般会把代码编译到低版本浏览器,async/await会被 Babel 等工具转成基于 Promise 的asyncToGenerator运行时函数。这里面有几个坑:
- 编译后的代码依赖全局
Promise,如果你要兼容老 IE,还得引入 Promise 的 polyfill,并且 polyfill 必须最先加载,否则运行时报错Promise is not defined。 - 原生
finally语法在编译时可能无法被部分旧工具正确处理,建议使用try/catch/finally已有的结构,或确保工具链升级到支持finally的版本。 - 真机浏览器兼容性可以用
caniuse查,但更重要的是在 CI 里加上目标浏览器自动测试,别等到用户反馈才排查。
一句话总结我的经验:只要用了 async/await,就要做好“异步异常全局监控”和“并发防重”这两件配套工作,否则代码写起来是爽了,线上排查会很难受。
6. 一些真心建议与长期习惯
从async/await进入 ES2017 到现在,我见过太多团队从“不用”到“所有地方都 await”,中间踩遍了坑。我个人现在的判断标准是:如果没有明显的串行依赖,就尽量用Promise.all或Promise.allSettled减少等待;如果有依赖,才用await逐个等待。这能显著降低接口链路的总耗时。
另外,我特别推荐在项目里统一封装一个async工具函数集合,比如withTimeout、retry、sequential、parallel。这些东西写一次,后续所有业务模块都受益。retry的伪代码也分享在这里:
async function retry(fn, times = 3, delay = 1000) { let lastError; for (let i = 0; i < times; i++) { try { return await fn(); } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError; }它配合async/await使用,可以在网络波动时自动重试,非常实用。不过要注意重试场景的“幂等性”——比如支付、创建订单这类操作不能盲目重试,否则会造成重复提交。
最后再分享一个小技巧:在写async函数时,尽量把函数体控制在“一眼能看完”的长度。如果函数体太长,说明业务流程过于复杂,适当拆分成多个小函数,每个小函数只负责一件明确的异步事务,这样await的语义会变得特别清晰,后续加日志、加监控也方便。我见过最头疼的代码就是在一个 200 行的async函数里连续await十几个方法,中间还夹杂各种条件判断,出了错根本不知道问题出在哪一个环节。把大函数拆细,配合具体的错误上下文,排障效率能翻好几倍。