避免地狱async await的使用及原理解析
本文深入剖析了JavaScript中async/await的使用陷阱。文章指出,虽然async/await旨在简化异步代码,但它往往隐藏了并行执行的机会,导致性能瓶颈。同时,文章对比了Promise与async/await在错误处理上的差异,指出try/catch带来的认知负担及引擎优化限制。最后,通过解析“回调地狱”与Promise链的关系,提供了避免嵌套过深的技巧,主张在特定场景下优先使用Promise以获得更清晰的控制流。
引言
无论你对async/await持何种态度,基于我的实践经验,我想阐述一个观点:async/await往往会让代码的复杂度不降反升。
JavaScript中async/await的设计初衷基于一个假设:异步代码比同步代码更复杂,而同步代码更易于理解。从客观角度看这是正确的,但在大多数实际场景中,我认为async/await并未真正解决这一痛点。
关于async/await的误区
我评估是否采用某种编程模式的标准之一,是看它能否提升代码的综合质量。例如,一个模式可能看起来简洁或流行,但如果它容易引发错误,我就会拒绝使用。这类模式具有两面性,稍有不慎就会适得其反。
首先,这个模式建立在一个谎言之上。
Async/await让异步代码看起来像同步代码。
这是它的核心卖点,但在我看来,这正是问题所在。它在代码执行之初就建立了一个错误的心理模型。虽然同步代码确实比异步代码容易处理,但同步代码绝不等同于异步代码,两者拥有截然不同的属性。
很多时候这并非问题,但当问题出现时,由于async/await恰好隐藏了相关线索,导致问题难以被识别。请看以下代码示例。
同步代码实现
const processData = ({ userData, sessionPrefences }) => {
save('userData', userData);
save('session', sessionPrefences);
return { userData, sessionPrefences }
}
Async/Await实现
const processData = async ({ userData, sessionPrefences }) => {
await save('userData', userData);
await save('session', sessionPrefences);
return { userData, sessionPrefences }
}
Promise实现
const processData = ({ userData, sessionPrefences }) => save('userData', userData)
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
这里存在性能隐患。我们将问题范围缩小到了processData函数。在这三种实现中,你对优化路径的假设分别是什么?
查看第一种情况,我发现我们在两个不同位置保存了两组独立数据,最后返回一个对象。唯一可能的优化点在于保存函数本身,没有其他选择。
查看第二个例子,我的想法相同。唯一可优化的地方依然是保存函数。
也许是因为我对Promise过于熟悉,但在查看第三个例子时,我很快发现了一个机会。我看到我们连续调用了save,尽管其中一个调用并不依赖于另一个。我们可以将这两个save调用并行化。
const processData = ({ userData, sessionPrefences }) => Promise.all([
save('userData', userData),
save('session', sessionPrefences)
])
.then(() => ({ userData, sessionPrefences })
同样的优化机会也存在于async/await代码中,只是因为我们处于异步代码的思维模式中,所以它被隐藏了起来。在async/await版本中并非没有提示,async和await关键字本应像第三个版本中的then一样给我们同样的直觉。但我敢打赌,对许多工程师来说,这种直觉并未产生。
为什么没有产生这种直觉?
因为我们被教导要以同步的思维方式来阅读async/await代码。在第一个同步代码例子中,我们无法将保存调用并行化;同样的逻辑(尽管现在是不正确的)被应用到了第二个例子。Async/await将我们的思维引导至同步模式,而这是错误的思维模式。
此外,如果我们要在async/await的例子中利用并行化的优势,无论如何我们必须使用Promise。
const processData = async ({ userData, sessionPrefences }) => {
await Promise.all([
save('userData', userData),
save('session',sessionPrefences)
])
return { userData, sessionPrefences }
}
在我看来,如果一个特定模式仅在少数常见情况下有效,那么它一定存在巨大的优势。如果我不得不在许多常见情况下“退回”到Promise模式,我就看不到async/await相比Promise的优势。对我来说,在多种范式之间切换所带来的认知负担并不值得。Promise在任何情况下都能完成任务,而且每次表现都与async/await一样好,甚至更好。
错误处理机制
处理错误对于异步代码至关重要。有几个关键场景,我们必须关注JavaScript中同步代码的错误处理。这主要发生在我们调用本地API,如JSON.parse,或浏览器功能,如window.localstorage时。
让我们回顾之前的save函数示例,并应用一些错误处理。假设在我们的同步例子中,save执行了一个可能抛出异常的操作。这是非常合理的,因为如果保存到sessionstorage,可能在序列化或试图访问sessionstorage的过程中抛出异常。为了处理同步代码中可能出现的错误,我们通常使用try/catch。
同步代码中的错误处理
const processData = ({ userData, sessionPrefences }) => {
try {
save('userData', userData);
save('session', sessionPrefences);
return { userData, sessionPrefences }
} catch (err) {
handleErrorSomehow(err)
}
}
根据不同的策略,我们可能重新抛出错误,或者在catch块中返回一些默认值。无论哪种方式,我们都必须在try块中封装任何可能抛出错误的逻辑。
Async/Await中的错误处理
由于async/await让我们“像看待同步一样看待异步代码”,我们也使用try/catch块。catch块甚至会将我们的reject判定为一个错误。
const processData = async ({ userData, sessionPrefences }) => {
try {
await save('userData', userData);
await save('session', sessionPrefences);
return { userData, sessionPrefences }
} catch (err) {
handleErrorSomehow(err)
}
}
看看这个,async/await实现了它的承诺。它看起来与同步版本几乎完全一样。
现在,有一些编程流派非常倚重try/catch。我觉得它们是一种精神上的负担。每当有try/catch时,我们现在不仅要担心函数返回什么,还要担心它抛出什么。我们不仅有分支逻辑,这增加了复杂性,而且还必须担心同时处理两种不同的范式。一个函数可以返回一个值,也可以抛出。因此,每个函数都要处理这两方面的问题。这很累人。
try/catch带来的尴尬
关于try/catch的最后一点。在JavaScript中,你一般不会在很多地方看到拥抱try/catch。与其他语言不同的是,在其他语言中,你会经常看到它,比如Java。JavaScript中的try块会立即将这部分代码排除在许多引擎优化之外,因为代码不能再被分解成确定的片段。换句话说,在JavaScript中,同样的代码在被try块包裹的情况下会比不被包裹的情况下运行得更慢,即使它没有抛出的可能性。
Promise的错误处理
让我们看看Promise在做什么。
const processData = ({ userData, sessionPrefences }) => save('userData', userData)
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
.catch(handleErrorSomehow)
你看到了吗?
.catch(handleErrorSomehow)
是的。这就是它的全部内容。这和其他的方法做的事情完全一样。我发现这比try/catch块更容易阅读。你觉得呢?如果同步代码也这么简单就好了......等一下!
const processData = ({ userData, sessionPrefences }) => Promise.resolve(save('userData', userData))
.then(() => save('session', sessionPrefences))
.then(() => ({ userData, sessionPrefences })
.catch(handleErrorSomehow)
好吧,这有缺点,但也超级有趣,你不觉得吗?这只是一个小小的提示,让你思考如果我们想的话,函数式风格的JavaScript会是什么样子。但不管怎样,接受还是不接受。我的目的是说服你使用Promises而不是async/await。而不是承诺Promises全面优于async/await。那就太疯狂了。😏
更关键的一点
我想提出的最后一点是。我有时会遇到一些论点,声称async/await可以防止callbacks和promises中可能出现的“回调地狱”现象。
说实话,我第一次听到这种论调时,我以为这个人只是混淆了,是想说“callbacks”。毕竟,promises设计之初的目的之一就是消除“回调地狱”的问题,所以我很困惑,人们说promises会导致回调地狱(我的意思是,它毕竟被称为回调(callbacks)地狱,而不是promises地狱)。
但后来我真的看到了一些promise的代码,它们看起来惊人地像回调地狱。我很困惑,为什么有人会这样使用promise。最终,我得出结论,有些人对promise的工作原理有一个非常基本的误解。
在我讨论这个问题之前,首先让我承认,事实上不可能用async/await创造出金字塔结构的回调地狱,所以它有这个优势。但是我从来没有写过一个超过两级的promise流,没有必要。当然有可能,但从来没有必要。
我发现,每当我在promise链中看到“回调地狱”时,都是因为人们没有意识到promise的作用就像一个无限长的流程图。换句话说,一个像这样的流程:
const id = 5
const lotsOAsync = () => fetch('/big-o-list')
.then((result) => {
if (result.ok) {
return result.json().then((list) => {
const {url: itemURL } = data.items.find((item) => item.id === id)
return fetch(itemURL).then((result) => {
if (result.ok) {
return result.json().then((data) => data.name)
} else {
throw new Error(`Couldn't fetch ${itemURL}`)
}
})
})
} else {
throw new Error(`Couldn't fetch big-o-list`)
}
})
真的应该这样写:
const id = 5
const lotsOAsync = () => fetch('/big-o-list')
.then((result) => result.ok ? result.json() : Promise.reject(`Couldn't fetch big-o-list`))
.then(({ items }) => items.find((item) => item.id === id))
.then(({url}) => fetch(url))
.then((result) => result.ok ? result.json() : Promise.reject(`Couldn't fetch ${result.request.url}`))
.then((data) => data.name)
如果这有点让人困惑,让我给你一个更简单,但更矫情的例子。
回调地狱 🔥
Promise.resolve(
Promise.resolve(
Promise.resolve(
Promise.resolve(
Promise.resolve(
Promise.resolve(5)
)
)
)
)
).then((val) => console.log(val))
promise天堂 👼
Promise.resolve()
.then(() => Promise.resolve())
.then(() => Promise.resolve())
.then(() => Promise.resolve())
.then(() => Promise.resolve())
.then(() => Promise.resolve(5))
.then((val) => console.log(val))
这两个例子在创建promise的数量和顺序方面都是一样的(以及它们实际上脱离现实,但这是出于学术目的,所以我们允许它)。然而,后一个比前一个更有可读性。
如果你习惯于写与第一个例子更像的promise流,让我给你提供一个好的小技巧来摆脱这种习惯。
每次你想在你的承诺流中写一个then或catch,首先确保你返回promise,然后转到最外层的promise(如果你一直遵循这个规则,那应该只有一层)并在那里添加你的then或catch。只要你在返回,你的值就会冒泡到最外层的promise。这就是你应该做的“then”。
请记住,你不一定要返回一个Promise来使用then。一旦你在一个promise的上下文中,任何返回的值都会通过它冒泡。Promise、number、字符串、函数、对象,等等。
Promise.resolve(5)
.then((val) => val + 3)
.then((num) => String(num))
.then((str) => `I have ${str} llamas`)
.then((str) => /ll/.test(str))
这都是完全合法的(尽管有点不合理,但编造的例子是为了说清楚🤷♂)
本文参考Why I avoid async/await,有修改。
总结
在我看来,promises
- 提供更好的提示,表明我们处于异步的心理模型中
- 在简单的情况下,对代码的表达至少与async/await一样干净。
- 为包括错误处理和并行化在内的更复杂的工作流提供了一个更干净的选择。
注:特别感谢技术指导dazhao(赵达)对本文的审阅指正。
以上就是避免地狱async await的使用及原理解析的详细内容,更多关于避免地狱async await原理的资料请关注本站其它相关文章!
ES6模块化中js文件怎么用import和export交互
2026-11-05 17:00:48
JS读取本地文件时报错或无响应的常见原因
2026-11-05 16:32:31
let、const与var的区别:JavaScript变量声明最佳实践
2026-11-05 14:16:38
JS中直接定义静态二维数组的字面量写法
2026-11-05 14:16:08
JS 怎么查找字符串中所有匹配项的位置
2026-11-05 14:15:53
