翻译自http://callbackhell.com

“回调地狱”是什么

异步JavaScript,或者使用回调函数的JavaScript,很难能够写出结构比较好的代码。大量的代码都会像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fs.readdir(source, function ( err, files ) {
if ( err ) {
console.log('Error finding files: ' + err);
}
else {
files.forEach(function ( filename, fileIndex ) {
console.log(filename);
gm(source + filename).size(function ( err, values ) {
if ( err ) {
console.log('Error identifying file size: ' + err);
}
else {
console.log(filename + ' : ' + values);
aspect = (values.width / values.height);
widths.forEach(function ( width, widthIndex ) {
height = Math.round(width / aspect);
console.log('resizing ' + filename + 'to ' + height + 'x' + height);
this.resize(width, height).write(dest + 'w' + width + '_' + filename, function ( err ) {
if ( err ) console.log('Error writing file: ' + err);
});
}.bind(this));
}
});
});
}
});

看看这些使用})结尾的代码的结构。这样的代码就被“亲切的”成为回调地狱。

由于人们在写JavaScript的时候,在视觉上,代码是从上到下执行的,所以,就造成了回调地狱。大多数人都会犯这个错。在其它的语言(比如C,Ruby或者Python)中,无论发生什么,第一行代码都会在第二行代码开始运行前就结束。在文件上也是这样。但是,正如你所学的一样,JavaScript是与众不同的。

回调函数是什么

回调只是人们在使用JavaScript的函数的时候的一个约定的名称。在JavaScript中并没有专门的一个东西叫做回调。与其他的立即返回值的函数不同,回调函数在返回一个值之前,需要花一些时间。异步这个词语的意思就是在未来发生,或者说不是马上发生。通常,只有在做一些I/O操作的时候会使用回调函数,比如下载,读取文件,与数据库交互等。

当你使用一个普通的函数的时候,你可以使用它的返回值:

1
2
3
var result = multiplyTwoNumbers(5, 10);
console.log(result);
// 将输出50

然而,异步函数或者使用回调函数不会立即返回值。

1
2
var photo = downloadPhoto('http://coolcats.com/cat.gif')
// photo is 'undefined'!

在上面这个例子中,cat.gif可能需要很长的一段时间来下载,而且,在下载完成之前你又不想让你的函数暂停运行。

所以,你把下载完成后需要执行的代码放在一个函数里。这个函数就是回调函数。把这个函数传入downloadPhoto函数,在下载完成之后,就会调用你的回调函数了,传入你的photo变量(当有错误发生的时候,就传入error)。

1
2
3
4
5
6
7
8
9
10
11
12
downloadPhoto('http://coolcats.com/cat.gif', handlePhoto);
function handlePhoto (error, photo) {
if (error) {
console.error('Download error!', error);
}
else {
console.log('Download finished', photo);
}
}
console.log('Download started');

人们在理解回调函数的时候,最大的障碍就是理解程序执行的顺序。在上面的例子中,有三件主要的事情发生。第一,声明handlePhoto,然后调用downloadPhoto并传入handlePhoto作为他的回调函数,然后最后输出Download started

但是,请注意,handlePhoto还没有被调用,他只是被创建了然后被当作回调函数传入了进去。在downloadPhoto完成他的任务之前,他是不会运行的。而downloadPhoto这个任务,可能需要很长的一段时间,取决于网速的快慢。

这个例子主要说明两点概念:

1、handlePhoto函数只是存储在未来执行的一些东西的方式;

2、事件发生的顺序不是从上到下的,他会跳过未执行完成的事件;

怎么避免回调地狱

导致回调地狱的主要原因就是很差的代码实践。幸好,写比较好的代码不是那么难。

你只需要按照下面三条规则就行了:

1、代码结构的嵌套不要太深

下面是使用browser-request来向服务器发起Ajax请求的一段比较凌乱的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var form = document.querySelector('form');
form.onsubmit = function ( submitEvent ) {
var name = document.querySelector('input').value;
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, function ( err, response, body ) {
var statusMessage = document.querySelector('.status');
if ( err ) {
return statusMessage.value = err;
}
statusMessage.value = body
});
};

这段代码有两个匿名函数。我们先给它们命名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var form = document.querySelector('form');
form.onsubmit = function formSubmit ( submitEvent ) {
var name = document.querySelector('input').value;
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, function postResponse ( err, response, body ) {
var statusMessage = document.querySelector('.status');
if ( err ) {
}
statusMessage.value = body
});
};

正如你所看到的,命名函数很简单并且你马上就能看到好处:

1、描述性的函数名可以让代码的可读性更好;

2、当有异常发生的时候,你可以从函数栈中获取是哪个函数出错了而不是得到一个匿名函数;

3、可以允许你通过函数名来改变他们的引用;

现在,我们可以把这些函数移动到程序的顶部了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
document.querySelector('form').onsubmit = formSubmit;
function formSubmit ( submitEvent ) {
var name = document.querySelector('input').value;
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, postResponse);
}
function postResponse ( err, response, body ) {
var statusMessage = document.querySelector('.status');
if ( err ) {
return statusMessage.value = err;
}
statusMessage.value = body;
}

注意,这里的函数声明是在文件的底部,当然,由于变量提升(Hoisting),我们依然可以使用。

2、模块化

这是最重要的部分:每个人都有能力创建模块。引用Node.js项目的Isaac Schlueter(Node.js)的一句话就是:“每做一件事就写一个小模块,最终汇集到其它模块里做一件大事。如果你不去故意这么做的话,你就不会遇到回调地狱。”

让我们把上面例子中的代码抽离出来,然后把他分离成一些文件模块。我会展示一个在浏览器端和服务器端都能运作的模块的样子。

下面这个叫做formuploader.js的文件包含了我们之前的两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports.submit = formSubmit;
function formSubmit( submitEvent ) {
var name = document.querySelector('input').value;
request({
uri: "http://example.com/upload",
body: name,
method: "POST"
}, postResponse);
}
function postResponse( err, response, body) {
var statusMessage = document.querySelector('.status');
if (err) return statusMessage.value = err;
statusMessage.value = body;
}

module.exports是Node.js的模块系统的一部分。我十分喜欢这种模块的方式,在任何地方都能运行,不需要很复杂的配置文件。

现在我们创建了这个文件,并且引入了页面中。我们只需要使用require来引入就可以使用了。现在我们的代码长这个样子:

1
2
var formUploader = require('formuploader');
document.querySelector('form').onsubmit = formUploader.submit;

现在我们的应用只有两行代码,还有下面这些好处:

1、对于新手程序员来说很容易理解。他们不需要把formuploader的函数全部从上到下的阅读完;

2、formuploader可以在其他的地方使用,无需再写一遍。还能够很容易的分享到Github和npm;

单独处理每个错误

错误的类型有很多种:

  • 语法错误,通常是由程序员第一次运行程序时引起的
  • 运行时错误,程序运行起来但是有bug,程序就被终止了
  • 环境错误,比如没有权限,硬盘出错,网络出问题等

这部分的内容主要着重于最后一种类型的错误。

前两个法则是关于你的代码的可读性的,这一条法则是关于你的代码的稳定性的。你会很自然的处理一些将要执行的任务,停止运行然后在后台处理一些任务,最后成功或者失败。有经验的程序员会告诉你,你可能永远都不会知道错误的发生,所以你必须在错误发生之前就计划好。

现在最流行的使用回调函数处理错误的方式就是采用Node.js的方式,即函数的第一个参数总是存储错误对象。

1
2
3
4
5
6
7
8
9
10
11
var fs = require('fs');
fs.readFile('/Does/not/exist', handleFile);
function handleFile( error, file ) {
if ( error ) {
return
}
console.error('Uhoh, there was an error', error);
// otherwise, continue on and use `file` in your code
}

让第一个参数为error只是一个约定,为了让你记得要处理错误。如果把这个作为第二个参数的话,你很可能就会写成这样function handleFile( file ) {},然后,很容易的就忽略了处理错误。

也可以配置代码审查工具来帮你记得要处理错误。最简单的就是使用叫做standard的代码审查工具。你需要做的只是在你的项目运行standard命令就可以了。然后,他就会为你展示你没有处理错误的回调函数。

总结

1、不要使用嵌套的函数。为这些函数命名,并且放到程序的顶部;

2、在对你有利的情况下,使用变量提升的特性把函数放到折叠块的下面;

3、在你的每个回调函数里都单独处理每个错误。可以使用代码审查器来帮你完成这个工作;

4、创建可复用的函数并且把他们放到模块里来提升你的代码的可读性。把你的代码这样拆分也利于你处理错误,写测试,强迫自己为自己的代码创建一个稳定的还带有文档的公共API,也利于重构。

避免回调地狱最重要的一方面就是要把函数移出来,以便于新人能够很快的理解程序流,而不用花过多的时间去猜想写这个程序的人到底想要做什么。

一开始你可以先将函数移到程序底部,然后,可以慢慢的将它们放到另一个文件,使用require来引入,最终把它们做成一个完整的模块。

关于创建模块,有下面几条推荐的法则:

  • 将重复使用的代码放到函数里
  • 当你的函数的代码量很多的时候,把它们放到另一个文件里,使用module.exports来创建模块,使用require来引入模块
  • 如果你有一些代码可以在多个工程中使用,那么,把这些代码抽离出来,为他们写一个README,测试用例,以及package.json,然后把这个代码提交到Github或者npm。这个方法的好处太多了
  • 一个模块不会太大,并且会专注于解决一个问题
  • 一个单独的模块文件最多不应该超过150行代码
  • 一个模块不应该有多于一层的文件夹的嵌套,如果有的话,那么就表示这个模块处理的事情太多了
  • 询问你所认识的有经验的程序员一个好的模块应该是什么样子,知道你心中有数了。如果一个模块需要好多分钟才能够理解,那么这不会是一个好的模块。

关于promises/generators/ES6等

在寻找更多高级的解决方法之前,要记住,回调函数只是JavaScript的一个很基本的部分(因为他们就是函数),而且,在你准备学习更高级的语言功能之前,你应该学会如何阅读和书写这些代码。因为,这些都是建立在你理解回调函数的基础之上的。如果你还不能写出可维护的代码,那么,加油吧!

如果你实在想要你的异步代码能够从上读到下,你可以试试下面这些神奇的东西。注意,这些东西可能会导致性能或者跨平台的问题,所以,在使用之前你要好好研究下。

Promises
尽管这是一种从上到下执行的方法,但是,他仍然是用来书写异步代码的方式。并且,由于受支持的try/catch的处理错误的方式,它能够处理更多类型的错误。

Generators
generators可以让你暂停执行单独的某个函数而不用打断整个程序的运行,当然,代价就是代码更加复杂。你的代码能够很明显的从上到下执行。

异步函数(Async Functions)
异步函数是ES7里提出的一个功能,在某种程度上,会用更高级的语法来使用promises和generators。如果感兴趣的话可以去看看。

在我的异步代码中,我90%会用到回调函数,当代码越来越复杂的时候,我会引入诸如run-parallel或者run-series的东西。我不认为回调函数和promises以及其他任何的东西有什么不同,保持代码简洁是最好的,不要嵌套,分解成小模块。

无论你是用的什么方法,都要处理每个错误,保持代码简洁。

记住,只有你自己能够避免回调地狱。