- 在前面所有的示例中,函数都是作为回调使用的,因为它是事件循环“回头调用”的目标。
- 回调函数是JavaScript的异步主力军,但是回调函数也不是没有缺点的。
一、程序的延续
- 回调函数包裹或者说封装了程序的延续。
- 看一段简化的代码,描述执行顺序:
// A
setTimeout(function() {
// C
},1000);
// B
- 有些人会描述,先执行A,设置延时1000毫秒,然后执行B,1000毫秒到时后执行C。
- 这样解释是不足的(不够精确),这也是回调作为异步表达和管理方式的缺陷的关键所在。
- 如果存在多个回调函数,那么就会难以理解、追踪、调试和维护。
二、顺序的大脑
- 我们大脑的工作方式优点类似于事件循环队列。
- 大脑在特定的时候,只能思考一件事情。我们好像并行执行多个任务,但实际上可能是快速的上下文切换。
- 也就是在多个任务之间快速地来回切换。
- 我们切换得非常快,对于外界来说,我们就像在并行地执行任务。
1. 执行与计划
- 我们大脑先安排顺序,然后按照顺序(A,然后B,然后C)执行,如果有某种形式的拥塞,会保证B等待A完成,C等待B完成。
- 开发者编写代码的时候是在计划一系列动作的发生。
- 问题是,代码(通过回调)表达异步的方式并不能很好地映射到同步的大脑计划行为。
- 我们思考方式是一步一步的,但是从同步切换到异步后,回调却不是按照一步一步的方式来表达。
- 这就是为什么精确编写和追踪使用回调的异步JavaScript困难的原因。
2. 嵌套回到和链式回调
- 多个函数嵌套在一起,每个函数都代表异步序列(多个有顺序的异步任务)中的一个步骤,这种代码常常被称为回调地狱。
顺序匹配
- 实际上,回调地狱与嵌套和缩进几乎没有什么关系。
- 这只是其中的一个问题,它引起的问题要比这个严重得多。
// 伪代码,function()代表回调函数
doA(function() {
doB();
doC(function() {
doD();
})
doE();
});
doF();
- 尽管有经验的你能够正确描述出实际的运行顺序,但这需要费一番脑筋才能想清楚。
- 实际顺序:
doA() => doF() => doB() => doC() => doE() => doD()
- 我们不得不上下来回查看哪个函数先被调用。想象一下如果异步任务更多,那么将更加复杂。
- 但是,这是假定异步的情况下。如果
doA()
和doD()
实际并不是异步,或者在某些情况下并不是异步的,那么这个顺序就更加困难了。
脆弱性
-
这样硬编码还会使代码更脆弱,因为它没有考虑可能导致步骤执行过程中的异常情况。
-
一旦你指定了所有可能事件和路径,代码就会变得非常复杂。
-
比如,如果步骤2失败,就永远不会到达步骤3,不管是重试还是跳转到其他错误流程。这个问题也可以解决,但是代码通常是重复的。(成功和失败的程序中的代码重复了)
-
这才是回调地狱的真正问题所在。
三、信任问题
-
回调最大的问题是控制反转,它会导致信任链完全断裂。
-
我们把自己程序一部分的执行控制交给某个第三方,称为控制反转。
-
我们使用第三方库的API,将回调函数传入的时候,可能会导致一些问题。
-
比如:在这个第三方库里面,因为某些错误将这个回调执行了多次。你可能只要它执行一次,但这不是你可以控制的。
当然,不只是次数问题,还有
- 调用回调过早
- 调用回调过晚(或没有调用)
- 调用次数过多或过少
- 没有成功地将参数传入到回调中
- 吞掉可能出现的报错和异常
-
这不只是针对外部代码,对于我们自己控制下的代码,也可能会有问题。
四、尝试挽救回调
回调设计存在几个变体,意在解决前面讨论的一些信任问题。
1. 处理错误
- 分离回调设计:一个用于成功通知,一个用于出错通知。比如ES6的Promise API。
- error-first风格:回调中第一个参数预留作为错误对象,如果成功,这个参数会置假。
2. 调用过早
- 对于既可能在现在(同步)也可能在将来(异步)调用你的回调的工具来说,会有明显的问题。
- 这种由同步或异步行为引起的不确定性总会带来极大的bug追踪难度。
- 永远异步调用回调,这样所有回调都是可以预测的异步调用了。