从回调地狱到回调天堂
问题
实验
现实生活中的例子
结论
还记得很多 JavaScript 代码看起来像这样吗?
router.put('/some-url', (req, res) => {
fs.readFile(filePath(req), (err, file) => {
if (err) res.status(500).send()
else {
parse(file, (err, parsed) => {
if (err) res.status(500).send()
else db.insert(parsed, err => {
if (err) res.status(500).send()
else res.status(200).send()
})
})
}
})
})
对于那些幸运地不记得那些日子的人来说,这被称为回调地狱,原因显而易见。幸运的是,我们已经前进了,现在的等效代码很可能看起来像这样:
router.put('/some-url', async (req, res) => {
try {
const file = await fs.readFile(filePath(req));
const value = await parse(file);
await db.insert(value);
response.status(200).send();
} catch {
response.status(500).send();
}
})
当然,既然现在有了async
/await
和Promise
s,我们很容易将回调地狱时代归咎于当时 JS 语法特性的不足,然后就此打住。但我确实认为,回顾一下,分析核心问题、解决问题的方法以及从中可以学到什么,是有价值的。
问题
让我们回顾一下上面这个地狱示例的整体结构:
doX(args, (err, res) => {
if (err) { ... }
else {
doY(args, (err, res) => {
if (err) { ... }
...
})
}
})
这里一个明显的问题是,屏幕上显示的大部分内容都是不太重要的东西:
doX(args /*, (err, res) => {
if (err) { ... }
else {*/
doY(args /*, (err, res) => {
if (err) { ... } */
...
/*}*/)
/*}*/
/*}*/)
为了比较,这将是现代非地狱版本的等效结构:
/* try { */
/*await*/ doX(args)
/*await*/ doY(args)
...
/*} catch { }*/
两个版本中的注释都表明了同一件事:doX()
和doY()
是异步函数,并且可能存在一些错误。然而,在地狱版本中,你需要为这些注释花费更多的空间,这导致代码的可读性大大降低。
👉 请注意,我们可以精简样板代码,并将代码重构为更易读的格式,而无需添加额外的语法。从历史上看,这种情况确实发生过,以库的形式出现Promise
(后来库得到了标准化,并通过一些语法支持获得了更多的青睐):
doX(args)
.then(() => doY(args))
.then(() => ...)
.catch(() => { ... })
doX(args)
/*.then(() =>*/doY(args)/*)*/
/*.then(() =>*/.../*)*/
/*.catch(() => { ... })*/
这段代码和地狱代码之间的一个重要区别是,在地狱代码中,重要内容和样板内容极其交织在一起,而在承诺库中,它们被整齐地分开,即使在样板数量几乎相同的情况下,代码也更容易阅读:
// without promises:
doX(args/*, (err, res) => { ... }*/)
// with promises:
doX(args)/*.then(() => { ... })*/
// with async/await:
/*await*/ doX(args)
Promises 还提供了其他有助于异步编程人体工程学的重要功能,最重要的是:
- 当链接时,承诺会自动平整。
- 承诺是共享的。
然而,我认为这些特性虽然有益,但不如前面提到的分离那么重要。为了说明这一点,让我们创建一个实验性的Promise库,只做分离,不做其他任何事情,看看效果如何。
实验
因此,最初我们从如下函数开始:
doX(args, (err, res) => {...})
这里的回调是主要的样板(也是我们地狱的名字),所以最简单的分离是将其从参数列表中取出doX()
,然后将其放入延迟函数中:
doX(args)((err, res) => {...})
☝️这基本上是实施方式上的改变doX
,如下所示:
function doX(args, callback) {
// do stuff
// maybe do more
callback(undefined, 42)
}
对此:
function doX(args) {
// do stuff
return callback => {
// maybe do more
callback(undefined, 42)
}
}
换句话说,我们只是改变了惯例:
接受回调作为最后一个参数
到:
返回一个接受回调作为参数的函数
我们的分离惯例本身似乎并没有起到太大作用,因为我们仍然保留着同样多的样板代码。然而,它确实为简化实用程序打开了大门,帮助我们摆脱样板代码。为了理解这一点,我首先介绍一下这个pipe()
实用程序:
function pipe(...cbs) {
let res = cbs[0];
for (let i = 1; i < cbs.length; i++) res = cbs[i](res);
return res;
}
简单来说,就是:
pipe(a, b, c, d)
等于:
let x = a
x = b(x)
x = c(x)
x = d(x)
在不久的将来,pipe()
甚至可能会被整合到 JavaScript 本身中,如下所示:
a |> b |> c |> d
无论如何,该pipe()
运算符允许我们巧妙地转换(新约定)返回的函数doX()
(记住,它是一个接受标准回调的函数),而无需手动编写回调。例如,我可以创建一个then()
如下实用程序:
export function then(f) {
return src => {
src((err, res) => {
if (!err) f(res)
})
return src
}
}
使用这些实用程序,我的异步代码将从以下状态转变:
doX(args)((err, res) => { ... })
对此:
pipe(
doX(args),
then(() => { ... })
)
或者更好的是(与管道运营商合并):
doX(args) |> then(() => { ... })
它看起来很像一个标准的承诺库:
doX(args).then(() => { ... })
我还可以创建一个简单的catch()
实用程序:
function catch(f) {
return src => {
src((err) => {
if (err) f(err)
})
return src
}
}
这将给我如下异步代码:
doX(args)
|> then(() => doY(args))
|> then(() => ...)
|> catch(() => { ... })
doX(args)
/*|> then(() =>*/ doY(args) /*)*/
/*|> then(() =>*/ ... /*)*/
/*|> catch(() => { ... })*/
它和 Promise 库一样简洁,几乎不费吹灰之力。更棒的是,这种方法还提供了可扩展性,因为我们不再受限于某个集合Promise
对象,而是可以创建/使用更广泛的实用函数:
function map(f) {
return src => cb => src((err, res) => {
if (err) cb(err, undefined)
else cb(undefined, f(res))
})
}
function delay(n) {
return src => cb => src((err, res) => {
if (err) cb(err, undefined)
else setTimeout(() => cb(undefined, res), n)
})
}
然后开始变得有点疯狂:
doX(args)
|> then(() => doY(args))
|> map(yRes => yRes * 2)
|> delay(200)
|> then(console.log)
现实生活中的例子
好吧,看来只需简单改变一下惯例,我们就能创建出与 Promise 库一样便捷的工具和库(而且几乎与async
/await
语法类似)。为了更好地理解,我们来看一些实际的例子。为此(主要是出于好奇),我创建了一个在线游乐场,其中包含我们实验性库的实现。
首先,让我们看一下原始示例,其最糟糕的版本如下所示:
router.put('/some-url', (req, res) => {
fs.readFile(filePath(req), (err, file) => {
if (err) res.status(500).send()
else {
parse(file, (err, parsed) => {
if (err) res.status(500).send()
else db.insert(parsed, err => {
if (err) res.status(500).send()
else res.status(200).send()
})
})
}
})
})
现代 JavaScript 版本如下所示:
router.put('/some-url', async (req, res) => {
try {
const file = await fs.readFile(filePath(req));
const value = await parse(file);
await db.insert(value);
response.status(200).send();
} catch {
response.status(500).send();
}
})
我们的新回调约定代码如下所示:
router.put('/some-url', (req, res) => {
fs.readFile(filePath(req))
|> map(parse)
|> flatten
|> map(db.insert)
|> flatten
|> then(() => res.status(200).send())
|> catch(() => res.status(500).send())
})
这个约定让我们非常接近async
/的便捷性await
。不过还是有点细微的差别:看到flatten
中间两次使用的工具函数了吗?这是因为与 Promise 不同,我们的回调在链式调用时不会被展平。我们假设parse()
也是异步的,也就是说它也返回一个类似 Promise 的。map(parse)
然后将 的结果映射readFile()
到一个新的类似 Promise 的,在传递给 之前,应该将其展平为已解析的值db.insert()
。在async
/await
代码中,这是通过await
关键字 before完成的parse()
,而在这里我们需要使用flatten
工具函数来完成。
附言:该flatten()
实用程序本质上也非常简单:
function flatten(src) {
return cb => src((err, res) => {
if (err) cb(err, undefined)
else res((err, res) => {
if (err) cb(err, undefined)
else cb(undefined, res)
})
})
}
我们再来看另一个例子:在这里,我们想从PokéAPI获取一些 Pokémon 信息并记录其能力:
fetch('https://pokeapi.co/api/v2/pokemon/ditto')
|> map(res => res.json())
|> flatten
|> then(res => console.log(res.abilities))
async(() => {
let res = await fetch('https://pokeapi.co/api/v2/pokemon/ditto')
res = await res.json()
console.log(res.abilities)
})()
结论
总结一下,这些似乎是导致回调地狱的主要问题:
- 大量样板代码
- 样板代码与重要代码严重交织在一起
根据我们的小实验,以最简单的方式解决第二个问题(仅分离样板代码和重要代码,不做任何其他更改)非常关键:它使我们能够将样板代码捆绑成小型实用函数,并减少样板代码和重要代码的比例,使其(几乎)与在语言本身中添加新语法等严厉的解决方案一样方便。
这个概念特别重要:您可能拥有无法摆脱的丑陋的实现细节和样板,但您始终可以将它们捆绑在一起并将其与实际的重要代码分开,即使以最简单的方式执行此操作也可以将地狱般的情况变成天堂般的情况。
值得注意的是,同样的方法论也适用于我们今天面临的其他类似问题。虽然我们(大部分)已经解决了异步函数的问题,但诸如异步流(类似于异步函数,但具有多个(可能无限)的输出,而不是一个)之类的较新的结构不断涌入我们的工具箱,并要求我们解决类似的问题。
附言:回调天堂这个名字实际上来自于callbag规范,它类似于我们新的回调约定,只不过是针对流而不是promise。如果你喜欢这篇文章,也请务必查看一下。
文章来源:https://dev.to/loreanvictor/from-callback-hell-to-callback-heaven-4i0c