首页 > 代码库 > [译] 回调地狱——JavaScript异步编程指南

[译] 回调地狱——JavaScript异步编程指南

原文:Callback Hell

 

什么是 “回调地狱”?

在 JavaScript 中,我们经常通过回调来实现异步逻辑,一旦嵌套层级多了,代码结构就容易变得很不直观,最后看起来像这样:

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)中,会确保第 1 行代码已执行完成,并且文件也已加载完毕之后,才开始执行第 2 行代码。但如你所知,JavaScript 并非如此。

 

什么是回调?

回调(callbacks)只是函数的一种用法的通用称呼,在 JavaScript 中,并没有一个特定的东西叫 “回调”,它仅仅是一个约定好的称呼。

不同于那些立即返回结果的函数,回调函数需要一定的时间来获得结果。

译者注:根据 wiki 上对 callback 的描述,回调分为同步回调和异步回调,这里应该是特指异步回调。详情见:Callback (computer programming)。 

This execution may be immediate as in a synchronous callback, or it might happen at a later time as in an asynchronous callback.

“asynchronous(异步)” ,也叫 “async”,表示 “需要耗费一定的时间” 或者 “发生在未来,而不是现在”。

在处理 I/O 时,通常会使用到回调,如下载、读取文件、与数据库交互等。

调用一个普通的函数时,我们可以直接使用其返回值:

var result = multiplyTwoNumbers(5, 10)
console.log(result)
// 控制台打印出 50

而使用回调的异步函数,不会立即返回结果:

var photo = downloadPhoto(‘http://coolcats.com/cat.gif‘)
// photo 未定义!

下载 gif 文件可能需要很长的时间,而你肯定不希望程序在下载过程中处于暂停(即 “block(阻塞)”)状态。

你可以把下载完成后需要执行的操作存放在一个函数中,这就是回调函数。把它传递给  downloadPhoto ,当下载完成时,downloadPhoto 会执行这个回调函数(callback,call you back later),并把 error(错误信息)或 photo(图片数据)传递给它。

downloadPhoto(‘http://coolcats.com/cat.gif‘, handlePhoto)

function handlePhoto (error, photo) {
  if (error) console.error(‘下载出错!‘, error)
  else console.log(‘下载完成‘, photo)
}

console.log(‘开始下载‘)

理解回调最大的难点,在于搞清楚程序运行时代码的执行顺序。在这个例子中主要有三个关键点:首先声明了  handlePhoto  函数,然后调用了  downloadPhoto  函数并将  handlePhoto  作为回调函数传递给它,最后 “开始下载” 被打印出来。

注意此时  handlePhoto  还没有被调用,只是创建并作为回调函数传递给了  downloadPhoto ,在  downloadPhoto  完成任务后才会被执行,这取决于网速有多快。

这个例子想要传达两个重要的概念:

  • 回调函数  handlePhoto  只是存放操作的一种方式,可以让这些操作在一段时间后(满足了特定条件)才被执行。
  • 代码执行的顺序不是按照视觉上的自上而下,而是基于逻辑的完成时机跳跃式触发。

译者注:关于异步回调的执行原理,可以参考 [译] JavaScript 的事件循环

 

如何处理回调地狱?

回调地狱的产生源于开发经验的不足,幸运的是想要写好这些代码并不困难。你只要遵循下面三个原则:

 

1、避免函数嵌套

下面是一段杂乱的代码,使用 browser-request 向服务器发起一个 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 =http://www.mamicode.com/ err
    statusMessage.value = body
  })
}

代码中有两个匿名函数,来给它们起个名字吧!

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 =http://www.mamicode.com/ 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 =http://www.mamicode.com/ err
  statusMessage.value = body
}

注意这里把函数声明移到了文件的底部,这得益于函数声明提升(function hoisting)。

 

2、模块化

这是最重要的一点:人人皆可搞模块(即代码库)。

Anyone is capable of creating modules (aka libraries)

引用(node.js 项目的)Isaac Schlueter 的话:“编写职责单一的小模块,组装起来以实现更大的功能。回调地狱你不去碰它就不会掉进去。”

Write small modules that each do one thing, and assemble them into other modules that do a bigger thing. You can‘t get into callback hell if you don‘t go there.

让我们从上面的代码中提取出样板代码,分割成两个文件,把它变成一个模块。我将展示一个模块模式,它既可用于浏览器,也可用于服务端。

新建一个文件叫  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 =http://www.mamicode.com/ err
  statusMessage.value = body
}

 module.exports  是 node.js 模块系统的一个用法,适用于 node、Electron 和使用 browserify 的浏览器。我非常喜欢这种模块化风格,因为它适用范围广、易于理解、而且不需要复杂的配置文件或脚本。

现在我们有了  formuploader.js (并且作为页面的一个外联脚本已加载完成),我们只需要引入(require)这个模块并使用它!

程序的具体代码如下:

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

程序仅仅只需要两行代码,而且还有以下好处:

  • 对于新的开发者更加容易理解——他们不用深陷于 “被迫通读全部  formuploader  函数”
  •  formuploader  可以用于其它地方而不需要复制代码,而且也更容易分享到 github 或 npm

 

3、处理每一个错误

错误有许多类型:语法错误(通常只要运行程序就能被捕获)、运行时错误(程序运行正常但存在一些 bug 会引起逻辑混乱)、平台错误(如无效的文件权限、硬件驱动失效、网络连接异常等)。这一部分主要针对最后一类错误。

前面两个原则可以让你的代码更具可读性,而这个原则,可以让你的代码更具稳定性。

回调函数被定义和分配后,会在后台执行,然后成功完成或者失败中止。任何有经验的开发者都会告诉你:你永远无法预测错误何时会发生,你只能假设它一定会发生。

在回调函数中处理错误的方式,最流行的是 Node.js 风格:回调函数第一个参数永远是 “error”。

 var fs = require(‘fs‘)

 fs.readFile(‘/Does/not/exist‘, handleFile)

 function handleFile (error, file) {
   if (error) return console.error(‘卧槽,出错了‘, error)
   // 正常,可以在代码中使用 `file` 了
 }

把第一个参数设置为  error ,是鼓励你记得处理错误的一个简单的约定。如果把它设置为第二个参数,你可能会把代码写成  function handleFile(file){} ,而忽略了错误处理。

编码规范检查工具(Code linters)也可以通过配置来帮助你记得处理回调错误。使用最简单的一个是 standard,你只需要在代码目录中执行  $ standard  命令,它就会把代码中没有处理错误的回调函数全部显示出来。

 

要点

(施工中……)

 

更多阅读

我的 回调更详细的介绍

nodeschool 上的教程

browserify-handbook 编写模块代码的示例

 

关于 promises/generators/ES6

(施工中……)

 

记住,只有你可以防止回调地狱和森林火灾

你可以在这个 github 上查看相关源码。

[译] 回调地狱——JavaScript异步编程指南