# YDKJS-回调
回调是编写和处理 JavaScript 程序异步逻辑的最常用方式。甚至包括一些最为高深和复杂的,所依赖的异步基础也仅限于回调。
但是......回调函数也不是没有缺点。
# continuation
思考下面这段代码:
// A
setTimeout(function() {
// C
}, 1000)
// B
你将怎样描述它的执行顺序?执行 A,设定延时 1000 毫秒,然后执行 B,然后定时到时后执行 C?
这样的描述似乎还不错,但是在匹配大脑对这段代码的理解和代码对于 JavaScript 引擎的意义方面,如此略显不足。这种不匹配既微妙又显著,也正是理解回调作为异步表达和管理方式的缺陷的关键所在。
当我们以回调函数的形式引入了单个 continuation
,我们就容许了大脑工作方式和代码执行方式的分歧。一旦这两者出现分歧,我们就得面对这样一个无法逆转的事实代码变得更加难以理解、追踪、调试和维护。
# 顺序的大脑
有一些人自称“能一心多用”。人们试图让自己成为多任务执行者的努力有各种方式,包括从搞笑到日常生活(边走路边嚼口香糖),再到十分危险的行为(边开车边发短信)。
但是,我们真的能一心多用吗?实际上,在任何特定的时刻,我们只能思考一件事情。
我们在假装并行执行多个任务时,实际上极有可能是在进行快速的上下文切换,比如与朋友或家人电话聊天的同时还试图打字。
换句话说,我们是在两个或更多任务之间快速连续地来回切换,同时处理每个任务的微小片段。我们切换得如此之快,以至于对外界来说,我们就像是在并行地执行所有任务。
这听起来是不是和异步事件并发机制(比如 JavaScript 中的形式)很相似呢?!
实际上,把广博复杂的神经学简化(即误用)为一种这里我足以讨论的形式就是,我们大脑的工作方式有点类似于事件循环队列。
同步的大脑计划能够很好地映射到同步代码语句,但是从同步转换到异步之后,可用的工具(回调)却不是按照一步一步的方式来表达的。
这就是为什么精确编写和追踪使用回调的异步 JavaScript 代码如此之难:因为这并不是我们大脑进行计划的运作方式。
# 嵌套回调与链式回调
观察下面的代码,这里我们得到了三个函数嵌套在一起构成的链,其中每个函数代表异步序列(任务,“进程”)中的一个步骤:
on('click', function handler(evt) {
setTimeout(function request() {
ajax('http://some.url.1', function response(text) {
if (text == 'hello') {
handler()
} else if (text == 'world') {
request()
}
})
}, 500)
})
这种代码常常被称为回调地狱(callback hell),有时也被称为毁灭金字塔(pyramid of doom,得名于嵌套缩进产生的横向三角形状)。
对于这样满是回调的代码,理解其中的异步流不是不可能,但肯定不自然,也不容易,即使经过大量的练习也是如此。
这种嵌套的结构看起来似乎还算清晰,但是一些不以嵌套/缩进的形式组织的代码就更让人难以理解了,显然,你需要在代码中不停地上下移动视线,加上一些非异步的回调,整个程序将会变得更加难以捉摸。
# 信任问题
顺序的人脑计划和回调驱动的异步 JavaScript 代码之间的不匹配只是回调问题的一部分。
让我们再次思考一下程序中把回调 continuation(也就是后半部分)的概念:
// A
ajax( "..", function(..){
// C
} );
// B
A 和 B 发生于现在,在 JavaScript 主程序的直接控制之下。而 C 会延迟到将来发生,并且是在第三方的控制下——在本例中就是函数 ajax(..)
。从根本上来说,这种控制的转移通常不会给程序带来很多问题。
但实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候 ajax(..)
(也就是你交付回调 continuation 的第三方)不是你编写的代码,也不在你的直接控制下。
我们把这称为控制反转(inversion of control),也就是把自己程序一部分的执行控制交给某个第三方。在你的代码和第三方工具(一组你希望有人维护的东西)之间有一份并没有明确表达的契约
# 省点回调
回调设计存在几个变体,意在解决前面讨论的一些信任问题。比如,为了更优雅地处理错误,有些 API 设计提供了分离回调(一个用于成功通知,一个用于出错通知):
function success(data) {
console.log(data)
}
function failure(err) {
console.error(err)
}
ajax('http://some.url.1', success, failure)
在这种设计下,API 的出错处理函数 failure() 常常是可选的,如果没有提供的话,就是假定这个错误可以吞掉。
还有一种常见的回调模式叫作“error-first 风格”(有时候也称为“Node 风格”),其中回调的第一个参数保留用作错误对象。如果成功的话,这个参数就会被清空/置假(后续的参数就是成功数据)。
事实上,这并没有像表面看上去那样真正解决主要的信任问题(比如没有涉及阻止或过滤不想要的重复调用回调的问题),而且事情似乎变得更糟了。
现在你可能同时得到成功或者失败的结果,或者都没有,并且你还是不得不编码处理所有这些情况。那么完全不调用这个信任问题又会怎样呢?如果这是个问题的话,你可能需要设置一个超时来取消事件:
function timeoutify(fn, delay) {
var intv = setTimeout(function() {
intv = null
fn(new Error('Timeout!'))
}, delay)
return function() {
// 还没有超时?
if (intv) {
clearTimeout(intv)
fn.apply(this, arguments)
}
}
}
// 使用"error-first 风格" 回调设计
function foo(err, data) {
if (err) {
console.error(err)
} else {
console.log(data)
}
}
ajax('http://some.url.1', timeoutify(foo, 500))
还有一个信任问题是调用过早。在特定应用的术语中,这可能实际上是指在某个关键任务完成之前调用回调。但是更通用地来说,对于既可能在现在(同步)也可能在将来(异步)调用你的回调的工具来说,这个问题是明显的。
有一条非常有效的建议:永远异步调用回调,即使就在事件循环的下一轮,这样,所有回调就都是可预测的异步调用了。
如果你不确定关注的 API 会不会永远异步执行怎么办呢?可以创建一个类似于这个“验证概念”版本的 asyncify(..)
工具:
function asyncify(fn) {
var orig_fn = fn,
intv = setTimeout(function() {
intv = null
if (fn) fn()
}, 0)
fn = null
return function() {
// 触发太快,在定时器 intv 触发指示异步转换发生之前?
if (intv) {
fn = orig_fn.bind.apply(
orig_fn,
// 把封装器的 this 添加到 bind(..) 调用的参数中,
// 以及克里化(currying)所有传入参数
[this].concat([].slice.call(arguments)),
)
}
// 已经是异步
else {
// 调用原来的函数
orig_fn.apply(this, arguments)
}
}
}
// 使用方式
function result(data) {
console.log(a)
}
var a = 0
ajax('..pre-cached-url..', asyncify(result))
a++
不错,又“解决”了一个信任问题!但这是低效的,而且也会带来膨胀的重复代码,使你的项目变得笨重。