# 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++

不错,又“解决”了一个信任问题!但这是低效的,而且也会带来膨胀的重复代码,使你的项目变得笨重。