第二章: 回调
主要理解 "回调地狱(callback hell)" 痛苦的点到底是哪, 以及尝试拯救回调.
1. 首先从实际生活中模拟
我相信大多数读者都曾经听某个人说过(甚至你自己就曾这么说),"我能一心多用". 试图表现得一心多用的效果包含幽默(孩子们的拍头揉肚子游戏), 平常的行为(边走边嚼口香糖), 和彻头彻尾的危险(开车时发微信).
但我们是一心多用的人吗? 我们真的能执行两个意识, 有意地一起行动并在完全同一时刻思考 / 推理它们两个吗? 我们最高级的大脑功能有并行的多线程功能吗?
答案可能令你吃惊: 可能不是这样.
当我们 模拟 一心多用时, 比如试着在打字的同时和朋友或家人通电话, 实际上我们表现得更像一个快速环境切换器. 换句话说, 我们快速交替地在两个或更多任务间来回切换, 在微小, 快速的区块中 同时 处理每个任务. 我们做的是如此之快, 以至于从外界看开我们在 平行地 做这些事情.
难道这听起来不像异步事件并发吗(就像 JS 中发生的那样)?! 如果不, 回去再读一遍第一章! 事实上, 将庞大复杂的神经内科世界简化为我希望可以在这里讨论的东西的一个方法是, 我们的大脑工作起来有点儿像事件轮询队列. 我们的大脑可以被认为是运行在一个单线程事件轮询队列中, 就像 JS 引擎那样. 这听起来是个不错的匹配.
但是我们需要比我们刚才分析的更加细致入微. 在我们如何计划各种任务, 和我们的大脑实际如何运行这些任务之间, 有一个巨大, 明显的不同.
对比实际生活中自己计划做某些事情, 我们小心, 顺序地 (A 然后 B 然后 C) 计划, 而且我们假设一个区间有某种临时的阻塞迫使 B 等待 A, 使 C 等待 B. 但实际上真正在执行的时候并不不会真正的按照心里的剧本来演.
比如:
"我得去商店, 然后买些牛奶, 然后去干洗店". 但真实的情况往往是
"我得去趟商店, 但是我确信在路上我会接到一个电话, 于是'嗨, 妈妈', 然后她开始讲话, 我会在 GPS 上搜索商店的位置, 但那会花几分钟加载, 所以我把收音机音量调小以便听到妈妈讲话, 然后我发现我忘了穿夹克而且外面很冷, 但没关系, 继续开车并和妈妈说话, 然后安全带警报提醒我要系好, 于是'是的, 妈, 我系着安全带呢, 我总是系着安全带!'. 啊, GPS 终于得到方向了, 现在......"
这就是为什么正确编写和推理使用回调的异步 JS 代码是如此困难: 因为它不是我们的大脑进行规划的工作方式. 有许多不确定性, 而且有时控制权并不在我们自己手里.
2. 回到代码中
考虑下面的代码:
- 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)"(由于嵌套的缩进使它看起来像一个放倒的三角形). 首先嵌套是问题吗? 是它使追踪异步流程变得这么困难吗? 当然, 有一部分是. 但是 "回调地狱" 实际上与嵌套 / 缩进几乎无关. 它是一个深刻得多的问题.
让我不用嵌套重写一遍前面事件 / 超时 / Ajax 嵌套的例子:
- listen("click", handler);
- function handler() {
- setTimeout(request, 500);
- }
- function request() {
- ajax("http://some.url.1", response);
- }
- function response(text) {
- if (text == "hello") {
- handler();
- } else if (text == "world") {
- request();
- }
- }
另一件需要注意的事是: 为了将第 2,3,4 步链接在一起使他们相继发生, 回调独自给我们的启示是将第 2 步硬编码在第 1 步中, 将第 3 步硬编码在第 2 步中, 将第 4 步硬编码在第 3 步中, 如此继续. 硬编码不一定是一件坏事, 如果第 2 步应当总是在第 3 步之前真的是一个固定条件.
不过硬编码绝对会使代码变得更脆弱, 因为它不考虑任何可能使在步骤前行的过程中出现偏差的异常情况. 举个例子, 如果第 2 步失败了, 第 3 步永远不会到达, 第 2 步也不会重试, 或者移动到一个错误处理流程上, 等等.
即便我们的大脑可能以顺序的方式规划一系列任务(这个, 然后这个, 然后这个), 但我们大脑运行的事件的性质, 使恢复 / 重试 / 分流这样的流程控制几乎毫不费力. 如果你出去购物, 而且你发现你把购物单忘在家里了, 这并不会因为你没有提前计划这种情况而结束这一天. 你的大脑会很容易地绕过这个小问题: 你回家, 取购物单, 然后回头去商店.
但是手动硬编码的回调 (甚至带有硬编码的错误处理) 的脆弱本性通常不那么优雅. 一旦你最终指明了 (也就是提前规划好了) 所有各种可能性 / 路径, 代码就会变得如此复杂以至于几乎不能维护或更新.
这 才是 "回调地狱" 想表达的! 嵌套 / 缩进基本上一个余兴表演, 转移注意力的东西.
3. 信任问题
让我们来构建一个夸张的场景来生动地描绘一下信任危机.
想象你是一个开发者, 正在建造一个贩卖昂贵电视的网站的结算系统. 你已经将结算系统的各种页面顺利地制造完成. 在最后一个页面, 当用户点解 "确定" 购买电视时, 你需要调用一个第三方函数(假如由一个跟踪分析公司提供), 以便使这笔交易能够被追踪.
你注意到它们提供的是某种异步追踪工具, 也许是为了最佳的性能, 这意味着你需要传递一个回调函数. 在你传入的这个程序的延续中, 有你最后的代码 -- 划客人的信用卡并显示一个感谢页面.
这段代码可能看起来像这样:
- analytics.trackPurchase(purchaseData, function() {
- chargeCreditCard();
- displayThankyouPage();
- });
足够简单, 对吧? 你写好代码, 测试它, 一切正常, 然后你把它部署到生产环境. 大家都很开心!
6 个月过去了, 没有任何问题. 你几乎已经忘了你曾写过的代码. 一天早上, 工作之前你先在咖啡店坐坐, 悠闲地享用着你的拿铁, 直到你接到老板慌张的电话要求你立即扔掉咖啡并冲进办公室.
当你到达时, 你发现一位高端客户为了买同一台电视信用卡被划了 5 次, 而且可以理解, 他不高兴. 客服已经道了歉并开始办理退款. 但你的老板要求知道这是怎么发生的."我们没有测试过这样的情况吗!?"
你甚至不记得你写过的代码了. 但你还是往回挖掘试着找出是什么出错了.
在分析过一些日志之后, 你得出的结论是, 唯一的解释是分析工具不知怎么的, 由于某些原因, 将你的回调函数调用了 5 次而非一次. 他们的文档中没有任何东西提到此事.
十分令人沮丧, 你联系了客户支持, 当然他们和你一样惊讶. 他们同意将此事向上提交至开发者, 并许诺给你回复. 第二天, 你收到一封很长的邮件解释他们发现了什么, 然后你将它转发给了你的老板.
看起来, 分析公司的开发者曾经制作了一些实验性的代码, 在一定条件下, 将会每秒重试一次收到的回调, 在超时之前共计 5 秒. 他们从没想要把这部分推到生产环境, 但不知怎地他们这样做了, 而且他们感到十分难堪而且抱歉. 然后是许多他们如何定位错误的细节, 和他们将要如何做以保证此事不再发生. 等等, 等等.
后来呢?
你找你的老板谈了此事, 但是他对事情的状态不是感觉特别舒服. 他坚持, 而且你也勉强地同意, 你不能再相信 他们 了(咬到你的东西), 而你将需要指出如何保护放出的代码, 使它们不再受这样的漏洞威胁.
修修补补之后, 你实现了一些如下的特殊逻辑代码, 团队中的每个人看起来都挺喜欢:
- var tracked = false;
- analytics.trackPurchase(purchaseData, function() {
- if (!tracked) {
- tracked = true;
- chargeCreditCard();
- displayThankyouPage();
- }
- });
注意: 对读过第一章的你来说这应当很熟悉, 因为我们实质上创建了一个门闩来处理我们的回调被并发调用多次的情况.
但一个 QA 的工程师问,"如果他们没调你的回调怎么办?" 噢. 谁也没想过.
你开始布下天罗地网, 考虑在他们调用你的回调时所有出错的可能性. 这里是你得到的分析工具可能不正常运行的方式的大致列表:
调用回调过早(在它开始追踪之前)
调用回调过晚 (或不调)
调用回调太少或太多次(就像你遇到的问题!)
没能向你的回调传递必要的环境 / 参数
吞掉了可能发生的错误 / 异常
...
这感觉像是一个麻烦清单, 因为它就是. 你可能慢慢开始理解, 你将要不得不为 每一个传递到你不能信任的工具中的回调 都创造一大堆的特殊逻辑.
不仅仅是其他人或者第三方的代码, 自己函数参数的检查 / 规范化是相当常见的, 即便是我们理论上完全信任的代码. 用一个粗俗的说法, 编程好像是地缘政治学的 "信任但验证" 原则的等价物.
现在你更全面地理解了 "回调地狱" 有多地狱.
4. 尝试拯救回调
举个例子, 为了更平静地处理错误, 有些 API 设计提供了分离的回调(一个用作成功的通知, 一个用作错误的通知):
- function success(data) {
- console.log(data);
- }
- function failure(err) {
- console.error(err);
- }
- ajax("http://some.url.1", success, failure);
回调从不被调用的问题, 可以设置一个超时来取消事件:
- 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)));
- }
- };
- }
这是你如何使用它:
- // 使用 "错误优先" 风格的回调设计
- function foo(err, data) {
- if (err) {
- console.error(err);
- } else {
- console.log(data);
- }
- }
- ajax("http://some.url.1", timeoutify(foo, 500));
对于被调用的 "过早", 可以总是异步地调用回调, 即便它是 "立即" 在事件轮询的下一个迭代中, 这样所有的回调都是可预见的异步.
复习
一个 JavaScript 程序总是被打断为两个或更多的代码块儿, 第一个代码块儿 现在 运行, 下一个代码块儿 稍后 运行, 来响应一个事件. 虽然程序是一块儿一块儿地被执行的, 但它们都共享相同的程序作用域和状态, 所以对状态的每次修改都是在前一个状态之上的.
不论何时有事件要运行, 事件轮询 将运行至队列为空. 事件轮询的每次迭代称为一个 "tick". 用户交互, IO, 和定时器会将事件在事件队列中排队.
在任意给定的时刻, 一次只有一个队列中的事件可以被处理. 当事件执行时, 他可以直接或间接地导致一个或更多的后续事件.
并发是当两个或多个事件链条随着事件相互穿插, 因此从高层的角度来看, 它们在 同时 运行(即便在给定的某一时刻只有一个事件在被处理).
在这些并发 "进程" 之间进行某种形式的互动协调通常是有必要的, 比如保证顺序或防止 "竞合状态". 这些 "进程" 还可以 协作: 通过将它们自己打断为小的代码块儿来允许其他 "进程" 穿插.
来源: https://juejin.im/post/5aeac8566fb9a07ac76ea7d9