实际上,回调函数是可以的。
什么?
在呼叫篮里下地狱
金字塔的扁平化
看出规律
外观变化
咖喱味浓郁
把它翻过来
提取出该模式
修剪冗余重量
最终代码
那个M字
结论
进一步的工作
致谢
什么?
本文讲述了JS宇宙中最著名的反派其实并不邪恶,只是被误解了。
在呼叫篮里下地狱
我不会深入探讨“回调地狱”这个术语的背景;我只会推荐一篇不错的文章,它解释了这个问题以及一些典型的解决方案。如果你不熟悉这个术语,请去阅读那篇文章;我等你。
好的。我们将从文章中复制粘贴有问题的代码,然后看看如何在不使用 Promise 和 async/await 的情况下解决这个问题:
const verifyUser = function(username, password, callback) {
dataBase.verifyUser(username, password, (error, userInfo) => {
if (error) {
callback(error);
} else {
dataBase.getRoles(username, (error, roles) => {
if (error) {
callback(error);
} else {
dataBase.logAccess(username, error => {
if (error) {
callback(error);
} else {
callback(null, userInfo, roles);
}
});
}
});
}
});
};
金字塔的扁平化
如果我们查看代码,会发现每次执行异步操作时,都需要传递一个回调函数来接收结果。由于我们将所有接收结果的回调函数都定义为匿名函数,最终导致出现一个巨大的金字塔结构。
首先,我们进行简单的重构,将每个匿名回调函数复制粘贴到一个单独的变量中,引入柯里化参数,以便显式地传递从周围作用域捕获的变量:
const verifyUser = (username, password, callback) =>
dataBase.verifyUser(username, password, f(username, callback));
const f = (username, callback) => (error, userInfo) => {
if (error) {
callback(error);
} else {
dataBase.getRoles(username, g(username, userInfo, callback));
}
};
const g = (username, userInfo, callback) => (error, roles) => {
if (error) {
callback(error);
} else {
dataBase.logAccess(username, h(userInfo, roles, callback));
}
};
const h = (userInfo, roles, callback) => (error, _) => {
if (error) {
callback(error);
} else {
callback(null, userInfo, roles);
}
};
至少现在它平坦了一些,但我们现在遇到了一些新的代码问题:
- 这种
if (error) { ... } else { ... }事到处都在发生。 - 我们中间表达式的变量名毫无意义。
verifyUser、f、g和h彼此紧密相关,因为它们直接引用彼此。
看出规律
不过,在讨论这些问题之前,让我们先注意到这些表达方式之间的一些相似之处。
所有这些函数都接受一些数据和一个callback参数f,此外g还h接受一对参数(error, something),其中只有一个参数为非空null值undefined。如果error该参数非空,则函数立即返回error并callback终止。否则,它们会something执行一些额外的工作,callback最终导致返回不同的错误信息或null结果值。
牢记这些共同点,我们将着手重构中间表达式,使它们看起来越来越相似。
外观变化
我觉得if语句过于冗长,所以我们现在花点时间把所有这些if语句都替换成三元表达式。由于返回值都会被丢弃,所以这不会导致代码行为发生任何变化。
我还会通过分别将重复的变量缩短error为callback和e来减少视觉干扰cb:
const verifyUser = (username, password, cb) =>
dataBase.verifyUser(username, password, f(username, cb));
const f = (username, cb) => (e, userInfo) =>
e ? cb(e) : dataBase.getRoles(username, g(username, userInfo, cb));
const g = (username, userInfo, cb) => (e, roles) =>
e ? cb(e) : dataBase.logAccess(username, h(userInfo, roles, cb));
const h = (userInfo, roles, cb) => (e, _) =>
e ? cb(e) : cb(null, userInfo, roles);
咖喱味浓郁
因为我们即将开始对函数参数进行一些复杂的操作,所以我打算借此机会对所有可以柯里化的函数参数进行柯里化。这样做可以统一参数类型,并方便后续的重构。
我们无法轻易地对接受一对参数的函数进行柯里化(e, xyz),因为底层dataBaseAPI(对我们来说是不透明的)要求回调函数同时接受可能的错误和可能的结果。但所有其他多参数函数都可以通过柯里化来消除(而且将会消除)。
我们先从dataBase方法说起:
// Curried wrapper around the `dataBase` API
const DB = {
verifyUser: username => password => cb =>
dataBase.verifyUser(username, password, cb),
getRoles: username => cb =>
dataBase.getRoles(username, cb),
logAccess: username => cb =>
dataBase.logAccess(username, cb)
}
现在,我们将所有对 `__init__` 的使用替换dataBase为 `__init__` 中的包装操作DB,并将所有剩余的多参数函数柯里化。此外,我们将 `__init__` 替换cb(null, userInfo, roles)为h`__init__` cb(null, { userInfo, roles }),以便回调函数始终接收两个参数:一个可能的错误和一个可能的结果。
const verifyUser = username => password => cb =>
DB.verifyUser(username)(password)(f(username)(cb));
const f = username => cb => (e, userInfo) =>
e ? cb(e) : DB.getRoles(username)(g(username)(userInfo)(cb));
const g = username => userInfo => cb => (e, roles) =>
e ? cb(e) : DB.logAccess(username)(h(userInfo)(roles)(cb));
const h = userInfo => roles => cb => (e, _) =>
e ? cb(e) : cb(null, { userInfo, roles });
把它翻过来
我们再进行一些重构。稍后会解释原因,我们将把所有错误检查代码“移”到上一级。不再让每个步骤都进行自己的错误检查,而是使用一个匿名函数,该函数接收当前步骤的错误e或结果v,如果没有问题,则将结果和回调函数转发到下一步:
const verifyUser = username => password => cb =>
DB.verifyUser(username)(password)((e, v) =>
e ? cb(e) : f(username)(cb)(v)
);
const f = username => cb => userInfo =>
DB.getRoles(username)((e, v) =>
e ? cb(e) : g(username)(userInfo)(cb)(v)
);
const g = username => userInfo => cb => roles =>
DB.logAccess(username)((e, _) =>
e ? cb(e) : h(userInfo)(roles)(cb)
);
const h = userInfo => roles => cb => cb(null, { userInfo, roles });
注意,最终函数中完全没有错误处理:h它只是接受几个参数,根据这些参数构建一个复合结果,然后立即将结果传递给指定的回调函数。让我们重写一下,h以便更清楚地展示这一点:
const h = userInfo => roles => {
const result = { userInfo, roles };
return cb => cb(null, result);
}
现在参数cb的传递位置各不相同,为了保持一致性,我们将调整参数顺序,使所有数据放在最前面,回调函数放在最后:
const verifyUser = username => password => cb =>
DB.verifyUser(username)(password)((e, v) =>
e ? cb(e) : f(username)(v)(cb)
);
const f = username => userInfo => cb =>
DB.getRoles(username)((e, v) =>
e ? cb(e) : g(username)(userInfo)(v)(cb)
);
const g = username => userInfo => roles => cb =>
DB.logAccess(username)((e, _) =>
e ? cb(e) : h(userInfo)(roles)(cb)
);
const h = userInfo => roles => {
const result = { userInfo, roles };
return cb => cb(null, result);
}
verifyUser现在f看起来几乎一模一样。她们俩:
- 接收一些数据和一个回调
- 执行一些异步操作
- 收到错误或值
- 如果结果是错误,则立即将其传递给回调函数。
- 否则,将成功结果和回调传递给后续步骤(
<next step>(v)(cb))
g非常相似,但略有不同。它不会接收v参数并在没有问题的情况下将其传递给下一步,而是无条件地丢弃任何成功的结果,只将回调函数传递给下一步。
为了解决这个问题,我们将重写该函数,g使其模仿其他两个函数并传递其(未定义的)结果。为了处理这个不想要的结果,我们将在“下一步”中引入一个虚拟参数,以便它丢弃传递的任何内容:
const g = username => userInfo => roles => cb =>
DB.logAccess(username)((e, v) =>
e ? cb(e) : (_ => h(userInfo)(roles))(v)(cb) // the "next step" discards the result
);
verifyUser现在它遵循与和相同的公式f。为了清晰起见,让我们将每个函数的异步操作和“下一步”显式复制到局部变量中:
const verifyUser = username => password => {
const task = DB.verifyUser(username)(password);
const next = f(username);
return cb => task((e, v) => e ? cb(e) : next(v)(cb));
}
const f = username => userInfo => {
const task = DB.getRoles(username);
const next = g(username)(userInfo);
return cb => task((e, v) => e ? cb(e) : next(v)(cb));
}
const g = username => userInfo => roles => {
const task = DB.logAccess(username);
const next = _ => h(userInfo)(roles);
return cb => task((e, v) => e ? cb(e) : next(v)(cb));
}
const h = userInfo => roles => {
const result = { userInfo, roles };
return cb => cb(null, result);
}
你看出规律了吗?
提取出该模式
到目前为止,应该已经很明显地看出存在一些重复性问题。看起来有人把处理错误和回调函数的代码复制粘贴到了每个函数里。当然,这是有意为之;我们已经重构代码,使其采用统一的模式,这样就可以避免重复代码的复制粘贴。
现在,我们可以一举将所有错误处理和回调线程相关的工作都移到一对辅助函数中:
const after = task => next =>
cb => task((e, v) => e ? cb(e) : next(v)(cb));
const succeed = v =>
cb => cb(null, v);
我们的步骤变成了:
const verifyUser = username => password =>
after
(DB.verifyUser(username)(password))
(f(username));
const f = username => userInfo =>
after
(DB.getRoles(username))
(g(username)(userInfo));
const g = username => userInfo => roles =>
after
(DB.logAccess(username))
(_ => h(userInfo)(roles));
const h = userInfo => roles =>
succeed({ userInfo, roles });
错误处理和回调线程已消失!
最好在这里稍作停顿。尝试将 ` afterand`的定义内联succeed到这些新表达式中,以验证它们是否与我们重构掉的那些表达式等价。
好吧,我们感觉温度正在回升!不过f,现在好像g也h没什么动静了……
修剪冗余重量
所以,让我们把它们去掉吧!我们只需要从引用它的函数开始h,反向操作,把每个函数内联到它的定义中:
// Inline h into g
const g = username => userInfo => roles =>
after(DB.logAccess(username))(_ =>
succeed({ userInfo, roles })
);
// Inline g into f
const f = username => userInfo =>
after(DB.getRoles(username))(roles =>
after(DB.logAccess(username))(_ =>
succeed({ userInfo, roles })
)
);
// Inline f into verifyUser
const verifyUser = username => password =>
after(DB.verifyUser(username)(password))(userInfo =>
after(DB.getRoles(username))(roles =>
after(DB.logAccess(username))(_ =>
succeed({ userInfo, roles })
)
)
);
我们可以使用引用透明性来引入一些临时变量,使代码更易于阅读:
const verifyUser = username => password => {
const auth = DB.verifyUser(username)(password);
const roles = DB.getRoles(username);
const log = DB.logAccess(username);
return after(auth)(u =>
after(roles)(r =>
after(log)(_ =>
succeed({ userInfo: u, roles: r })
)
)
);
};
就是这样!这段代码非常简洁,没有重复任何错误检查,并且与Promise我们之前链接的文章中的版本大致类似。调用方式verifyUser如下:
const main = verifyUser("someusername")("somepassword");
main((e, o) => (e ? console.error(e) : console.log(o)));
最终代码
// Tools for sequencing callback APIs
const after = task => next =>
cb => task((e, v) => e ? cb(e) : next(v)(cb));
const succeed = v =>
cb => cb(null, v);
// Curried wrapper around the `dataBase` API
const DB = {
verifyUser: username => password => cb =>
dataBase.verifyUser(username, password, cb),
getRoles: username => cb =>
dataBase.getRoles(username, cb),
logAccess: username => cb =>
dataBase.logAccess(username, cb)
}
// Our implementation
const verifyUser = username => password => {
const auth = DB.verifyUser(username)(password);
const roles = DB.getRoles(username);
const log = DB.logAccess(username);
return after(auth)(u =>
after(roles)(r =>
after(log)(_ =>
succeed({ userInfo: u, roles: r })
)
)
);
};
那个M字
结束了吗?嗯,我们中的一些人可能仍然觉得这段代码verifyUser有点过于复杂。有办法解决这个问题,但为了解释如何解决,我首先要坦白一件事。
我并非在重构这段代码的过程中独立发现了 ` afterand`的定义succeed。实际上,我事先就已经有了这些定义,因为我是从一个 Haskell 库中复制过来的,在那里它们分别名为 `and`>>=和 ` .`。这两个函数共同构成了“延续单子”pure的定义。
这有什么意义呢?原来,有很多便捷的方法可以将单子计算按顺序组合在一起,而不会出现金字塔效应。
verifyUser为了说明这一点,我们先来稍微改变一下“有点”的定义格式:
const verifyUser = username => password => {
const auth = DB.verifyUser(username)(password);
const roles = DB.getRoles(username);
const log = DB.logAccess(username);
return
after(auth) (u =>
after(roles)(r =>
after(log) (_ =>
succeed({ userInfo: u, roles: r }))));
};
如果你眯起眼睛,忽略括号,你可能会注意到这个定义与以下 Haskell 函数之间的相似之处:
-- In Haskell, function application does not require parentheses,
-- and binary functions may be applied infix
verifyUser :: Username -> Password -> IO (UserInfo, Roles)
verifyUser username password =
let
auth = DB.verifyUser username password
roles = DB.getRoles username
log = DB.logAccess username
in
auth >>= \u ->
roles >>= \r ->
log >>= \_ ->
pure (u, r)
>>=这种使用`and` 函数引入从单子计算步骤中捕获的新变量的模式非常常见,以至于有一种特殊的语法糖来表示它,称为“do 表示法”。以下是用 Haskell 编写的相同计算,使用了 do 表示法:
verifyUser' :: Username -> Password -> IO (UserInfo, Roles)
verifyUser' username password =
let
auth = DB.verifyUser username password
roles = DB.getRoles username
log = DB.logAccess username
in
do
u <- auth
r <- roles
_ <- log
pure (u, r)
虽然 JavaScript 中没有通用的 do 表示法(或许我们应该有!),但有很多方法可以模拟它。本文篇幅有限,无法详细解释 monad 和 do 表示法,但为了便于说明,这里提供一个使用verifyUser模拟 do 表示法库编写 JavaScript 代码的方法:
const { mdo } = require("@masaeedu/do");
// `Cont` is our implementation of the continuation monad
const Cont = monad({ pure: succeed, bind: after });
const verifyUser = username => password => {
const auth = DB.verifyUser(username)(password);
const roles = DB.getRoles(username);
const log = DB.logAccess(username);
return mdo(Cont)(({ u, r }) => [
[u, () => auth ],
[r, () => roles],
() => log ,
() => Cont.pure({ userInfo: u, roles: r })
]);
};
这固然很好,但值得注意的是,某些单子计算具有“固定”的结构,也就是说,它们可能不会利用先前步骤的结果来决定下一步的操作。由于这类计算实际上不需要显式地绑定和命名中间步骤的结果,因此可以通过“遍历”一个固定的步骤容器来更方便地构建它们,最终生成一个相应的结果容器。
幸运的是,我们的例子恰好是一个“固定结构”的计算,也就是说,每一步都独立于前一步的结果。这意味着它也可以写成以下更简洁的形式:
const verifyUser = username => password => {
const auth = DB.verifyUser(username)(password);
const roles = DB.getRoles(username);
const log = DB.logAccess(username);
// Applicative lifting
const f = u => r => _ => ({ userInfo: u, roles: r });
return Cont.lift(f)([auth, roles, log]);
};
const verifyUser = username => password => {
const auth = DB.verifyUser(username)(password);
const roles = DB.getRoles(username);
const log = DB.logAccess(username);
// Traverse a dictionary of continuations into a continuation of a dictionary
return Obj.sequence(Cont)({
userInfo: auth,
roles: roles,
_: log
})
};
本文篇幅有限,无法详细分析构建单子和应用式计算的所有方法,但可以肯定的是,有很多强大而优雅的工具可以用来合成任意单子中的计算。通过认识到我们基于回调的异步模型是单子的(具体来说,它对应于延续单子),并观察相关的单子操作,我们可以将这些通用工具应用于异步编程。
结论
好了,我们成功了!有什么收获呢?我希望我已经成功说服你相信以下几点:
- 引用透明重构是一种强大的技术,可以消除重复代码并发现有用的模式。
- “回调地狱”并非回调本身固有的问题,而是回调式 API 的特定调用方式所导致的。如果方法得当,回调式 API 可以简洁优雅,易于使用。
- 在编程语境中,“单子”的概念并非(仅仅)是晦涩难懂的学术术语,而是一个用于识别和利用日常编程中自然出现的模式的实用工具。
进一步的工作
为了保持文章的易懂性,我特意将类型签名或类似单子(monad)的概念留到文章最后才引入。或许在未来的文章中,我们可以重新推导这个抽象概念,重点关注单子和单子转换器(monad-transformer)的概念,并特别关注它们的类型和规律。
致谢
非常感谢@jlavelle、@mvaldesdeleon和@gabejohnson对本文提供的反馈和建议。
文章来源:https://dev.to/masaeedu/actually-callbacks-are-fine-l4g





