逃离回调地狱

原文链接:https://www.sitepoint.com/saved-from-callback-hell/

Devil standing over offce worker with receivers hanging everywhere

这篇文章已经由Mallory van Achterberg, Dan Prince 和 Vildan Softic 三位同行审核,在此感谢所有同行为创造优秀网站做出的努力。

作者的其他文章

  1. Quick Tip: How to Throttle Scroll Events
  2. Getting Started with the Raspberry Pi GPIO Pins in Node.js

在实际开发中,回调地狱是真实存在的,开发者将它视为洪水猛兽,甚至到了想逃离它们的地步。而JavaScript的灵活性却对此爱莫能助。从表面上看,回调似乎并不完美,所以我们总想找到其替代者。

好消息是,只需要简单的几步即可逃离回调地狱。我觉得,在你的代码中不使用回调就好比壮士断腕。回调函数是JavaScript的核心之一,也是它的精粹所在。如果你用其他方式来代替回调,那也只是换汤不换药,无济于事。

有朋友告诉我,回调这东西一无是处,并劝我去学习更好的编程语言。那么,回调真的如此吗?

在JavaScript中使用回调有它自身的闪光点。如果仅仅是因为回调的话,我们没有理由不用JavaScript。

让我们来研究一下合理的编程方式是怎么处理回调的。我的偏好是坚持SOLID原则,让我们来一探究竟。

回调地狱是什么

我知道你可能在想,回调地狱到底是什么,为什么我应该关注它呢?在JavaScript中,回调的角色是充当一个委托函数,这个委托可能会在未来的任一时刻执行。在JavaScript中,当接收函数调用回调时发生委托。接收函数可能在执行过程中的任意时刻调用回调。

简而言之,回调是作为参数传递给另一个函数的函数。什么时候调用回调是由接收函数决定的,因此回调并不会立即执行,以下是代码示例。

1
2
3
4
5
6
7
8
9
10
function receiver(fn) {
return fn();
}

function callback() {
return 'foobar';
}

var callbackResponse = receiver(callback);
// callbackResponse == 'foobar'

如果你曾写过ajax请求,那就肯定遇到过回调函数。因为我们无法知道回调将在什么时候执行,所以异步代码使用这种方式。

回调的问题在于依赖于其他回调的异步代码。下面我会使用setTimeout来模拟带有回调的异步调用。

代码已经放在Github,可以自由查看拷贝。后文中大部分代码片段都在这个库里面,你可以拷贝下来自己运行。

看吧,下面就是回调深渊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setTimeout(function (name) {
var catList = name + ',';

setTimeout(function (name) {
catList += name + ',';

setTimeout(function (name) {
catList += name + ',';

setTimeout(function (name) {
catList += name + ',';

setTimeout(function (name) {
catList += name;

console.log(catList);
}, 1, 'Lion');

}, 1, 'Snow Leopard');

}, 1, 'Lynx');

}, 1, 'Jaguar');

}, 1, 'Panther');

从上面的代码可以看到,我们向setTimeout中传递了一个将在1ms后执行的回调函数。最后一个参数用来给回调函数传递数据。这和期望从服务器返回name参数的ajax请求类似。

在MDN上,对setTimeout有一个很详细的介绍

我通过这段异步代码,收集了一组猫的名字列表。每个回调都会返回给我一个猫的名字,然后我将其添加到列表中。我试着更合理地实现它,但是,鉴于JavaScript函数的灵活性,这简直就是噩梦。

匿名函数

在前面的例子中,你可能注意到了匿名函数的使用。匿名函数是一个用于赋给一个变量或作为参数传递给其他函数的未命名函数表达式。

在有些编码标准中不建议使用匿名函数。最好是为它们命名,使用function getCat(name){}而不是function (name){}。使用具名函数会让你的程序更加清晰易读。匿名函数很容易定义使用,但是会让你直坠回调地狱,当你走上这条不归路时,最好停下来反思。

有个打破回调的简单方式是使用函数声明:

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
27
28
29
30
31
32
33
setTimeout(getPanther, 1, 'Panther');

var catList = '';

function getPanther(name) {
catList = name + ',';

setTimeout(getJaguar, 1, 'Jaguar');
}

function getJaguar(name) {
catList += name + ',';

setTimeout(getLynx, 1, 'Lynx');
}

function getLynx(name) {
catList += name + ',';

setTimeout(getSnowLeopard, 1, 'Snow Leopard');
}

function getSnowLeopard(name) {
catList += name + ',';

setTimeout(getLion, 1, 'Lion');
}

function getLion(name) {
catList += name;

console.log(catList);
}

在仓库中没有这段代码,但是其改进在这个提交上面。

每个函数都有它自己的声明,其优点是我们可以不再使用金字塔式的回调。每个函数都被隔离,专注于自己的任务。现在每个函数都有理由做出改变,这说明我们的研究方向是正确的。例如像getPanther()这样被当做参数传入的函数,JavaScript不关心你是如何创建回调的。然而,这样写又有什么值得改进的地方呢?

为了了解更加透彻,可以看这篇文章SitePoint article on Function Expressions vs Function Declarations

不过,有个缺点是每个函数的作用域不再限定在其回调内。每个函数被放到全局作用域下,而不是作为一个闭包回调。所以,这就是为什么catList申明在全局作用域下,是为了保证回调函数能访问到列表。有时候,使用全局作用域并不是一个好的方案。因为它在将猫名字添加到列表的同时调用下一个回调,这也造成了代码冗余。

这些代码总有一些回调地狱的影子,所以为了处理回调地狱常常需要付出巨大的努力而且开始时可能还会觉得事倍功半,无济于事。那有没有更好的编码方式呢?

依赖倒置

依赖倒置原则告诉我们,应该对编码进行抽象,而不是关注其具体细节。其核心思想是将一个大的问题分解成小的依赖。这些依赖变得彼此独立,与实现细节无关。

SOLID原则建议:

1
当遵循这项原则时,传统的依赖关系建立从高层次/策略设定模块到底层的依赖模块倒置,从而使高层次的模块独立于底层模块的实现细节。

如何理解这段话呢?好消息是通过为参数分配一个回调,猜猜会发生什么?实际上你已经这样做了,至少在一定程度上,为了解耦,将回调看做依赖。这种依赖变成一个契约,从这点就表明你已经开始做SOLID编程了。

获得回调自由的一种方式是创建一个契约:

1
`fn(catList);`

这定义了我打算用回调来实现的功能,它需要跟踪一个单一的参数,也就是上文中猫的列表。

此依赖关系现在可以通过参数传递:

1
2
3
4
5
6
7
function buildFerociousCats(list, returnValue, fn) {
setTimeout(function asyncCall(data) {
var catList = list === '' ? data : list + ',' + data;

fn(catList);
}, 1, returnValue);
}

注意,函数表达式asyncCall的作用域在buildFerociousCats下。当与异步编程中的回调配合时,这种写法是很有效果的。该契约异步执行,并获取其所需参数。由于其解耦性,该组合获得了所需的回调自由。JavaScript的灵活性让编码更明了,充分发挥了自身的优势。

接下来的写法不言而喻,以下是其中一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
buildFerociousCats('', 'Panther', getJaguar);

function getJaguar(list) {
buildFerociousCats(list, 'Jaguar', getLynx);
}

function getLynx(list) {
buildFerociousCats(list, 'Lynx', getSnowLeopard);
}

function getSnowLeopard(list) {
buildFerociousCats(list, 'Snow Leopard', getLion);
}

function getLion(list) {
buildFerociousCats(list, 'Lion', printList);
}

function printList(list) {
console.log(list);
}

注意,以上片段没有重复的代码。在不使用全局变量的情况下,回调可以追踪自己的状态。例如,回调getLion可以与契约之后的任何东西相关联。这是一个需要猫的列表作为参数的回调。这段代码已经上传至GitHub

多态回调

这是什么呢?让我们更疯狂一点。如果我们想将行为从创建逗号分隔列表更改为管道分隔呢?我发现的一个问题是buildFerociousCats与实现细节相关联。可以使用list + ',' + data做到这一点。

简单来说就是回调行为的多态性。其原则仍然是:像契约一样处理回调,并使其实现互不关联。一旦回调提升到抽象,特定的细节可以随意改变。

多态打开了在JavaScript中复用代码的新大门。将多态回调想象成定义严格契约的一种方式,同时允许其有足够的自由,这时候细节就不再重要。值得注意的是我们仍然在讨论依赖倒置。多态回调只是一个花哨的名字,指出了进一步采取该想法的一种方式。

我们可以定义一个使用listdata参数的契约:

cat.delimiter(cat.list, data);

然后使用buildFerociousCats并做一些微小的调整:

1
2
3
4
5
6
7
function buildFerociousCats(cat, returnValue, next) {
setTimeout(function asyncCall(data) {
var catList = cat.delimiter(cat.list, data);

next({ list: catList, delimiter: cat.delimiter });
}, 1, returnValue);
}

现在JavaScript对象cat封装了list数据和delimiter方法。回调链的异步回调,在以前我们称之为fn。注意,我们可以随意使用JavaScript对象将参数分组。cat对象同时需要listdelimiter两个特定的键。此JavaScript对象现在是契约的一部分,其余代码保持不变。

我们可以这样来实现:

1
2
buildFerociousCats({ list: '', delimiter: commaDelimiter }, 'Panther', getJaguar);
buildFerociousCats({ list: '', delimiter: pipeDelimiter }, 'Panther', getJaguar);

回调被交换。只要契约得到满足,细节就不再重要。我们可以轻松地改变其行为。现在回调被看成是一种依赖,被反转成一个高级别的契约。这个想法采用我们已经知道的回调,并将其提高到一个新的水平。 通过减少回调到契约,它提升了抽象和解耦软件模块。

激进的是,从独立模块自然过渡到单元测试。delimiter契约是一个纯函数。这意味着在多次输入的情况下,每次都可以得到相同的输出。这个级别的可测试性提高了解决方案的可行性。毕竟,模块化的独立赋予了自我评估的权利。

pipe delimiter的有效单元测试可能是这样的:

1
2
3
4
5
6
7
describe('A pipe delimiter', function () {
it('adds a pipe in the list', function () {
var list = pipeDelimiter('Cat', 'Cat');

assert.equal(list, 'Cat|Cat');
});
});

你可以随意想象一下其实现细节是什么样子,同时也可以在github上查看代码

总结

通过其细节才能掌握JavaScript中的回调。我希望你能看到JavaScript函数的微妙变化。当我们缺乏基础知识时,可能会误解回调函数。一旦理清JavaScript函数,SOLID原则也会很好理解。实现SOLID编程需要有强大的基础知识,语言本身灵活性的体现在于程序员如何使用它。

我最欣慰的是JavaScript培养了良好的编程能力。对基础知识和细节的把握会使你在一门语言上造诣更高。这种方式在回调函数中显得尤为重要。一些不起眼的细节往往会提升你的编程技能。