回调
一、continuation
先看一个异步回调例子:
// A
ajax('..', function(..) {
// C
})
// B
// A
和 // B
表示程序的前半部分(也就是现在的部分),而 // C
标识了程序的后半部分(也就是将来的部分)。前半部分立刻执行,然后是一段时间不确定的停顿。在未来的某个时刻,如果 Ajax 调用完成,程序就会从停下的位置继续执行后半部分。
总结来说:回调函数包裹或者说封装了程序的延续(continuation)。
一旦我们以回调函数的形式引入了单个 continuation
(或者几十个,就像很多程序所做的那样!),我们就容许了大脑工作方式和代码执行方式的分歧。一旦这两者出现分歧,我们就得面对这样一个无法逆转的事实:代码变得更加难以理解、追踪、调试和维护。
二、顺序的大脑
1. 执行与计划
对我们程序员来说,编写异步事件代码,特别是当回调是唯一的实现手段时,困难之处就在于这种思考 / 计划的意识流对我们中的绝大多数来说是不自然的。
我们的思考方式是一步一步的,但是从同步转换到异步之后,可用的工具(回调)却不是按照一步一步的方式来表达的。
这就是为什么精确编写和追踪使用回调的异步 JavaScript 代码如此之难:因为这并不是我们大脑进行计划的运作方式。
2. 嵌套回调
嵌套回调示例:
listen('click', function handler(e) {
setTimeout(function request() {
ajax('http://some.url', function response(res) {
if (res === 'hello') {
handler()
} else if (res === 'world') {
request()
}
})
})
})
这里我们得到了三个函数嵌套在一起构成的链,其中每个函数代表异步序列(任务,“进程”)中的一个步骤。
这种代码常常被称为 回调地狱,有时也被称为 毁灭金字塔(得名于嵌套缩进产生的横向三角形状)。
但实际上回调地狱与嵌套和缩进几乎没有什么关系。它引起的问题要比这些严重得多。
我们的顺序阻塞式的大脑计划行为无法很好地映射到面向回调的异步代码。这就是回调方式最主要的缺陷:对于它们在代码中表达异步的方式,我们的大脑需要努力才能同步得上。
三、信任问题
顺序的人脑计划和回调驱动的异步 JavaScript
代码之间的不匹配只是回调问题的一部分。
再次看这个异步回调例子:
// A
ajax('..', function(..) {
// C
})
// B
// A
和 // B
表示程序的前半部分(也就是现在的部分),而 // C
会延迟到将来发生,并且是在第三方的控制下——在本例中就是函数 ajax(..)
。从根本上来说,这种控制的转移通常不会给程序带来很多问题。
但是,请不要被这个小概率迷惑而认为这种控制切换不是什么大问题。实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候 ajax(..)
(也就是你交付回调 continuation
的第三方)不是你编写的代码,也不在你的直接控制下。多数情况下,它是某个第三方提供的工具。
我们把这称为 控制反转,也就是把自己程序一部分的执行控制交给某个第三方。
在某种程度上我们应该在内部函数中构建一些防御性的输入参数检查,以便减少或阻止无法预料的问题。回调并没有为我们提供任何东西来支持这一点。我们不得不自己构建全部的机制,而且通常为每个异步回调重复这样的工作最后都成了负担。
回调最大的问题是控制反转,它会导致信任链的完全断裂。
小结
回调函数是 JavaScript 异步的基本单元。但是随着 JavaScript 越来越成熟,对于异步编程领域的发展,回调已经不够用了。
- 第一,大脑对于事情的计划方式是线性的、阻塞的、单线程的语义,但是回调表达异步流程的方式是非线性的、非顺序的,这使得正确推导这样的代码难度很大。难于理解的代码是坏代码,会导致坏 bug。
- 第二,也是更重要的一点,回调会受到 控制反转 的影响,因为回调暗中把控制权交给第三方(通常是不受你控制的第三方工具!)来调用你代码中的 continuation。这种控制转移导致一系列麻烦的信任问题,比如回调被调用的次数是否会超出预期。
参考资料
- 《你不知道的JavaScript(中卷)》