回调地狱Callback Hell

注意:
这是一篇意译文,里面不免夹杂了自己的理解和二次加工。
原文请点击这里,Callback Hell
你可以在 github上找到这篇文章的源代码。
原作者的GitHub地址,请点击这里,@maxogden

翻译初衷

作者的一些思想和方法,不代表就是最好或正确的;但很值得借鉴,比如模块化。虽然一早就知道要这么写程序,但对于像我这样的初学者而言,在实际码代码的过程中,往往会忽略掉这些思想以及技巧。翻译此文,以加深自己对于优良思想的印象,以及帮助养成良好的代码编写习惯。下面是译文:


JS异步编程指南

回调地狱 是什么?

JS异步编程,或使用大量回调函数时,其代码阅读起来晦涩难懂,并不直观。许多代码往往这样:

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))
        }
      })
    })
  }
})

这个形状像不像倒下的金字塔?你看到后面那一大堆})了吧?这就是让很多人熟知的回调地狱(Callback Hell)了。
代码的执行从顶部第一行开始,一直顺序执行到最后一行;JS程序员们则试图以一种显而易见的方式,在书写时呈现这种代码执行的顺序。这就是引起回调地狱的原因所在,而很多程序员恰恰犯了这种错误。其他的语言中,诸如C、Python和Ruby,程序员们期待的是在第二行语句执行之前,第一行的所有行为都要结束,然后一直这样执行到文件结尾。当然如你将要知晓得,JavaScript并不一样。

回调函数(Callback)是什么?

回调函数仅仅是JS函数使用方式中的一个概念的名字。JS中并没有什么特殊的东西叫回调函数,它只是一个概念,或者大家约定俗成的叫法。与大部分立即返回结果的函数不同,套用回调函数的函数会花费一定的时间来处理即将得到的结果。单词asynchronous异步回调处理,也被简称做async异步,就是指“花费一些时间”,或者“发生在将来,而不是此刻”的意思。回调函数通常只用来处理与I/O相关的事件,例如下载文件、文档读取、与数据库通信等。

当调用一个普通的函数时,你能及时的使用它的返回值。比如:

var result = multiplyTwoNumbers(5, 10) 
console.log(result)
// 50 gets printed out

然而在异步处理,使用回调函数时,你就不能立即使用函数的返回值。比如:

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

在这个例子中,gif图片的下载可能需要一定的时间;但是你又不想因等待图片下载,而中断程序运行。
取而代之的是,你将这段在下载行为完成之后要运行的代码,托管在一个函数中。这就是回调的概念。你把这个回调函数放在函数downloadPhoto内,当图片下载完毕再来运行它;显示图片,或者报一个错误。

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此时并没有执行;我们仅仅是定义了handlePhoto函数,并把它当做一个参数传入downloadPhoto函数中。但它会在downloadPhoto函数完成其下载任务后执行,而下载时间则取决于网络状况。

上述例子的目的在于说明两个重要的概念:

  • handlePhoto回调函数是一种代码延后执行的方式,目的在于一定时间之后进行相应的操作;
  • 代码执行的顺序并不是由上到下,而是根据任务完成情况在回调函数之间跳转。

怎样解决回调地狱?

回调地狱主要源于糟糕的代码风格,幸而培养良好的编程习惯并不是一件难事。
你只需要遵循以下三条金律:

1. 让代码更扁薄一些

下面是一段运行在浏览器上的JS代码,使用browser-request(译者注:一个JS库)来向服务器发起AJAX请求:

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
  })
}

上面代码中,有两个匿名函数(分别在第二、第八行)。让我们分别给他们起个名字,formSubmitpostResponse

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) return statusMessage.value = err
    statusMessage.value = body
  })
}

如你所见,给匿名函数起个名字如此简单;不仅如此,它还有不少立马见效的效果:

  • 得益于我们使用描述性的函数名,极大地增强了代码的可读性;
  • 你可以依据函数名称来追踪代码执行的过程,而不是仅仅看到一堆anonymous;
  • 同时也允许在别处声明函数,并通过函数名来引用它们。

下面我们就将函数声明放到程序的顶层:

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
}

需要注意的是,函数的function声明放在文件的末尾。这有赖于JS的一大特性,函数声明提升。

2. 模块化

这才是本篇最重要的部分:任何人都拥有编写模块(库)的能力!
借用Isaac Schlueter(Node.js项目核心开发者) 的一句话来讲,“编写一些小的模块,每一个模块独立完成某项小任务;然后把这些小模块拼装成一个能够完成更多任务的大模块。假如你这么做了,就可以避免走入回调地狱的境地。”
让我们开始做这件事情。我们仍然使用上面的示例代码,通过将其拆分到不同的文件中,让它变成我们理想中的模块。我会展示一种模块模式,这种模式可以用于浏览器端,或者用于服务器端(或者两者皆可)。
下面这个命名为formuploader.js的文件,包含有我们之前示例代码中的两个函数:

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模块系统中的一个事例,你可以在node, electron和引用了browserify的浏览器端使用它。我超级喜欢这种风格的模块,因为它可以用在每一个你想用到的地方;而且它非常容易理解,不需要复杂的配置文件或引用脚本。
现在我们已经有了formuploader.js模块(并且通过script标签引入到页面中),我们要做的就仅仅是请求引用和使用它。接下来就是我们应用程序特定代码部分的样子:

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

如此我们的应用程序就只有两行代码,并且有如下优点:

  • 便于新手理解 —— 新手们不必因为要通读formuploader.js模块而懵逼;
  • formuploader可以很好地移用到其他地方,而不需要反复的复制代码;当然,也便于在GitHub或是npm上分享。
3. 处理每一个单独的报错

错误类型有很多种:语法错误(往往在初次运行代码时出现);运行时错误(代码跑起来了,但一个bug导致结果一团糟);由诸如无效的文件权限引起的平台错误;硬件驱动挂掉;无网络连接等等。这一部分的内容,只是定位于后面的这类错误。
前两条金律意在提高代码的可读性,最后这条金律则意在提高代码的稳定性。当涉及到处理回调函数的时候,实际在处理一系列的任务;处理任务,跳出到后台运行,然后成功地完成或者因失败而终止。任何一个有经验的程序员都会告诉你,你永远无法判定这些错误信息会在什么时候跳出来。所以你必须在任何时刻都将可能的报错,放在你的考虑之内。
使用回调函数来处理报错的最流行方式,是将错误信息作为第一参数传入回调函数的Node.js风格。

 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
 }

将错误信息作为第一个参数传入回调函数,是一种简单易用的通常做法,有助于提醒你时刻注意可能的错误信息。而如果将其当做第二个参数,那你很有可能会把回调函数写成function handleFile (file) { }这样的形式,这样更容易忽略掉错误信息的处理。
代码检查器也可以通过相关配置,提醒你在使用回调函数时处理错误信息。其中最简单的一款,叫作standard。你需要做的仅仅是在相应的文件夹下运行$ standard命令;然后它就会将每一个未进行报错处理的回调函数为你显示出来。

总结

  1. 不要多层嵌套函数。将函数命名,并且放置在程序的顶层;
  2. 好好利用JS的函数声明提升这一特性,将函数放置在文件末尾;
  3. 处理好函数回调过程中的每一个可能的报错信息,可以通过检查器比如 standard来帮助你做这件事情;
  4. 编写可复用的函数并将其模块化,从而降低用于阅读、理解代码的消耗;将代码拆分成多个小组件,有利于处理错误信息、编写测试程序,也有利于你编写稳定的、文档化的API以及代码重构。

避免回调地狱最重要的一方面,应该是将函数抽离出来。这么做可以让整个程序流更便于阅读和理解,也让新接触该程序的人不必在乎所有的细枝末节而把握住程序真正的目的。

刚开始的话,你可以先试着如何将回调函数移到文件末尾;在做好这一步的基础上,再将这些函数转移到另一个文件中,并使用require('./photo-helpers.js')这样的语句来加载它们;最后,再将它们独立成单独的模块,并使用require('image-resize')这样的语句来将该模块引入。

在写模块的过程中,以下几条很好的建议以供参考:

  • 首先,将重复使用的代码,写成一个函数;
  • 当函数(或一系列相关函数)足够大时,将它们移入单独的文件。并使用module.exports语句,将它们作为模块接口暴露出来。你可以使用相关引用来加载它们;
  • 假若说你的代码可以跨项目移植使用,那就给它写一个单独的README文件吧,以及单独的测试和package.json文件。然后把它发布到GitHubnpm。这么做有太多好处了,在这里不能一一列举;
  • 一个好的模块,体量小的同时,能够专注于解决一个问题;
  • 模块中的单独文件,应当不多于大概150行的JS代码;
  • 模块内包含JS文件的文件夹,嵌套不应超过一层。如果超过一层文件夹嵌套,那该模块实现的功能可能太多了;
  • 向更多你认识的老资格程序员们请教,让他们为你展示优秀的模块直到你对这些模块有一个很好的理解。如果某个模块要耗费你好几分钟的时间去理解它做了什么,那这个模块可能并不是写的很好;

更多参考

读读我的另一篇文章,longer introduction to callbacks
或者试试nodeschool上的一些教程。
你也可以查看一下browserify-handbook,上面有些模块化代码的例子。

Promise 、Generator、ES6等

在了解更高级的解决方法之前,要牢记回调函数是JavaScript的基础部分(因为他们仅仅是函数而已)。在学习更多更高级的语法特性之前,你应当学会如何读写回调函数。因为JavaScript中更高级的知识,都有依赖于你对回调函数的理解。在这一方面多下些功夫,持续练习,直到你能够熟练地写出可维护的回调函数代码。

假如你确实想让你的异步代码能够由上到下顺畅地阅读,一些巧妙的方法你可以试一试。注意,这可能会涉及到性能问题和(或)跨平台运行的兼容性问题。所以,确保你可以自己搜索相关信息。

Promises是一种编写异步代码的方式。这种方式使代码运行起来仍然像是自顶而下,并且由于鼓励使用try/catch语句来处理报错,可以容纳更多类型的错误信息处理。

Generators可以保持整个程序运行的同时,“暂停”独立的函数,这让整个异步代码呈现出自上而下运行的形式。理解Generators,可能需要耗费一些时间和精力。你可以参考下watt 的一些例子。

Async functions异步函数是ES2017中将会提出的新特性,它会在不久的将来把Promises和Generators包装到更高阶的语法中。如果你对此感兴趣,可以自行搜索一下这方面的信息。

就我个人而言,机会90%的异步代码里面我都会用到回调函数。而且当代码过于复杂的时候,我也会使用一些工具诸如run-parallel 或是run-series来帮助我的工作(run-parallelrun-series是两个使一系列函数同时运行的框架——译者注)。但我并不认为回调函数本身,或者Promises或者其他什么,对我有多大的改变;最大的影响来自“使代码保持简洁”,而非函数层层嵌套或是将它们分拆到不同的模块。

无论你选择怎样的方法,时刻记住处理每一个错误信息,时刻提醒自己保持代码简洁

谨记,只有你自己能使你避免陷入回调地狱,能使你远离刀山火海。


2017-03-04: 初稿
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容