回撥地獄-編寫非同步JavaScript指南

MaxWang發表於2019-02-16

什麼是“回撥地獄”?

非同步Javascript程式碼,或者說使用callback的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,第一行程式碼執行結束後,才會開始執行第二行程式碼,按照這種模式一直到執行到當前檔案中最後一行程式碼。隨著你學習深入,你會發現JavaScript跟他們是不一樣的。

什麼是回撥(callback)?

某種使用JavaScript函式的慣例用法的名字叫做回撥。JavaScript語言中沒有一個叫“回撥”的東西,它僅僅是一個慣例用法的名字。大多數函式會立刻返回執行結果,使用回撥的函式通常會經過一段時間後才輸出結果。名詞“非同步”,簡稱“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函式,當下載結束,回撥會被呼叫。如果下載成功,傳入photo給回撥;下載失敗,傳入error給回撥。

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

人們理解回撥的最大障礙在於理解一個程式的執行順序。在上面的例子中,發生了三件事情。

  1. 宣告handlePhoto函式
  2. downloadPhoto函式被呼叫並且傳入了handlePhoto最為它的回撥
  3. 列印出Download started

請大家注意,起初handlePhoto函式僅僅是被建立並被作為回撥傳遞給了downloadPhoto,它還沒有被呼叫。它會等待downloadPhoto函式完成了它的任務才會執行。這可能需要很長一段時間(取決於網速的快慢)。

這個例子意在闡明兩個重要的概念:

  1. handlePhoto回撥只是一個存放將來進行的操作的方式
  2. 事情發生的順序並不是直觀上看到的從上到下,它會當某些事情完成後再跳回來執行。

怎樣解決“回撥地獄”問題?

糟糕的編碼習慣造成了回撥地獄。幸運的是,編寫優雅的程式碼不是那麼難!

你只需要遵循三大原則

1. 減少巢狀層數(Keep your code shallow)

下面是一堆亂糟糟的程式碼,使用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 = 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 = err
    statusMessage.value = body
  })
}

如你所見,給匿名函式一個名字是多麼簡單,而且好處立竿見影:

  • 起一個一望便知其函式功能的名字讓程式碼更易讀
  • 當丟擲異常時,你可以在stacktrace裡看到實際出異常的函式名字,而不是”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
}

請大家注意,函式宣告在程式的底部,但是我們在函式宣告之前就可以呼叫它。這是函式提升的作用。

2.模組化(Modularize)

任何人都有有能力建立模組,這點非常重要。Isaac Schlueter(NodeJS專案成員)說過“寫出一些小模組,每個模組只做一件事情,然後把他們組合起來放入其他的模組做一個複雜的事情。只要你不想陷入回撥地獄,你就不會落入那般田地。”

讓我們把上面的例子修改一下,改為一個模組。

下面是一個名為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模組化的用法。現在已經有了 formuploader.js 檔案,我們只需要引入它並使用它。請看下面的程式碼:

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

我們的應用只有兩行程式碼並且還有以下好處:

  1. 方便新開發人員理解你的程式碼 — 他們不需要費盡力氣讀完formuploader函式的全部程式碼
  2. formuploader可以在其他地方複用

3.處理每一個異常(Handle every single error)

有三種不同型別的異常:語法異常,執行時異常和平臺異常。語法異常通常由開發人員在第一次解釋程式碼時捕獲,執行時異常通常在程式碼執行過程中因為bug觸發,平臺異常通常由於沒有檔案的許可權,硬碟錯誤,無網路連結等問題造成。這一部分主要來處理最後一種異常:平臺異常。

前兩個大原則意在提高程式碼可讀性,但是第三個原則意在提高程式碼的穩定性。在你與回撥打交道的時候,你通常要處理髮送請求,等待返回或者放棄請求等任務。任何有經驗的開發人員都會告訴你,你從來不知道哪裡回出現問題。所以你有必要提前準備好,異常總是會發生。

把回撥函式的第一個引數設定為error物件,是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
 }

把第一個引數設為error物件是一個約定俗成的慣例,提醒你記得去處理異常。如果它是第二個引數,你更容易把它忽略掉。

總結

  • 不要巢狀使用函式。給每個函式命名並把他們放在你程式碼的頂層
  • 利用函式提升。先使用後宣告。
  • 處理每一個異常
  • 編寫可以複用的函式,並把他們封裝成一個模組

相關文章