ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

async/await中return与return await的区别:Promise返回与异常处理深度解析

async/await中return与return await的区别:Promise返回与异常处理深度解析 做前端这么多年async/await几乎天天在写但每次代码评审只要聊到return总有人被绕进去。有一次一个同事写了段逻辑接口偶尔返回undefined查了半天发现是async函数里“少写了一个return”。还有一次try-catch包得严严实实错误却硬生生逃走了原因就是return和return await的语义差别。这些问题单拎出来都不难但混在一起确实容易翻车。这篇就围绕async/await里的return展开从底层机制到实际场景把返回值、return await、异常处理这些关键点彻底梳理一遍。不管你是刚接触异步编程的新手还是写了几年JavaScript的老手这篇文章应该都能帮你把一些模糊的地方补清楚。1. async函数的return到底返回了什么1.1 一切return都会被包装成Promise先记住最核心的一句话async函数永远返回一个Promise对象。不管你在函数体内return了什么最终传递给调用方的都是一个Promise而不是你return的那个原始值。举个例子async function getNumber() { return 42; } const result getNumber(); console.log(result); // Promise { 42 } console.log(typeof result); // object这里return 42但getNumber()拿到的并不是数字42而是一个Promise。这个Promise在内部被设置为已完成状态值是42。如果你想拿到真正的数字必须await它或者用.then()const result await getNumber(); console.log(result); // 42这个包装动作是语言规范里写死的在设计上async函数就是语法糖它内部会把返回值交给Promise.resolve做一次包装。所以从行为上说下面两个函数是等价的async function foo() { return 42; } function bar() { return Promise.resolve(42); }理解这一点非常关键。因为很多bug就出在“以为return的结果就是最终数据”结果拿到一个Promise没做await就往下操作最后得到[object Promise]或者直接undefined。1.2 return没有值的情况undefined陷阱如果async函数里没有return语句或者return后面没跟任何值那函数返回值就是Promise.resolve(undefined)。这个看起来太基础了但实际岗位上踩坑的不少。典型场景是条件分支async function isLogin() { if (token) { return true; } // 注意这里没有return所有非token路径都会走到这里 // 相当于 return undefined; } const status await isLogin(); if (status false) { // 你可能以为能到这里但实际上不会 console.log(未登录); }如果token不存在isLogin()返回的是undefined不是false。用if (status false)判断时undefined不等于false所以逻辑直接跳过。但如果你写的是if (!status)那undefined也会被当成假值倒还能工作。这种隐式的类型差异在团队协作里很容易引发“我这边测着没问题他那边却是坏的”这种闹剧。建议是需要返回布尔值的async函数所有路径都明确写return true或return false不要依赖“自然走到函数末尾返回undefined”的隐式行为。1.3 return一个Promise时会再多包一层吗有人会问如果我在async函数里return一个已经存在的Promise那这个Promise会不会被再包一层导致需要await两次答案是不会。在async函数里return一个Promise这个Promise会被“摊平”flatten调用方await一次就能拿到最终值。机制上类似Promise.resolve(promise)当参数本身就是Promise时Promise.resolve会直接复用这个Promise不会做无意义的二次包装。function getData() { return Promise.resolve(实际数据); } async function wrapper() { return getData(); } const result await wrapper(); console.log(result); // 实际数据不是Promise不过这跟我们下面要讲的return await还是有区别区别不在“最终值拿不拿得到”而在异常处理和时序上。2. return和return await90%的坑都出在这里2.1 经典错误try-catch里return了Promisecatch形同虚设这是整个return问题里最深的一个坑而且坑了无数人。先看这段代码async function test1() { try { return await Promise.reject(出错了); } catch (e) { console.log(test1捕获到了, e); return 恢复了; } } async function test2() { try { return Promise.reject(出错了); } catch (e) { console.log(test2捕获到了, e); return 恢复了; } } await test1(); // 输出test1捕获到了出错了 await test2(); // 没有输出直接抛出一个未处理的rejection区别就在一个await上。test1里return await Promise.reject(出错了)会先执行awaitawait看到的是一个rejected的Promise于是会在当前async函数上下文内抛出一个异常。这个异常发生在try块内部所以被紧跟其后的catch捕获整个函数正常恢复。test2里return Promise.reject(出错了)不会等这个Promise有任何结果函数直接把rejected Promise作为返回值交出去然后立即完成。此时try块已经执行完毕catch根本没有机会参与。异常被丢给了test2的调用方如果调用方也没做处理就是经典的“Unhandled Promise Rejection”。很多人的错误处理代码写成test2的样子还奇怪“明明包了catch为啥报错还是冒出去了”。这里道理其实和同步代码很像你在return一个“会抛错的动作”的引用而不是执行它再返回结果。async/await的try-catch只能捕获await表达式解析过程中产生的异常捕获不了“return一个会失败的Promise”这种操作本身。那test2的错误到底能被谁捕获答案是调用方如果调用方用await接住的话try { await test2(); } catch (e) { console.log(调用方捕获到了, e); // 出错了 }但这就失去了在函数内部进行错误处理的初衷等于把这个Promise当作“裸的rejection”直接抛出去了。2.2 执行时机的那一点点差别除了异常捕获return和return await在“什么时候完成函数”上也有微妙的差异。async function f1() { return Promise.resolve(1); } async function f2() { return await Promise.resolve(1); }f1在return这一行就会立刻把Promise的状态交接给外部函数体结束了。f2则需要等Promise.resolve(1)的微任务回调执行完拿到1然后再把1包裹成Promise返回。这意味着f2整体完成时间会晚一个微任务周期。虽然这个差异在日常业务里几乎感知不到但在性能敏感的高频调用场景比如循环里反复调用同一个async函数每多一层多余微任务都会累积开销。V8引擎后来优化过async/await的执行模型但在编写时还是建议在try-catch以外不要无脑到处写return await没必要就不用。不过在try-catch内部却是反过来不要无脑省掉await。除非你明确知道异常该由谁处理。2.3 团队代码规范怎么定这几年的社区lint规则里比较主流的方向是强制在try-catch内使用return await。比如ESLint的no-return-await规则本来提倡“不要写return await”后来社区又专门推出了typescript-eslint/return-await允许配置成in-try-catch要求用户在try块里return Promise时必须有await。我个人建议的规范是try块内return一个Promise时一律写成return await保证try-catch能捕获到。try块外return一个Promise时直接return就行不用画蛇添足写await。如果你不确定统一用try-catch包一层然后统一return await这样最省心也多不了多少开销。这个规范的好处是代码逻辑稳定不会因为“少写一个await”就把异常漏出去。3. 实战中的return场景拆解3.1 接口请求不要忘记return前端最常见的异步场景就是接口请求。很多人写请求时会犯一个低级但致命的错误async function getUser() { const response await fetch(/api/user); const data response.json(); // 忘了return } const user await getUser(); console.log(user); // undefinedresponse.json()是一个Promise但如果你不return它async函数自然认为你要返回undefined。于是getUser返回了一个值为undefined的Promise等到调用方拿到结果时只剩一头雾水。正确的写法是async function getUser() { const response await fetch(/api/user); return response.json(); } const user await getUser(); console.log(user); // 真实的用户数据这里的response.json()本身返回Promise但async函数会把它摊平成最终数据所以调用方await一次就能拿到JSON对象。如果想进一步做兜底可以这样async function getUser() { try { const response await fetch(/api/user); if (!response.ok) { throw new Error(HTTP错误${response.status}); } return await response.json(); } catch (error) { console.error(获取用户信息失败, error); return null; } }这里用了return await response.json()保证如果res.json()解析失败比如返回的是非法JSON也会进入catch兜底。如果写成return response.json()那么json解析异常会绕过catch直接成为未处理的rejection。3.2 错误恢复return await的正确姿势还有一个常见用法在catch块里决定要不要恢复然后返回恢复值。这个场景里return await能保证所有后续的异步逻辑仍然在错误处理范围内。async function loadConfig() { try { const remoteConfig await fetchConfigFromServer(); return await processConfig(remoteConfig); } catch (error) { console.warn(加载远程配置失败使用本地默认配置); return getLocalDefaultConfig(); } }这里catch块返回的getLocalDefaultConfig()即使是个Promiseasync函数也会摊平它。但如果getLocalDefaultConfig()内部出现错误这个错误仍然会变成loadConfig()的rejection只能由外部调用方处理。如果你想在catch里再做一层保险不让错误继续往外冒那就得这样写async function loadConfig() { try { const remoteConfig await fetchConfigFromServer(); return await processConfig(remoteConfig); } catch (error) { try { return await getLocalDefaultConfig(); } catch (fallbackError) { console.error(本地配置也损坏了, fallbackError); return {}; } } }这样嵌套虽然在缩进上不好看但能保证这个函数“尽量”不抛出异常。不少基础库在初始化时就是这么干的宁可返回空配置也不让启动流程挂掉。3.3 并发任务return与Promise.all当你在async函数里需要并发执行多个任务并用return汇总结果时要小心“并发被串行化”的问题。async function getAllData() { const user await fetchUser(); // 等第一个完成 const posts await fetchPosts(); // 才开始第二个 return { user, posts }; }这段代码虽然用了await但两个请求是串行的总耗时是两个接口耗时的和。如果这两个请求没有依赖关系应该并发执行async function getAllData() { const [user, posts] await Promise.all([fetchUser(), fetchPosts()]); return { user, posts }; }这里Promise.all会并发发出两个请求等所有请求都settle之后再统一返回。总耗时约等于最慢的那个请求。在并发场景里return语句本身没什么特别但如果你忘了await Promise.all直接return Promise.all的结果那么调用方拿到的确实还是一个Promise因为async函数也会摊平它单值上是没问题的。不过要注意如果并发里某个Promise被rejectPromise.all会整体reject错误会传递给调用方。如果希望“其他任务成功的结果也保留下来”就要用Promise.allSettledasync function getAllData() { const results await Promise.allSettled([fetchUser(), fetchPosts()]); return results.map(r (r.status fulfilled ? r.value : null)); }这种写法适合“有几个失败不影响整体”的场景。3.4 循环与递归中的return在循环里使用await时一个常见的错误是想当然地以为return能“提前结束整个循环”。其实return当然可以结束当前async函数但如果你是在一个回调式的循环里比如forEach用async函数情况就变了async function processList(list) { list.forEach(async (item) { await process(item); }); return done; // 这里不会等forEach里的异步任务完成 }上面的代码里forEach传入的async回调里的await并不会阻塞外层processListforEach会立刻遍历完processList也立刻返回done。那些异步任务还在后台慢慢跑。这就是“async回调不能被forEach等待”的老问题。如果你想批量处理并等待全部完成用for...of更稳async function processList(list) { for (const item of list) { await process(item); // 逐项处理 } return done; }或者仍然用并发但改成mapasync function processList(list) { await Promise.all(list.map(item process(item))); return done; }递归里同理async函数递归时每一层的返回值依然走Promise包装规则。经典的目录遍历async function findFile(dir) { const entries await readDir(dir); for (const entry of entries) { const fullPath path.join(dir, entry); const stat await getStat(fullPath); if (stat.isDirectory()) { const found await findFile(fullPath); // 递归 if (found) return found; } else if (entry target.txt) { return fullPath; } } return null; }注意递归调用时用了return findFile(...)这其实也是return一个Promiseasync函数会摊平它。如果想更保险在try-catch中建议写成return await findFile(...)语义是一致的但错误处理能更精准。递归场景多异常容易层层包裹建议在关键递归处显式return await方便调试栈。4. 那些return相关的迷惑行为4.1 throw和return Promise.reject的区别在async函数里throw异常和return一个rejected的Promise最终表现有时看起来一样但语义上有区别。async function f1() { throw new Error(错误1); } async function f2() { return Promise.reject(new Error(错误2)); } await f1(); // 抛出错误1 await f2(); // 抛出错误2调用方在这两种情况下都能用try-catch捕获异常。区别主要在可读性和意图上throw一个Error对象语义是“这里发生了异常”也更符合同步代码的思维习惯。return Promise.reject(...)暗示你是在“主动构造一个失败的Promise并返回”在一些封装函数里偶尔有用但容易让读者疑惑。我个人建议业务代码里统一用throwreturn Promise.reject只在极少数“必须返回一个Promise而不能throw”的适配场景才用。还有一个细节在async函数里return Promise.reject()之后如果你在函数末尾又写了一些同步代码那些代码还是能执行的async function f() { return Promise.reject(new Error(失败)); console.log(这行会执行吗); // 会执行 }return后面那行console.log其实不会执行……等一下这条要仔细核实。常规return后面的语句是不执行的。但如果return的是表达式表达式会先计算return之后的其他语句不会执行。上面的console.log在return之后所以不会执行。这是个常见的误解点不应该踩。换个能体现差异的例子async function f() { const promise Promise.reject(new Error(失败)); throw new Error(提前终止); }这种情况下promise已经创建即使函数后来throw了别的错误这个rejected Promise仍需被处理比如被await、catch否则可能出现unhandled rejection警告。所以要小心在async函数里创建了rejected Promise却没有await或catch它即便后面有别的return或throw那个游离的rejection也可能触发警告。4.2 return一个thenable会发生什么JavaScript里有一类特殊的对象叫thenable就是带then方法的普通对象。async函数在return一个thenable时会被Promise.resolve“吸收”掉当作Promise来处理async function f() { return { then(resolve) { resolve(我是thenable); } }; } const result await f(); console.log(result); // 我是thenable这在实际项目中很少会刻意去用但你在调用一些第三方库时如果库返回的对象恰好是thenableasync函数会把它当作异步值来处理。这个行为其实和await一个普通对象的机制是相同的。更值得提醒的是如果你return了一个对象但对象内部有自定义then属性且不是函数那Promise.resolve会直接把它当成普通值。这种edge case遇到过一两次就会长记性。4.3 测试一下执行顺序用一个简单例子把return和return await的执行顺序差异测试清楚async function testReturn() { console.log(return前); return Promise.resolve(直接返回Promise); } async function testReturnAwait() { console.log(return await前); return await Promise.resolve(先等待再返回); } testReturn().then(() console.log(testReturn完成)); testReturnAwait().then(() console.log(testReturnAwait完成));在大多数现代的JavaScript引擎里几乎很难观察出先后的差距因为V8对async/await做了专门优化。但在老版本Node或某些旧浏览器里return await会比return多一个额外的微任务周期整体完成时间略微靠后。写这段不是让你去追求微妙的性能优化而是想说不要迷信“反正都一样”的说法两个写法在某些环境下确实有可测量的差异。正因如此团队规范里才要统一风格。5. 常见问题速查与避坑指南5.1 问题与解决方案对照表现象可能原因解决办法调用async函数拿到undefined函数内忘记了return检查所有分支确保有返回值的路径都显式returntry-catch没捕获到异步错误try块内return了一个Promise没用await把return改成return await接口总是返回[object Promise]调用方忘了await或者多包了一层确保调用方await不要new Promise再包一层判断返回值时把undefined和false混淆函数没有显式返回false所有分支都写明确return值循环里return没生效用的是forEach async回调改用for...of或Promise.all等待异步任务完成并发请求变成串行await一个接一个写在try里没有并发用Promise.all合并并发无依赖的请求游离的unhandled rejection创建了rejected Promise但没await或catch用catch兜底或确保调用方正确await这个表里每一条我都见过不止一次有些是同事代码里踩到有些是我自己线上debug到怀疑人生。5.2 几个越早知道越好的检查习惯第一写async函数时先深呼吸想一下“我这个函数应该返回什么”。如果是数据那每个return路径都要返回同样类型的数据。如果是void不返回值那就别期待调用方拿到任何有效值。第二遇到try-catch检查所有return语句是否写成了return await。这个检查极其重要尤其是你准备把一段同步的逻辑改造为异步时。同步代码里return fn()和return fn()没有区别但异步代码里有区别。第三手写一个简单的“返回值类型断言函数”在关键的调用边界做一次校验function isPromise(value) { return value typeof value.then function; } async function getData() { const data await request(); if (isPromise(data)) { throw new Error(getData返回值异常不该是Promise); } return data; }这个做法看起来有点笨但在一些内部封装较多的项目里真的能尽早暴露问题。5.3 我的个人习惯和一点体会我现在写async函数时脑子里会有一张检查清单函数返回值类型确定吗异常是不是在每个层级都被正确处理并发请求有没有被串行化forEach有没有用错这些在我日常工作中反复出现每次代码提交前都会过一遍。说实话async/await的return问题核心就是两条主线一条是“Promise包装和摊平”另一条是“return和return await在异常捕获时的行为差异”。这两条主线吃透了剩下的都是具体分支场景。如果你现在正好被一个async返回值问题卡住比如接口返回undefined或者错误总是逃过catch不妨先回头看看代码里的每个return旁边有没有await。很多时候问题就是这么简单简单到让人不好意思怀疑。
返回列表