什麼是“回撥地獄”?
非同步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`)
人們理解回撥的最大障礙在於理解一個程式的執行順序。在上面的例子中,發生了三件事情。
- 宣告
handlePhoto
函式 -
downloadPhoto
函式被呼叫並且傳入了handlePhoto
最為它的回撥 - 列印出Download started。
請大家注意,起初handlePhoto
函式僅僅是被建立並被作為回撥傳遞給了downloadPhoto
,它還沒有被呼叫。它會等待downloadPhoto
函式完成了它的任務才會執行。這可能需要很長一段時間(取決於網速的快慢)。
這個例子意在闡明兩個重要的概念:
-
handlePhoto
回撥只是一個存放將來進行的操作的方式 - 事情發生的順序並不是直觀上看到的從上到下,它會當某些事情完成後再跳回來執行。
怎樣解決“回撥地獄”問題?
糟糕的編碼習慣造成了回撥地獄。幸運的是,編寫優雅的程式碼不是那麼難!
你只需要遵循三大原則:
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
我們的應用只有兩行程式碼並且還有以下好處:
- 方便新開發人員理解你的程式碼 — 他們不需要費盡力氣讀完
formuploader
函式的全部程式碼 -
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
物件是一個約定俗成的慣例,提醒你記得去處理異常。如果它是第二個引數,你更容易把它忽略掉。
總結
- 不要巢狀使用函式。給每個函式命名並把他們放在你程式碼的頂層
- 利用函式提升。先使用後宣告。
- 處理每一個異常
- 編寫可以複用的函式,並把他們封裝成一個模組