回调地狱 是什么?
in 前端 with 0 comment

回调地狱 是什么?

in 前端 with 0 comment

回调地狱 是什么?

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)

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

var photo = downloadPhoto('https://coolcats.com/cat.gif')

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

downloadPhoto('https://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函数完成其下载任务后执行,而下载时间则取决于网络状况。

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

怎样解决回调地狱?

回调地狱主要源于糟糕的代码风格,幸而培养良好的编程习惯并不是一件难事。

你只需要遵循以下三条金律:

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

var form = document.querySelector('form')
form.onsubmit = function (submitEvent) {
  var name = document.querySelector('input').value
  request({
    uri: "https://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
  })
}

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

var form = document.querySelector('form')
form.onsubmit = function formSubmit (submitEvent) {
  var name = document.querySelector('input').value
  request({
    uri: "https://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: "https://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: "https://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)
   
}

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

总结

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

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

Promise 、Generator、ES6等

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

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

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

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

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

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

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

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

Responses