Skip to content

💤

《你不知道的 JavaScript》

回调

在第一章中,我们探讨了 JS 中关于异步编程的属于和概念。我们的焦点是理解驱动所有“事件”(异步函数调用)的单线程(一次一个)事件循环队列。我们还探讨了各种解释同时运行的事件链,或“进程”(任务,函数调用等)间的关系的并发模式。

我们在第一章的所有例子中,将函数作为独立的,不可分割的操作单位使用,在这些函数内部语句按照可预知的顺序运行(在编译期水平之上!),但是在函数顺序水平上,事件(也就是异步函数调用)可以以各种顺序发生。

在所有这些情况中,函数都是一个“回调”。因为无论什么时候事件循环队列中的事件被处理时,这个函数都作为事件循环“调用并返回”程序的目标。

正如你观察到的,JS 程序中,回调是目前为止最常见的表达和管理异步的方式。确实,在 JS 语言中回调是最基础的异步模式。

无数的 JS 程序,即便是最精巧最复杂的程序,都曾经除了回调外不依靠任何其他异步模式而编写(当然,和我们在第一章中探讨的并发互动模式一起)。回调函数是 JS 的异步苦工,而且它工作得相当好。

除了...回调并不是没有缺点。许多开发者都对Promises提供的更好的异步模式感到兴奋不已。但是如果你不明白它在抽象什么,和为什么抽象,是不可能有效利用任何抽象机制的。

在本章中,我们将深入探讨这些话题,来说明为什么更精巧的异步模式(在本书的后续章节中讨论)是必要和被期望的。

延续

让我们回到在第一章中开始的异步回调的例子,但让我稍微修改它一下来画出重点:

javascript
// A
ajax('..',function(..){
  // C
})
// B

// A// B代表程序的前半部分(也就是现在),// C标识了程序的后半部分(也就是将来)。前半部分立即执行,然后会出现一个不知多久的“暂停”。在某个未来时刻,如果 Ajax 调用完成了,那么程序会回到它刚才离开的地方,并继续执行后半部分。

换句话说,回调函数包装或封装了程序的延续

让我们把代码弄得简单一些:

javascript
// A
setTimeout(function () {
  // C
}, 1000)
// B

稍等片刻然后问你自己,你将如何描述(给一个不那么懂 JS 工作方式的人)这个程序的行为。来吧,大声说出来。这个很好的练习将使我的下一个观点更鲜明。

现在大多数读者可能在想或说着这样的话:“运行 A,然后设置一个等待 1000 毫秒的定时器,一旦它触发,就执行 C”。与你的版本有多接近?

你可能已经发觉了不对劲儿的地方,给了自己一个修正版:“运行 A,设置一个 1000 毫秒的定时器,然后运行 B,然后在定时器事件触发后,运行 C”。这比第一个版本更准确。你能发现不同之处吗?

虽然第二个版本更准确,但是对于以一种我们的大脑匹配代码,代码匹配 JS 引擎的方式讲解这段代码来说,这两个版本都是不足的。这里的鸿沟即是微小的也是巨大的,而且是理解回调作为异步表达和管理的缺点的关键。

只要我们以回调函数的方式引入一个延续(或者像许多程序员那样引入几十个!),我们就允许了一个分歧在我们的大脑的工作方式和代码的运行方式之间形成。当这两者背离时,我们的代码就不可避免地陷入这样的境地:更难理解,更难推理,更难调试,更难维护。

顺序的大脑

我相信大多数读者都曾经听某个人说过(甚至你自己就曾这么说),“我能一心多用”。试图表现得一心多用的效果包含幽默(孩子们的拍头揉肚子游戏),平常的行为(边走边嚼口香糖),和彻头彻尾的危险(开车时发短信)。

但我们是一心多用的人吗?我们真的能执行两个意识,有意的一起行动并在完全同一时刻思考/推理它们两个吗?我们最高级的大脑功能有并行的多线程功能吗?

答案可能令你吃惊:可能不是这样

我们的大脑其实就不是这样构成的。我们中大多数人(特别是 A 型人格!)都是自己不情愿承认的一个一心一用者。其实我们只能在任意给定的时刻考虑一件事。

我不是说我们所有的下意识,潜意识,大脑的自动功能,比如心跳和眨眼。那些都是我们延续生命的重要任务,我们不会有意识地给它们分配大脑的能量。谢天谢地,当我们在 3 分钟内第 15 次刷朋友圈时,我们的大脑在后台(线程!)继续着这些重要任务。

相反我们讨论的是在某时刻我们的意识最前线的任务。对我来说,是现在正在写这本书。我还在这完全同一时刻做其他高级的大脑活动吗?不,没有。我很快而且容易分心 —— 在这最后的几段中有几十次了!

当我们模拟一心多用时,比如试着在打字的同时和朋友或家人通电话,实际上我们表现得更像一个快速环境切换器。换句话说,我们快速交替的在两个或更多任务间来回切换,在微小,快速的区块中同时处理每个任务。我们做的是如此之快,以至于从外界看来我们在平行的做这些事情。

难道这听起来不像异步事件并发吗?(就像 JS 中发生的那样)?!如果不,回去再读一遍第一章!

事实上,有一种方法可以将庞大复杂的神经内科世界简化为我希望可以在这里讨论的东西,那就是我们的大脑工作起来有点儿像事件循环队列。

如果你把我打的每一个字(或词)当作一个单独的异步事件,那么现在这一句话上就有十几处地方,可以让我的大脑被其他的事件打断,比如我的感觉,甚至只是我随机的想法。

我不会被打断,也不会一有机会就被拉到其他的“处理”上去(谢天谢地 —— 要不这本书永远也写不完了!)。但是它发生的也足够频繁,以至于我感到我的大脑几乎持续不断的切换到各种不同的环境(也就是“进程”)。而这和 JS 引擎的感觉十分相像。

执行与计划

好了,这么说来我们的大脑可以被认为是运行在一个单线程事件循环队列中,就像 JS 引擎那样。这听起来是个不错的匹配。

但是我们需要比我们刚才分析得更加细致入微。在我们如何计划各种任务,和我们的大脑实际如何运行这些任务之间,有一个巨大,明显的不同。

再一次,回到这边文章的写作的比喻上来。在我心里的粗略计划轮廓是继续写啊写,顺序的经过一系列在我思想中定好的点。我没有在这次写作期间计划任何的打扰或非线性的活动。但无论如何,我的大脑依然不停的切换。

即便在操作级别上我们的大脑是异步事件的,但我们还是用一种顺序的,同步的方式计划任务。“我得去商店,然后买些牛奶,然后去干洗店”。

你会注意到这种高级思维(规划)方式看起来不是那么“异步”。事实上,我们几乎很少会故意只用事件的形式思考。相反,我们小心,顺序的(A 然后 B 然后 C)计划,而且我们假设一个区间有某种临时的阻塞迫使 B 等待 A,使 C 等待 B。

当开发者编写代码时,他们规划一组将要发生的动作。如果他们是合格的开发者,他们会小心的规划。比如“我需要将z的值设为x的值,然后将x的值设为y的值”。

当我们编写同步代码时,一个语句接一个语句,它工作起来就像我们的跑腿 todo 清单:

javascript
// 交换`x`与`y`(通过临时变量`z`)
z = x
x = y
y = z

这三个赋值语句是同步的,所以x = y会等待z = x完成,而y = z会相应的等待x = y完成。另一种说法是这三个语句临时的按照特定的顺序绑在一起执行,一个接一个。幸好我们不必在这里关心任何异步事件的细节。如果我们关心,代码很快就会变得非常复杂!

如果同步的大脑规划和同步的代码语句匹配的很好,那么我们的大脑能把异步代码规划的多好呢?

事实证明,我们在代码中表达异步的方式(用回调)和我们同步的大脑规划行为根本匹配的不是很好。

你能实际想象一下像这样规划你的跑腿 todo 清单的思维线索吗?

“我得去趟商店,但是我确信在路上我会接到一个电话,于是‘嗨,妈妈’,然后她开始讲话,我会在 GPS 上搜索商店的位置,但那会花几分钟加载,所以我把收音机音量调小以便听到妈妈讲话,然后我发现我忘了穿夹克而且外面很冷,但没关系,继续开车并和妈妈说话,然后安全带警报提醒我要系好,于是‘是的,妈,我系着安全带呢,我总是系着安全带!’。啊,GPS 终于得到方向了,现在...”

虽然作为我们如何度过自己的一天,思考以什么顺序做什么事的规划听起来很荒唐,但这正是我们大脑在功能层面运行的方式。记住,这不是一心多用,而只是快速的环境切换。

我们这些开发者编写异步事件代码困难的原因,特别是当我们只有回调手段可用时,就是意识流思考/计划的流动对我们大多数人是不自然的。

我们用一步接一步的方式思考,但是一旦我们从同步走向异步,在代码中可以用的工具(回调)不是以一步接一步的方式表达的。

而且这就是为什么正确编写和推理使用回调的异步 JS 代码是如此困难:因为它不是我们的大脑进行规划的工作方式。

NOTE

唯一比不知道代码为什么崩溃更可怕的事情是,不知道为什么一开始代码是好用的!这是一种经典的“纸牌屋”心理:“它好用,但不知为什么,所以大家都别碰!”你可能听说过,“他人即地狱”(萨特),而程序员们模仿这种说法,“他人的代码即地狱”。我相信:“不理解我自己的代码才是地狱。”而回调正是主要元凶之一。

嵌套/链接的回调

考虑下面的代码:

javascript
listen('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)”(由于嵌套的缩进使它看起来像一个倒放的三角形)。

但是“回调地狱”实际上与嵌套/缩进几乎无关。它是一个深刻得多的问题。我们将继续在本章剩下的部分看到它为什么和如何成为一个问题。

首先,我们等待“click”事件,然后我们等待定时器触发,然后我们等待 Ajax 应答回来,就在这时它可能会将所有这些再做一遍。

猛的一看,这段代码的异步性质可能看起来与顺序的大脑规划相匹配。

首先(现在),我们:

javascript
listen('..',function handler(..){
  // ..
})

稍后,我们:

javascript
setTimeout(function request(..){
  // ..
},500)

稍后,我们:

javascript
ajax('..',function response(..){
  // ..
})

最后,(最稍后),我们:

javascript
if(..){
  // ..
}
else {
  // ..
}

不过用这样的方式线性推导这段代码有几个问题。

首先。这个例子中我们的步骤在一条顺序的线上(1,2,3,4...)是一个巧合。在真实的异步 JS 程序中,经常会有很多噪音把事情搞乱,在我们从一个函数跳到下一个函数时不得不在大脑中把这些噪音快速的演练一遍。理解这种充满回调的异步流程并非不可能,但绝对不自然或容易,即使是经历了很多练习后。

而且,有些更深层的错误,只是在这段代码中并不明显。让我们建立另一个场景(假想代码)来展示它:

javascript
doA(function () {
  doB()

  doC(function () {
    doD()
  })

  doE()
})

doF()

虽然根据经验你将正确的指出这些操作的真实顺序,但我打赌它第一眼看上去有些使人糊涂,而且需要一些协调的思维周期才能搞明白。这些操作将会以这种顺序发生:

  • doA(..)
  • doF(..)
  • doB(..)
  • doC(..)
  • doE(..)
  • doD(..)

你是在第一次浏览这段代码就看明白的吗?

好吧,你们肯定有些人在想我在函数的命名上不公平,故意引导你误入歧途。我发誓我只是按照从上到下出现的顺序命名的。不过让我再试一次:

javascript
doA(function () {
  doC()

  doD(function () {
    doF()
  })

  doE()
})

doB()

现在,我以它们实际执行的顺序用字母命名了。但我依然要打赌,即便是现在对这个场景有经验的情况下,大多数读者追踪A -> B -> C -> D -> E -> F的顺序并不是自然而然的。你的眼睛肯定在这段代码中上上下下跳了许多次,对吧?

就算它对你来说都是自然的,这里依然还有一个可能肆虐的灾难。你能发现它是什么吗?

如果doA(..)doD(..)实际上不是如我们明显的假设的那样,不是异步的呢?嗯,现在顺序不同了。如果它们都是同步的(也许仅仅有时是这样,根据当时程序所处的条件而定),现在的顺序是A -> C -> D -> F -> E -> B

你在背景中隐约听到的声音,正是成千上万双手掩面的 JS 开发者的叹息。

嵌套是问题吗?是它使追踪异步流程变得这么困难吗?当然,有一部分是。

但是让我不用嵌套重写一遍前面事件/定时器/Ajax 嵌套的例子:

javascript
listen('click', handler)

function handler(evt) {
  setTimeout(request, 500)
}

function request() {
  ajax('http://some.url.1', response)
}

function response(text) {
  if (text == 'hello') {
    handler()
  } else if (text == 'world') {
    request()
  }
}

这样的代码组织形式几乎看不出来有前一种形式的嵌套/缩进困境,但它的每一处依然容易受到“回调地狱”的影响。为什么呢?

当我们线性的(顺序的)推理这段代码,我们不得不从一个函数跳到下一个函数,在跳到下一个函数,并在代码中跳来跳去“查看”顺序流。并且要记住,这个简化的代码风格是某种最佳情况。我们都知道真实的 JS 程序代码经常更神奇的错综复杂,使这样量级的顺序推理更加困难。

另一件需要注意的事:为了将 2,3,4 步链接在一起使它们相继发生,回调独自给我们的启示是将第 2 步硬编码在第 1 步中,将第 3 步硬编码在第 2 步中,将第 4 步硬编码在第 3 步中。如此继续。硬编码不一定是一件坏事,如果总是能确定第 2 步总是出现在第 3 步之前。

不过硬编码绝对会使代码变得更脆弱,因为它不考虑任何可能使在步骤前行的过程中出现偏差的异常情况。举个例子,如果第 2 步失败了,第 3 步永远不会到达,第 2 步也不会重试,或者移动到一个错误处理流程上,等等。

所有这些问题你都可以手动硬编码在每一步中,但那样的代码总是重复的,而且不能在其他步骤或你程序的其他异步流程中复用。

即便我们的大脑可能以顺序的方式规划一系列任务(这个,然后这个,然后这个),但我们大脑运行的事件的性质,使恢复/重试/分流这样的流程控制几乎毫不费力。如果你出去购物,而且你发现你把购物单忘在家里了,这并不会因为你没有提前计划这种情况而结束这一天。你的大脑会很容易绕过这个小问题:你回家,取购物单,然后回头去商店。

但是手动硬编码的回调(甚至带有硬编码的错误处理)的脆弱本性通常不那么优雅。一旦你最终明了(也就是提前规划好了)所有各种可能性/路径,代码就会变得如此复杂以至于几乎不能维护或更新。

才是“回调地狱”的意义所在!而嵌套/缩进基本上就是一个小插曲,转移注意力的东西。

如果以上这些还不够,我们还没有触及两个或更多这些回调延续的链条同时发生会怎么样,或者当第 3 步分叉成为带有大门或门闩的“并行”回调,或者...我的天哪,我脑子疼,你呢?

你抓住这里的重点了吗?我们顺序的,阻塞的大脑规划行为和面向回调的异步代码不能很好的匹配。这就是需要清楚的阐明关于回调的首要缺陷:它们在代码中表达异步的方式,是需要我们的大脑不得不斗争才能保持一致的。

信任问题

在顺序的大脑规划和 JS 代码中回调驱动的异步处理间的不匹配只是关于回调的问题的一部分。还有一些更深刻的问题值得担忧。

让我们再一次重温这个概念 —— 回调函数是我们程序的延续(也就是程序的第二部分):

javascript
// A
ajax('..',function(..){
  // C
})
// B

// A// B现在发生,在 JS 主程序的直接控制之下。但是// C被推迟到将来再发生,并且在另一部分的控制之下 —— 这里是ajax(..)函数。在基本的感觉上,这样的控制交接一般不会让程序产生很多问题。

但是不要被这种控制切换不是什么大事的罕见情况欺骗了。事实上,它是回调驱动的设计的最可怕的(也是最微妙的)问题。这个问题围绕着一个想法展开:有时ajax(..)(或者说你向之前提交回调的部分)不是你写的函数,或者不是你可以直接控制的函数。很多时候它是一个由第三方提供的工具。

当你把程序的一部分拿出来并把它执行的控制权交给一个第三方时,我们称这种情况为“控制反转”。在你的代码和第三方工具之间有一个没有明言的“契约” —— 一组你期望被维护的东西。

五个回调的故事

为什么这件事情很重要可能不是那么明显。让我们来构建一个夸张的场景来生动的描绘一下信任危机。

想象你是一个开发者,正在建造一个贩卖昂贵电视的网站的结算系统。你已经将结算系统的各种页面顺利的制造完成。在最后一个页面,当用户点击“确定”购买电视时,你需要调用一个第三方函数(假如有一个跟踪分析公司提供),以便使这笔交易能够被追踪。

你注意到它们提供的是某种异步追踪工具,也许是为了最佳的性能,这意味着你需要传递一个回调函数。在你传入的这个程序的延续中,有你最后的代码 —— 划客人的信用卡并显示一个感谢页面。

这段代码可能看起来像这样:

javascript
analytics.trackPurchase(purchaseData, function () {
  chargeCreditCard()
  displayThankyouPage()
})

足够简单,对吧?你写好代码,测试它,一切正常,然后你把它部署到生产环境。大家都很开心!

6 个月过去了,没有任何问题,你几乎已经忘记了你曾写过的代码。一天早上,工作之前你先在咖啡店坐坐,悠闲地享用着你的拿铁,直到你接到老板慌张的电话要求你立即扔掉咖啡并冲进办公室。

当你到达时,你发现一位高端客户为了买一台电视信用卡被划了 5 次,而且可以理解,他很不高兴。客服已经道了歉并开始办理退款。但你的老板要求知道这是怎么发生的。“我们没有测试过这样的情况吗!?”

你甚至不记得你写过的代码了。但你还是往回挖掘试着找出是什么出错了。

在分析过一段日志之后,你得出的结论是,唯一的解释是分析工具不知怎么的,由于某些原因,将你的回调函数调用了 5 次而非一次。他们的文档中没有任何东西提到此事。

十分令人沮丧,你联系了客户支持,当然他们和你一样惊讶。他们同意将此事向上提交至开发者,并许诺给你回复。第二天,你收到一封很长的邮件解释他们发现了什么,然后你将它转发给了你的老板。

看起来,分析公司的开发者曾经制作了一些实验性的代码,在一定条件下,将会每秒重试一次收到的回调,在超时之前共计 5 秒他们从没要想把这部分推到生产环境,但不知怎得他们这样做了,而且他们感到十分难堪而且抱歉。然后是许多他们如何定位错误的细节,和他们将要如何做以保证此事不再发生。等等,等等。

后来呢?

你找你的老板谈了此事,但是他对事情的状态感到不是特别舒服。他坚持,而且你也勉强的同意,你不能再相信他们了(咬到你的东西),而你将需要指出如何保护放出的代码,使它们不再受这样的漏洞威胁。

修修补补之后,你实现了一些如下的特殊逻辑代码,团队的每个人看起来都挺喜欢:

javascript
var tracked = false

analytics.trackPurchase(purchaseData, function () {
  if (tracked) return
  tracked = true
  chargeCreditCard()
  displayThankyouPage()
})

NOTE

对读过第一章的你来说这应当很熟悉,因为我们实质上创建了一个门闩来处理我们的回调被并发调用多次的情况。

但一个 QA 的工程师问,“如果他们没调你的回调怎么办?”噢。谁也没想过。

你开始布下天罗地网,考虑在他们调用你的回调时所有出错的可能性。这里是你得到的分析工具可能不正常运行的方式的大致列表:

  • 调用回调过早(在它开始追踪之前)
  • 调用回调过晚(或不调)
  • 调用回调太少或太多次(就像你遇到的问题!)
  • 没能向你的回调传递必要的参数/环境
  • 吞掉了可能发生的错误/异常
  • ...

这感觉就像一个麻烦清单,因为它就是。你可能慢慢开始理解,你将要不得不为每一个传递到你不能信任的工具中的回调都创建一大堆特殊逻辑。

现在你更全面地理解了“回调地狱”有多地狱。

不仅是其他人的代码

现在有些人可能会怀疑事情到底是不是如我所宣扬的这么大条。也许你根本就不和真正的第三方工具互动。也许你用的是进行了版本控制的 API,或者自己保管的库,因此它的行为不会在你不知晓的情况下改变。

那么,好好思考这个问题:你能真正信任你理论上控制(在你的代码库)的工具吗?

这样思考:我们大多数人都同意,至少在某个区间内我们应当带着一些防御性的输入参数检查制造我们自己的内部函数,来减少/防止以外的问题。

过于相信输入:

javascript
function addNumbers(x, y) {
  // + 操作符使用强制转换重载为字符串连接
  // 所以根据传入参数的不同,这个操作不是严格的安全

  return x + y
}

addNumbers(21, 21) // 42
addNumbers(21, '21') // '2121'

防御不信任的输入:

javascript
function addNumbers(x, y) {
  // 保证数字输入
  if (typeof x !== 'number' || typeof y !== 'number') {
    throw Error('Bad parameters')
  }

  // 如果我们到达这里, + 就可以安全的做数字加法
  return x + y
}

addNumbers(21, 21) // 42
addNumbers(21, '21') // Error: Bad parameters

或者也许依然安全但更友好:

javascript
function addNumbers(x, y) {
  // 保证数字输入
  x = Number(x)
  y = Number(y)

  //  + 将会安全的执行数字加法
  return x + y
}

addNumbers(21, 21) // 42
addNumbers(21, '21') // 42

不管你怎么做,这类函数参数的检查/规范化是相当常见的,即便是我们理论上完全信任的代码。用一个粗俗的说法,编程好像是地缘政治学的“信任但验证”原则的等价物。

那么,这不是要推论出我们应当对异步函数回调的编写做相同的事,而且不仅是针对真正的外部代码,甚至要对一般认为是“在我们控制之下”的代码?我们当然应该

但是回调没有给我们提供任何协助,我们不得不自己构建所有的装置,而且这通常最终成为许多我们要在每个异步回调中重复的模板/负担。

有关于回调的最麻烦的问题就是控制反转导致所有这些信任完全崩溃。

如果你有代码用到回调,特别但不限于第三方工具,而且你还没有为所有这些控制反转的信任问题实施某些缓和逻辑,那么你的代码现在就bug,虽然它们还没咬到你。将来的 bug 依然是 bug。

尝试拯救回调

有几种回调的设计试图解决一些(不是全部!)我们刚才看到的信任问题。这是一种将回调模式从它自己的崩溃中拯救出来的勇敢,但注定失败的努力。

举个例子,为了更平静地处理错误,有些 API 设计提供了分离的回调(一个用作成功的通知,一个用作错误的通知):

javascript
function success(data) {
  console.log(data)
}

function failure(err) {
  console.error(err)
}

ajax('http://some.url.1', success, failure)

在这种设计的 API 中,failure()错误处理器通常是可选的,而且如果不提供的话它会假定你想让错误被吞掉。呃。

NOTE

ES6 的 Promises 的 API 使用的就是这种分离回调设计。我们将在下一章中详尽的讨论 ES6 的 Promises。

另一种常见的回调设计模式称为“错误优先风格”(有时称为“Node 风格”,因为它几乎在所有的 Node.js 的 API 中作为惯例使用),一个回调的第一个参数为一个错误对象保留(如果有的话)。如果成功,这个参数将会是空/falsy(而其他后续的参数将是成功的数据),但如果出现了错误的结果,这第一个参数会被设置为 truthy(而且通常没有其他东西会被传递了):

javascript
function response(err, data) {
  // 有错?
  if (err) {
    console.error(err)
  }
  // 否则,认为成功
  else {
    console.log(data)
  }
}

ajax('http://some.url.1', response)

这两种方法都有几件事情应当注意。

首先,它们没有像看起来那样真正解决主要的信任问题。在这两个回调中没有关于防止或过滤意外的重复调用的东西。而且,事情现在更糟糕了,因为你可能同时得到成功和失败信号,或者都得不到,你仍然不得不围绕着这两种情况写代码。

还有,不要忘了这样的事实:虽然它们是你可以引用的标准模式,但它们绝对更加繁冗,而且是不太可能复用的模板代码,所以你将会对在你应用程序的每一个回调中敲出它们感到厌倦。

回调从不被调用的信任问题怎么解决?如果这要紧(而且它可能应当要紧!),你可能需要设置一个定时器来取消事件。你可以制作一个工具来帮你:

javascript
function timeoutify(fn, delay) {
  var intv = setTimeout(function () {
    intv = null
    fn(new Error('Timeout!'))
  }, delay)

  return function () {
    // 超时还没发生?
    if (intv) {
      clearTimeout(intv)
      fn.apply(this, [null].concat([].slice.call(arguments)))
    }
  }
}

这是你如何使用它:

javascript
// 使用“错误优先”风格的回调设计
function foo(err, data) {
  if (err) {
    console.error(err)
  } else {
    console.log(data)
  }
}

ajax('http://some.url.1', timeoutify(foo, 500))

另一个信任问题是被调用的“过早”。在应用程序规范上讲,这可能涉及在某些重要的任务完成之前被调用。但更一般地,在那些既可以现在(同步的),也可以在将来(异步的)调用你提供的回调的工具中这个问题更明显。

这种围绕着同步或异步行为的不确定性,几乎总是导致非常难追踪的 bug。在某些圈子中,一个名叫 Zalgo 的可以导致人精神错乱的虚构怪物被用来描述这种同步/异步的噩梦。经常能听到人们喊“别放出 Zalgo!”,而且它引出了一个响亮的建议:总是异步的调用回调,即便它是“立即”在事件循环的下一个迭代中,这样所有的回调都是可预见的异步。

NOTE

更多关于 Zalgo 的信息,参见 Oren Golan 的“Dont't Release Zalgo!(不要释放 Zalgo!)”🔗和 Isaac Z. Schlueter 的“Designing APIs for Asynchrony(异步 API 设计)”🔗

考虑下面的代码:

javascript
function result(data) {
  console.log(a)
}

var a = 0

ajax('http://some.url.1', result)
a++

这段代码是打印0(同步回调调用)还是打印1(异步回调调用)?这...要看情况。

你可以看到 Zalgo 的不可预见性有能有多快的威胁你的 JS 程序。所以听起来傻乎乎的“别放出 Zalgo”实际上是一个不可思议的常见且实在的建议 —— 总是保持异步。

如果你不知道当前的 API 是否总是异步地执行呢?你可以制造一个像asyncify(..)这样的工具:

javascript
function asyncify(fn) {
  var orig_fn = fn
  var 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)
    }
  }
}

你像这样使用asyncify(..)

javascript
function result(data) {
  console.log(a)
}

var a = 0

ajax('http://some.url.1', asyncify(result))
a++

不管 Ajax 请求是由于存在于缓存中而解析为立即调用回调,还是它必须走过网线去取得数据而异步的稍后完成,这段代码总是输出1而不是0 —— result(..)总是被异步的调用,这意味着a++有机会在result(..)之前运行。

哦耶,又一个信任问题被“解决了”!但它很低效,而且又有更多臃肿的模板代码让你的项目变得沉重。

这只是关于回调一遍又一遍的重复的故事。它们几乎可以做任何你想做的事,但你不得不努力工作来达到目的,而且大多数时候这种努力远远超过你可以或应该在这种代码推理上花费的精力。

你可能发现自己希望有一些内建的 API 或语言机制来解决这些问题。终于 ES6 带来了一些很棒的答案,所以继续读下去!

复习

回调是 JS 中异步的基础单位。但是随着 JS 的成熟,它们对于异步编程的演化趋势来讲显得不够。

首先,我们的大脑用顺序的,阻塞的,单线程的语义方式规划事情,但是回调使用非线性,非顺序的方式表达异步流程,这使我们正确推理这样的代码变得非常困难。错误的代码推理会导致错误的代码。

我们需要一种方法,以更同步化,顺序化,阻塞的方式来表达异步,正如我们的大脑那样。

第二,而且更重要的是,回调遭受这控制反转的蹂躏,它们隐含的将控制权交给第三方(通常第三方工具不受你控制!)来调用你程序的延续。这种控制权的转移使我们得到一张信任问题的令人不安的列表,比如回调是否会比我们期望的被调用更多次。

制造特殊的逻辑来解决这些信任问题是可能的,但是它比它应有的难度高多了,还会产生更笨重和更难维护的代码,而且在 bug 实际咬到你的时候代码会显得在这些危险上被保护的不够。

我们需要一个所有这些信任问题的一般化解决方案。一个可以被所有我们制造的回调复用,而且没有多余的模板代码负担的方案。

我们需要比回调更好的东西。目前为止它们做的不错,但 JS 的未来要求更精巧和强大的异步模式。本书在后续章节将会深入这些新兴的发展变化。