JavaScript之异步 - 回调函数
1. 回顾之前的概念
在函数内部,语句以可预测的顺序执行(在编译器以上的层级!),但是在函数顺序这一层级,事件(也就是异步函数调用)的运行顺序可以有多种可能。
函数都是作为回调( callback)使用的,因为它是事件循环“回头调用”到程序中的目标,队列处理到这个项目的时候会运行它。
回调是编写和处理 JavaScript 程序异步逻辑的最常用方式。确实,回调是这门语言中最基础的异步模式。
(1). 我们把代码块的分成现在运行的代码块和未来运行的代码块。事件循环就是一种来处理多个块执行的机制,且执行代码块的时候调用Javascript引擎。我们现在得到的信息是,事件循环是基于javascript引擎的的机制。
(2). 但是JavaScript 引擎本身并没有时间的概念,只是一个按需执行JavaScript 任意代码片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行。
这里得到的信息是(个人理解): 引擎会执行代码块, 当我们加载js之后,引擎就会解释执行代码,由js代码的入口开始按照js代码的顺序(理解为这样,从代码块的角度,不考虑预编译的问题)来执行。但是这些只能适用于当前代码块的执行,如果存在setTimeout,事件绑定,ajax等操作,回掉函数的执行是光依靠javascipt引擎解决不了的。因为引擎自己不确定什么时间需要执行哪一个回掉函数的代码块,引擎的工作还需要运行的环境决定。
(3). 发生异步操作的时候(setTimeout,事件绑定,ajax),引擎会和宿主环境一起工作,执行完当前代码块的时候,引擎向宿主环境添加监听事件(个人认为,也可能是其他的实现原理),当监听的事件完成,宿主环境将回掉函数插入到事件循环队列进行执行。
2. continuation
// A
ajax( "..", function(..){
// C
} );
// B
// A 和 // B 表示程序的前半部分(也就是现在的部分),而 // C 标识了程序的后半部分(也就是将来的部分)。前半部分立刻执行,然后是一段时间不确定的停顿。在未来的某个时刻,如果 Ajax 调用完成,程序就会从停下的位置继续执行后半部分。换句话说,回调函数包裹或者说封装了程序的延续( continuation)。
// A
setTimeout( function(){
// C
}, 1000 );
// B
一旦我们以回调函数的形式引入了单个 continuation,我们就容许了大脑工作方式和代码执行方式的分歧。一旦这两者出现分歧(这远不是这种分歧出现的唯一情况,我想你明白这一点!),我们就得面对这样一个无法逆转的事实:代码变得更加难以理解、追踪、调试和维护。
3. 顺序的大脑
3.1 执行与计划
虽然在执行的层级上,我们的大脑是以异步事件方式运作的,但我们的任务计划似乎还是以顺序、同步的方式进行:“我要先去商店,然后买点牛奶,然后去一下干洗店。”你会注意到,这个较高层级的思考(计划)过程看起来并不怎么符合异步事件方式。实际上,我们认真思考的时候很少是以事件的形式进行的。取而代之的是,我们按照顺序( A,然后 B,然后 C)仔细计划着,并且会假定有某种形式的临时阻塞来保证 B 会等待 A 完成, C 会等待 B 完成。
我们的思考方式是一步一步的,但是jsvascript从同步转换到异步之后,可用的工具(回调)却不是按照一步一步的方式来表达的。这就是为什么精确编写和追踪使用回调的异步 JavaScript 代码如此之难:因为这并不是我们大脑进行计划的运作方式。因为我们不知道什么时候会执行调用。
4. 信任问题
// A
ajax( "..", function(..){
// C
} );
// B
数情况下,它是某个第三方提供的工具。
我们把这称为控制反转(inversion of control),也就是把自己程序一部分的执行控制交给某个第三方
4.1 五个回掉的故事
看一下下面的故事:假设你是一名开发人员,为某个销售昂贵电视的网站建立商务结账系统。你已经做好了结账系统的各个界面。在最后一页,当用户点击“确定”就可以购买电视时,你需要调用(假设由某个分析追踪公司提供的)第三方函数以便跟踪这个交易。
你注意到,可能是为了提高性能,他们提供了一个看似用于异步追踪的工具,这意味着你需要传入一个回调函数。在传入的这个continuation 中,你需要提供向客户收费和展示感谢页面的最终代码。代码可能是这样:
analytics.trackPurchase( purchaseData, function(){
chargeCreditCard();
displayThankyouPage();
} );
当前的代码是这样,然后测试通过,一切正常。
(1) 过了一段时间,出现了一个问题。一位高级客户购买了一台电视,信用卡却被刷了五次,他很生气,这可以理解。也就是是说回掉函数执行了五次,当然客服已经道歉而且退款,但是作为开发人员,我们需要去处理这样的问题,并且老板肯定也会需要一个原因,为什么出现这样的原因。
然后作为开发人员的我们联系了第三方的人员,他们给出的理由是:开发了一些实验性的代码,在某种情况下,会在五秒钟内每秒重试一次传入的回调函数,然后才会因超时而失败。然后第三方也找到了出错的原因,并且保证以后不会有类似的情况发生。
然后和老板说了这个问题,但是老板对这种情况不满意,认为我们不能信任第三方的东西,于是需要我们找到某种方法保护结账代码,保证不会出现这种问题。
于是我们写下了下面的代码,引入latch(加上判断条件),这里我们其实就是创建了一个latch (条件)来处理对回调的多个并发调用。
var tracked = false;
analytics.trackPurchase( purchaseData, function(){
if (!tracked) {
tracked = true;
chargeCreditCard();
displayThankyouPage();
}
} );
(2). 但是,后来有一个QA 工程师问道:“如果他们根本不调用这个回调怎么办?”哎呦!之前你们双方都没有想到这一点。
然后,你开始沿着这个兔子洞深挖下去,考虑着他们调用你的回调时所有可能的出错情况。这里粗略列出了你能想到的分析工具可能出错的情况:
• 调用回调过早(在追踪之前);
• 调用回调过晚(或没有调用);
• 调用回调的次数太少或太多(就像你遇到过的问题!);
• 没有把所需的环境/ 参数成功传给你的回调函数;
• 吞掉可能出现的错误或异常;
• ……
这感觉就像是一个麻烦列表,实际上它就是。你可能已经开始慢慢意识到,对于被传给你无法信任的工具的每个回调,你都将不得不创建大量的混乱逻辑。
4.2 不只是别人的代码
通过4.1的问题,我们需要思考这一点:你能够真正信任理论上(在自己的代码库中)你可以控制(第三方和自己的代码)的工具吗?不妨这样考虑:多数人都同意,至少在某种程度上我们应该在内部函数中构建一些防御性的输入参数检查,以便减少或阻止无法预料的问题。(1) 过分信任输入
function addNumbers(x,y) {
// +是可以重载的,通过类型转换,也可以是字符串连接
// 所以根据传入参数的不同,这个运算并不是严格安全的
return x + y;
}
addNumbers( 21, 21 ); // 42
addNumbers( 21, "21" ); // "2121"
(2) 针对不信任输入的防御性代码
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"
(3) 依旧安全但更有好一点
function addNumbers(x,y) {
// 确保输入为数字
x = Number( x );
y = Number( y );
// +安全进行数字相加
return x + y;
}
addNumbers( 21, 21 ); // 42
addNumbers( 21, "21" ); // 42
据此是不是可以推断出,对于异步函数回调的组成,我们应该要做同样的事情,而不只是针对外部代码,甚至是我们知道在我们自己控制下的代码?当然应该。
但是,回调并没有为我们提供任何东西来支持这一点。我们不得不自己构建全部的机制,而且通常为每个异步回调重复这样的工作最后都成了负担。
回调最大的问题是控制反转,它会导致信任链的完全断裂。如果你的代码中使用了回调,尤其是但也不限于使用第三方工具,而且你还没有应用某种逻辑来解决所有这些控制反转导致的信任问题,那你的代码现在已经有了bug,即使它们还没有给你造成损害。隐藏的bug 也是bug。
5. 省点回掉
(1). 分离回掉: 一个用于成功通知,一个用于失败通知
function success(data) {
console.log( data );
}
function failure(err) {
console.error( err );
}
ajax( "http://some.url.1", success, failure );
在这种设计下,API的出错处理函数failure常常是可选的,如果没有提供的话,就假定这个错误可以吞掉。 Promise API使用的就是这种分离回掉设计。
(2) error-first
也称为Node风格,因为几乎所有Node.js的api都采用这种风格。其中回调的第一个参数保留用作错误对象(如果有的话)。如果成功的话,这个参数就会被清空/ 置假(后续的参数就是成功数据)。不过,如果产生了错误结果,那么第一个参数就会被置起/ 置真(通常就不会再传递其他结果):function response(err,data) {
// 出错?
if (err) {
console.error( err );
}
// 否则认为成功
else {
console.log( data );
}
}
ajax( "http://some.url.1", response );
(3) 在看如果回掉函数没有调用的情况
代码使用了一个timeouttify函数进行检测,如果超过500ms就会控制台上打出error "Timeout!"
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 );
}
};
}
function foo(err,data) {
if (err) {
console.error( err );
}
else {
console.log( data );
}
}
ajax( "http://some.url.1", timeoutify( foo, 500 ) );
(4) 回调过早
在特定应用的术语中,这可能实际上是指在某个关键任务完成之前调用回调。但是更通用地来说,对于既可能在现在(同步)也可能在将来(异步)调用你的回调的工具来说,这个问题是明显的。
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(..)调用的参数中
[this].concat( [].slice.call( arguments ) )
);
}
// 已经是异步
else {
// 调用原来的函数
orig_fn.apply( this, arguments );
}
};
}
function result(data) {
console.log( a );
}
var a = 0;
asyncify(result)();
a++;
看一下这个代码,如果回调函数是正常调用回来的,那么时间上最早也是在settimeout函数执行之后执行的,因为settimeout和函数回调都是在未来的时间调度上执行的,此时intv的值为null,会执行else中的函数,也就是正常的函数回调执行。如果回调函数执行过早,那么此时intv为1,然后会执行if中的函数,此时会在当前函数的作用域中对fn函数进行赋值,然后等到下个事件队列的时候执行,此时就会有settimeout形成一个异步的事件队列执行。
着 a++ 有机会在 result(..) 之前运行。