同事一脸困惑地跑过来给我看页面:明明接口返回了"login success",函数也写了return "login success",可页面上显示的是[object Promise]。我看了一眼代码,函数名前面赫然多了个async。这不是他第一次在 async/await 的 return 上栽跟头了,我猜你多半也遇到过类似的坑:要么 return 出去的值拿不到,要么在 try/catch 里 return 了但错误还是冒出去了,要么在回调里 return 一圈发现什么都没发生。
这篇文章就把async function里的return从底层到实践彻底梳理一遍,覆盖面试常考的执行顺序题、日常开发中最容易踩的五个 return 陷阱,以及return await promise和return promise到底差在哪。做前端或 Node 的同学,尤其是写过一段时间异步代码但总是被Promise返回值教育的,这篇应该能帮你省下不少排查时间。
先说结论:你看到的return,在 async 函数里是"假"的。你以为你在返回值,实际上你在给一个隐式的Promise设定最终状态。
1. async 函数里的 return 为什么总是"变"Promise
1.1 一件奇怪的事:return 一个普通值,外面拿到的却是 Promise
先看最基础的现象:
async function getMessage() { return "hello"; } console.log(getMessage()); // Promise {<fulfilled>: 'hello'}你没有看错,getMessage()的返回值是一个 Promise,而不是字符串"hello"。很多初学者在这里就会卡住:我明明 return 了字符串,为什么拿不到?
这里有一个 JavaScript 语言层面的强制规则:async 函数永远返回一个 Promise。无论你在 async 函数里return什么值,这个值都会被自动塞进一个 Promise 里,作为这个 Promise 的 resolve 值返回给调用方。想拿到真正的字符串,你只能这么写:
async function getMessage() { return "hello"; } getMessage().then((msg) => { console.log(msg); // "hello" }); // 或者在另一个 async 函数里 async function main() { const msg = await getMessage(); console.log(msg); // "hello" }不是 JS 故意整人,而是async/await的定位就是"基于 Promise 的语法糖"。一旦函数被标记为async,它的返回类型就固定了——只能返回 Promise。
1.2 async 函数 return 的真实处理流程
那么return "hello"是怎么变成 Promise 的?规范里的处理过程并不复杂,可以拆成三步:
- async 函数执行时,引擎会立刻创建一个 Promise(我们叫它
outerPromise)作为函数的返回对象。 - 函数体同步执行到
return语句,JS 引擎取出 return 后面的表达式值。 - 这个值会被传入一个类似
Promise.resolve(value)的流程,用来决定outerPromise最终变成什么状态。
关键在第三步。不同情况对应不同的结果:
| return 的值 | outerPromise 最终状态 | 调用方 await 的结果 |
|---|---|---|
| 普通值(字符串/数字/对象/undefined) | fulfilled,携带该值 | 拿到该值 |
一个 Promise/thenable(return promise) | 跟随这个 promise 的状态(状态由它决定) | 拿到 promise 的 resolve 值 |
函数体内throw error | rejected,携带错误 | 抛出相同错误 |
我平时给团队讲这个时会用一个快递类比:async 函数就像一家快递公司,你 return 的普通值是"货物",但你不是直接在仓库门口收货,而是快递公司把货物打好包(Promise),再送到你手上。你手上拿到的永远是一个"包裹预告",想验收货物,必须拆包——也就是await或者.then()。
1.3 为什么这个特性会"坑"到很多人
最常见的坑来自跨语言思维惯性。写过 C 语言的人都知道return就是把一个值返回给调用者,C 语言里int main()返回 0 就是返回 0,类型明确,不会变魔术。但 JS 的async function把 return 的语义改了:返回值不再直接交到调用方手里,而是被包装后再异步交过去。
另一个常见坑是"顺手加 async"。很多业务代码原本是同步函数,后来因为里面要读接口、读数据库,就加了一个async关键字,结果返回值的类型被整个改写。类似的现象在 Python 里也有:async def函数即使内部写return 1,调用方拿到的也是 coroutine 对象,必须await才能拿到 1。这不是某个语言特有的反直觉,而是异步函数模型下的通用规则。
所以判断一个 async 函数写的对不对,第一步永远是问自己:调用方到底有没有 await 它?
2. return value、return await value 与 return promise,执行时机差在哪
2.1 没有 try/catch 时三种写法几乎等价
澄清一个很多人纠结的问题。在没有任何 try/catch 包裹的情况下,下面三种写法在外观上等价:
async function fn1() { const value = await fetchData(); return value; } async function fn2() { return await fetchData(); } async function fn3() { return fetchData(); }调用方的用户体验完全一样:都能通过await fn()拿到最终数据。所以在普通场景下,粗暴结论是"都行"。
但"几乎等价"不等于"完全等价"。return await promise和return promise之间差了一个额外的微任务周期。return await会让 async 函数在await处先挂起,等 promise settle 后再恢复执行,然后才真正 return;而return promise直接把 promise 抛给外层 Promise 去跟随,少经历一次"await 挂起-恢复"的流程。
理论上,return promise的微任务开销略少。这也是老版本 ESLint 规则no-return-await的出发点:既然两者结果一样,何必多此一举 await 一次?
2.2 有 try/catch 时,return promise 会让 catch 失效
真正的分水岭在于错误捕获。看这段代码:
async function withTryCatchAndAwait() { try { const data = await fetchData(); return data; } catch (error) { console.log("捕获到了", error); return "fallback"; } }这个写法没问题。fetchDatareject 时,await会触发 catch,函数错误被捕获,然后返回 fallback。
但换个写法:
async function returnPromiseDirectly() { try { const dataPromise = fetchData(); return dataPromise; // 错误的捕获方式 } catch (error) { console.log("永远不会打印我"); return "fallback"; } }这段代码的 catch 永远不会执行。原因是fetchData()返回的 promise 在 return 时还没有 settle,它的 rejection 发生在当前同步块之后,而 try/catch 只能捕获同步操作和await表达式中 promise 的 rejection,不能捕获 return 表达式里 promise 的 rejection。
用规范的话说:return promise会直接把 promise 的连接(resolving)交给外层outerPromise,相当于这个 promise 已经"离开"了 try 块,后面变 rejected 就和 try/catch 没关系了。而return await promise则相当于你先在这个 try 块内"拆包"一次,拆的过程中 reject 了,自然能触发 catch。
我把这两段代码给团队新人看,十个人里有六个人第一眼猜错,回答"catch 会捕获到"的人占了多数。原因很直观:Promise.reject明明是在 try 块里写的,为什么 catch 抓不到?关键就在于"return 时机"和"promise settle 时机"不是同一个时刻。
2.3 一道"猜输出"题看执行顺序和微任务时机
理解了上面的机制之后,再看一道经典的执行顺序题:请说出下面代码的输出顺序。
async function test() { console.log("1"); Promise.resolve().then(() => console.log("3")); await Promise.resolve(); console.log("4"); return "5"; } test().then((v) => console.log(v)); console.log("2");输出顺序是:
1 2 3 4 5看到没,test()调用之后并没有立刻输出1,而是继续同步往下走,先输出了2。这是因为await Promise.resolve()这一行让函数在 await 处让出了控制权,把后续代码(输出 4、return 5)放进了微任务队列。而Promise.resolve().then(...)比 await 的恢复逻辑更早进入微任务队列,所以它先输出3。
这个例子可以直观说明:async 函数里的 return 值不是立即被调用方拿到的,而是要等微任务循环转完一圈。这也解释了为什么const result = test()拿到的只能是 Promise——同步代码根本等不到 4 和 5 的执行。
3. 排查实录:明明 return 了,为什么拿到的还是 undefined
3.1 案例一:Vue2 的 data 和 return 记忆错乱
热心网友经常会搜"vue2的data和return",这两种 return 混在一起时特别容易出事。先区分:Vue2 里组件的data必须是一个函数,返回一个对象;而methods里的方法可以用 async。
常见错误是这样:
export default { data() { return { list: [], }; }, methods: { async fetchList() { const res = await api.getList(); return res.data; // 问题就在这 }, }, };模板里写{{ fetchList() }},期望看到列表,结果页面上显示的是[object Promise],甚至更糟——直接渲染了一个空内容。原因很简单:Vue 模板插值不支持 await,它拿到的是fetchList()返回的 Promise,而不是res.data。
正确做法是不要指望return给模板,而是把数据塞回 data 状态里,让响应式系统去驱动视图:
methods: { async fetchList() { const res = await api.getList(); this.list = res.data; // 状态驱动视图,而不是 return }, },这个案例给我们的启发是:在 UI 框架里,async 方法的 return 通常不是给人用的,而是给调用方用的。模板渲染需要的是同步数据源,你应该把异步结果存储到响应式数据中。
3.2 案例二:forEach 回调里的 return,回到的是"异次元"
另一个高频翻车现场是forEach配合 async 回调:
async function processItems(items) { let total = 0; items.forEach(async (item) => { const value = await fetchPrice(item); total += value; // 这个 return 不会回给外层 }); return total; // 大概率返回 0 }total为 0 的原因不是服务端接口有问题,而是forEach根本不关心回调函数的返回值。你写在回调里的return,只是用来结束当前回调函数的,它不会把total += value的结果返回给外层processItems。而且forEach里的async回调返回的 Promise 也没有被收集,外层同步执行到return total时,异步回调可能还没跑完。
要修复,把forEach换成for...of是更符合直觉的方式:
async function processItems(items) { let total = 0; for (const item of items) { const value = await fetchPrice(item); total += value; } return total; }也可以保留map加Promise.all,用返回值一次性汇总:
async function processItems(items) { const prices = await Promise.all(items.map((item) => fetchPrice(item))); return prices.reduce((sum, price) => sum + price, 0); }不管用哪种写法,关键认知是:回调函数里的 return,作用域只属于回调函数本身。想从异步回调里"往外传值",只有两种途径——把值放进外层作用域的变量,或者把 Promise 收集起来统一 await。
3.3 案例三:catch 块里"漏 return"导致吞掉错误
这个坑我几乎每个月都能在 code review 里看到一次:
async function fetchUserInfo() { try { const res = await api.getUserInfo(); return res.data; } catch (error) { console.error("获取用户信息失败", error); // 这里忘了 return } }当接口失败时,这个函数到底返回什么?答案不是"抛出错误",而是undefined。因为 catch 块正常执行完后,async 函数就正常结束了,函数整体返回了一个 fulfilled 状态的 Promise,resolve 值恰好是undefined。
调用方写const user = await fetchUserInfo()时,拿到undefined后继续user.name,于是报错"Cannot read properties of undefined"。这个报错已经和最初的接口错误完全脱节了,排查时根本不知道根因在哪。
一个最简单的修复原则:async 函数的 catch 块里,要么return一个兜底值,要么throw重新抛出错误,一定不要留空。像这样:
async function fetchUserInfo() { try { const res = await api.getUserInfo(); return res.data; } catch (error) { console.error("获取用户信息失败", error); return null; // 明确降级 // 或者 throw new Error("用户信息获取失败", { cause: error }); } }如果拿不到用户信息属于致命错误,后者更合适;如果这个信息是锦上添花,返回null让调用方做兜底展示,也没问题。怕就怕什么都没写,错误信息也打印了,但业务表现却像是"静默成功"。
3.4 案例四:一个典型热搜代码里的 return 陷阱
网上有人搜过这么一段代码片段:
function resolveTabTitleInfoFromHistory(url) { let history = decodeURIComponent(url); if (!history || typeof history !== "string") return ""; const queryIndex = history.indexOf("?"); if (queryIndex === -1) { return history; } return history.slice(queryIndex + 1); }这段代码本身中规中矩,是一个同步的字符串解析函数。但假如有同学因为某一次await需求,给这个函数随便加了个async:
async function resolveTabTitleInfoFromHistory(url) { let history = decodeURIComponent(url); if (!history || typeof history !== "string") return ""; const queryIndex = history.indexOf("?"); if (queryIndex === -1) { return history; } return history.slice(queryIndex + 1); }改动看似无害,但所有调用点全部遭殃:原本const title = resolveTabTitleInfoFromHistory(url)拿到的是字符串,现在变成 Promise,后续所有字符串方法都失效。如果你在真实项目里见到[object Promise]拼在 URL 参数里,多半就是这种"顺手加 async"造成的。
这也是我要反复强调的一句话:不要为了内部一个 await 调用,随手把函数改造成 async。改之前要想清楚,这个函数的调用契约是否需要变成异步,调用方是否已经用 await 接住。
4. return 在 async 函数中与错误处理的纠缠
4.1 reject 与 throw 的等价性,以及细微差异
在 async 函数里,throw new Error("x")和return Promise.reject(new Error("x"))从调用方看上去都是返回一个 rejected Promise:
async function fnA() { throw new Error("from throw"); } async function fnB() { return Promise.reject(new Error("from reject")); } fnA().catch((e) => console.log(e.message)); // from throw fnB().catch((e) => console.log(e.message)); // from reject但它们的内部行为有一个关键差异:throw是同步抛出的异常,如果外面包了 try/catch,异常会被捕获;而return Promise.reject()返回的是一个 rejected Promise,try/catch 只能捕获同步错误和 await 表达式里的 rejection,捕获不到 return 表达式里 promise 的 rejection。
这一点和 2.2 节讨论的是同一个机制。我想把它单拎出来,是因为不少人把return Promise.reject(error)当成"在 async 函数里手动抛出错误"的正确写法。实际上如果你想错误立刻被外层 try/catch 感知,应该用throw error:
async function handler() { try { return await risky(); } catch (error) { // 正确处理:这里 return await 把 reject 转为 catch 的捕获 // 如果想重新抛出,用 throw,而不是 return Promise.reject(error) throw error; } }4.2 错误被 return 吞掉的两张"罪魁祸首"面孔
第一种前面讲过:catch 块里忘写 return,错误被 console 打印后,函数返回 undefined,错误"被吞"。第二种更隐蔽:
async function fetchConfig() { try { return await getConfig(); } catch (error) { return handleError(error); } } async function handleError(error) { // 这里也 try 了一下,但还是没返回明确值 const recovery = await recover(); if (!recovery) { // 没有 throw,也没有 return } return recovery; }handleError里有一个分支没有 return 也没有 throw,那么这个分支结束后handleError返回 fulfilled undefined,fetchConfig也会正常 resolve 为 undefined。调用方完全不知道发生了降级,后续可能把 undefined 当作有效配置去处理,引发更难查的连锁问题。
我给这种代码做 review 时会明确要求:在 async 函数的每一个分支路径上,排查一遍有没有遗漏 return 和 throw。这跟同步函数"检测所有代码路径是否都有返回值"是同一个习惯,只不过异步函数的 return 包着一层 Promise,遗漏的肉眼可见度更低。
4.3 正确组合:try/catch + return await 的两段式结构
直接给一个适合大多数业务场景的模板:
async function loadData() { try { const data = await callService(); return transform(data); } catch (error) { if (isNetworkError(error)) { return getCache(); // 网络错误时降级 } if (isAuthError(error)) { redirectToLogin(); return null; } throw error; // 未知错误继续往上抛 } }这段代码里有两件事值得注意:
- 成功路径上
return transform(data)是直接在await拿到结果后返回值,不需要return await再包一层,因为此时transform(data)是普通数据,不是 Promise,直接 return 没有任何问题。 - 需要捕获 promise rejection 时,
await callService()足够让 catch 感知;但如果callService()是"拿到一个 promise 然后 return",那就必须写成return await callService()才捕获得到(本节开头已经讲过)。
我的习惯是:业务代码的返回点尽量"靠近"await 表达式,避免把 promise 带着到处跑。return await在 try/catch 里是守护神,在 try/catch 外就是多余动作。
4.4 finally 与 return 的特殊现象:return 竟然会被覆盖
还有一个容易忽略的细节:finally块里如果写了return,它会覆盖 try 块里的返回值。看这段代码:
async function foo() { try { return "原始值"; } finally { return "被覆盖"; } } console.log(await foo()); // "被覆盖"这不是 async 特有的行为,同步函数也一样。但放在 async 里更容易被忽略,因为 return 和 Promise 的 resolve 时机叠加在一起。如果你在 finally 里做了资源清理、日志上报之类的操作,注意不要意外 return 一个覆盖性的值。正确的清理写法如下:
async function process() { try { const data = await loadData(); return data; } finally { await reportLog(); // 只做副作用,不要 return 业务值 } }finally会在return data之后、函数真正向外层 resolve 之前等待reportLog完成,但不会覆盖data。这个时序对理解 async 函数的 return 时机很有帮助:return 后面如果还有 finally,不是立刻就返回的,JS 引擎会先把 finally 执行完再真正离开函数。
5. 一线工程里的 return 规范与性能取舍
5.1 该用 return promise 直传,还是 return await
把前四节的结论落到工程上,我们可以这样定一个策略表:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| async 函数最终返回值是普通数据 | return data | 最简洁,没有额外包装 |
| async 函数最终返回值是 Promise,且不在 try/catch 中 | return promise | 少一个微任务周期,性能更优 |
| 在 try/catch 中需要捕获 promise 的 rejection | return await promise | 否则 catch 捕获不到,错误会直接冒泡 |
| 需要把 promise 的 rejection 转成自定义异常后继续抛 | try { return await promise } catch (e) { throw wrap(e) } | 既捕获了原始错误,也让外层感知错误 |
换句话说,return await不是原罪,错的是在不需要捕获的地方用它徒增微任务。老的 ESLint 规则一刀切禁用no-return-await,后来社区发现它把 try/catch 场景的正确写法也禁了,所以新版规则演进出了allow-simple-return-await这类选项,允许在 try/catch 里写return await。我在项目里就直接关闭了no-return-await对 try 块的约束,因为业务代码在 try 块里写return await才是常态。
5.2 从"零成本 async 堆栈"看 return 对排查问题的影响
可能有人会问:直接return promise少了微任务,性能好,那为什么还要用return await?除了错误捕获,还有一个历史包袱:错误堆栈。
在早期 V8 实现里,直接return promise会导致 async 调用链的堆栈丢失很多中间帧。看这个例子:
async function inner() { throw new Error("boom"); } async function outer() { // 早期 V8 里这样写,堆栈里可能看不到 outer return inner(); } async function main() { try { await outer(); } catch (e) { console.log(e.stack); } }在 Chrome 60 时代,e.stack打印出来的调用栈经常会少了outer这一层,直接看到inner和main,排查链路断了一截。原因就是return promise让这个 promise 直接接管了 async 函数外层 Promise 的状态,函数本身没有经历await恢复点,引擎没有机会在恢复点记录栈帧。
后来 V8 实现了"零成本 async 堆栈跟踪"(Chrome 80+、Node 12+),return promise也能保留完整调用栈。但如果你在维护一些老环境的代码,或者遇到堆栈显示不完整的情况,把return promise改成return await promise依然是最快的修复招数。
我的建议是:如果性能不是瓶颈,宁可用 return await 换来完整堆栈;如果性能敏感,优先做基准测试再决定。多数业务接口的响应时间是几十到几百毫秒,多个一两个微任务根本感知不到,但排查线上问题时缺一帧堆栈就足够让人抓狂。
5.3 直接可落地的三条规定
最后放三条我在团队里强制执行的 return 规范,你可以直接抄走:
async 函数的返回点必须表达明确意图。不写 return 就自动返回 undefined,这在 async 函数里几乎总是 bug。每一个 catch 分支都必须显式
return兜底值或throw错误,不允许空块。try 块内一律
return await,try 块外一律return promise。这个规则简单好记,又能同时满足错误捕获和性能两方面的诉求。想知道你写的是不是规范形态,把函数从开头读到结束,只要看到 try 块里写return promise,那基本可以判定是 bug 风险。不要在 finally 块里 return 业务值。finally 只做清理、日志、埋点这类副作用;如果你确实想在 finally 里改返回结果,请改成 try/catch/finally 的显式结构,或者干脆在 try 块里直接返回。
这三条不是拍脑袋定的,每一条背后都对应着一类真实事故:catch 空块导致静默失败、return promise导致 catch 捕获不到、finally 覆盖返回值导致数据异常。团队里统一了这套约定之后,async 函数 return 相关的 review 争议基本消失了。
6. 最后的个人体会与一个小技巧
我记得自己最早从 Promise 链迁移到 async/await 时,也被return的语义狠狠坑过一次:把一个加了解析逻辑的函数随手改成 async,结果下游所有字符串拼接都变成了"[object Promise]"。那一次排查花了我几乎整个下午。后来我练出一个习惯,凡是看到一个 async 函数,我先不看内部逻辑,直接看它的调用方:调用方敢不敢不 await 就使用返回值?如果敢,那这个函数根本不该被标记为 async。
一个小技巧分享给大家:调试 async 函数时,与其在 return 前打console.log(promise)看Promise { <pending> },不如在 return 前写console.log(await promise)。这样能直接打印出 promise 的 resolve 值,而不是那个永远显示 pending 的 Promise 对象。我靠这一招省下了无数次断点调试时间。你下次再遇到"为什么 return 的值不是我想的那样",先检查函数签名,再看 return 的类型,最后看调用方有没有 await,按这个顺序排查,基本能解决九成的问题。