這個問題作者認為是所有從後端轉向前端開發的程式設計師,都會遇到的第一問題。JS前端程式設計與後端程式設計最大的不同,就是它的非同步機制,同時這也是它的核心機制。
為了更好地說明如何返回非同步呼叫的結果,先看三個嘗試非同步呼叫的範例吧。
範例一:呼叫一個後端介面,返回介面返回的內容
function foo() {
var result
$.ajax({
url: "...",
success: function(response) {
result = response
}
});
return result // 返回:undefined
}
函數foo嘗試呼叫一個介面並返回其內容,但每次執行都只會返回undefiend。
範例二:使用Promise的then方法,同樣是呼叫介面然後返回內容
function foo() {
var result
fetch(url).then(function(response) {
result = response
})
return result // 返回:undefined
}
與上一個範例的呼叫一樣,也只會返回undefined。
範例三:讀取本地檔案,然後返回其內容
function foo() {
var result
fs.readFile("path/to/file", function(err, response) {
result = response
})
return result // 返回:undefined
}
毫無意外這個範例的呼叫結果也是undefined。
為什麼?
因為這三個範例涉及的三個操作————ajax、fetch、readFile都是非同步操作,從操作指令發出,到拿到結果,這中間有一個時間間隔。無論你的機器效能多麼強勁,這個間隔也無法完全抹掉。這是由JS的主執行緒是單執行緒而決定的,JS程式碼執行到一定位置的時候,它不能等待,等待意味著使用者介面的卡頓,這是使用者不能容忍的。JS採用非同步執行緒優化該場景,當主執行緒中有非同步操作發起時,主執行緒不會阻塞,會繼續向下執行;當非同步操作有資料返回時,非同步執行緒會主動通知主執行緒:「Hi,老大,資料來了,現在要用嗎?」
「好的!馬上給我。」
這樣非同步執行緒把非同步程式碼推給主執行緒,非同步程式碼才得以執行。對於上面三個範例而言,result = response
就是它們的非同步程式碼。
下面作者畫一張輔助理解這種機制吧:
當非同步執行緒準備好資料的時候,主執行緒也不是馬上就能處理,只有當主執行緒有空閒了,並且前面沒有排隊等待處理的資料了,新的非同步資料才能得以處理。
在瞭解了JS的非同步機制以後,下面看前面三個範例如何正確改寫。
先看範例一,使用回撥函數改寫:
function foo(callback) {
$.ajax({
url: "...",
success: function(response) {
callback(response)
}
});
// return result // 返回:undefined
}
在呼叫函數foo的時候,事先傳遞進來一個callback,當ajax操作取到介面資料的時候,將資料傳遞給callback,由callback自行處理。
這種基於回撥的解決方案,雖然「巧妙」地解決了問題,但在存在多層非同步回撥的複雜專案中,往往由於一個操作依賴於多個非同步資料而造成「回撥噩夢」。
第二種改進的方案,不使用回撥函數,而是使用ES2015中新增的Promise及其then方法,下面以範例二進行改造:
function foo() {
return new Promise(function(resolve, reject) {
fetch(url).then(function(response) {
resolve(response)
})
})
}
foo().then(function(res){
console.log(res)
})..catch(function(err) {
//
})
foo返回一個Promise物件,注意,Promise僅是一個可能承載正確資料的容器,它並不是資料。在使用它的,需要呼叫它的then方法才能取得資料(在有資料返回的時候)。與then同時存在的另一個有用的方法是catch,它用於捕捉非同步操作可能出現的異常,處理可能的錯誤對加強魯棒性至關重要,這個catch方法不容忽視。
注意:範例中的fetch方法作者沒有給出具體實現,它在這裡是作為一個返回Promise物件的非同步操作被對待的,也因此我們看到了,在這個方法被呼叫後返回的物件上,也可以緊跟著呼叫then方法(第3行)。
但是,這種使用Promise的解決方案就完美了嗎,就沒有問題了嗎?顯然不是的。
過多的「緊隨」風格的then方法呼叫及catch方法呼叫,讓程式碼的前後邏輯不清晰;當我們閱讀這樣的程式碼時,並不是從上向下瀑布式閱讀的,而是時而上、時而下跳動著閱讀的,這很不舒服。不僅閱讀時不舒服,編寫時也很難以用一種像後端程式設計那樣的從上向下的簡潔的邏輯組織程式碼。
下面開始開始使用ES2017標準中提供async/await語法關鍵字,對範例三進行改寫:
function foo() {
return new Promise(function(resolve, reject) {
fs.readFile("path/to/file", function(err, response) {
resolve(response)
})
})
}
(async function(){
const res = await foo().catch(console.log)
console.log(res)
})()
基於async/await語法關鍵字的方案,是使用Promise的方案的升級版,在這個方案中也使用了Promise。第8行第11行,這是一個IIFE(立即呼叫函數表示式),之所以要用一個只使用一次的臨時匿名函數將第9行第10行的程式碼包裹起來,是因為await必須用在一個被async關鍵字修飾的函數或方法中,只能直接用到頂層的檔案作用域或模組作用域下。
使用這種方案的優化是,程式碼可以像後端程式設計那樣從上向下寫,結構可以很清晰。這也是一種被稱為「非同步轉同步」的JS程式設計正規化,在前端開發中已被普遍接受。
注意,「非同步轉同步」並沒有真正改變非同步程式碼,非同步程式碼仍然是非同步程式碼,它們仍然會在非同步執行緒中先默默地執行,等有資料返回了再通知主執行緒處理。當我們使用這種程式設計模式的時候,一定不要在主執行緒上去await一個Promise,可以發起非同步操作,讓非同步操作像葡萄一樣掛在主執行緒上,但不能等待它們返回了再往下執行。
先看一段Promise+then方法風格的jQuery程式碼:
$.ajax({
url: "test.html",
context: document.body
}).done(function() {
$(this).addClass("done")
});
第4行,這裡的done方法是jQuery自行實現的,$.ajax方法返回的是一個DeferredObject(延遲物件),這個物件上有done方法,這個方法與Promise的then類似。
jQuery成名在前,在ES2015標準誕生之前,jQuery的DeferredObject就已經被定義了。Promise本身並沒有神奇的地方,它可以發揮作用,主要依賴的是在JS中,Object是參照物件,繼承於Object原型的Promise也是參照物件,當非同步操作發起時,只有一個「空」的Promise被建立了,但是它的參照被保持了;當資料回來的時候,資料再被「裝填」進這個物件,這樣通過先前持有的參照,非同步程式碼便可以存取到物件上攜帶的資料。
Promise的勝利,更多是程式設計思想上的勝利,Promise的成功,也是程式設計思想上的成功。所有一種語言中程式設計思想上的成功,在其他語言中都可以被學習和借鑑。事實上在後端程式設計中,這種偽裝成同步程式碼風格的非同步程式設計思想也極其普遍,它們擁有一個共同的名字,叫協程。
在JS中處理非同步呼叫的結果,最佳實踐就是「非同步轉同步」:使用Promise + async/await語法關鍵字。在這裡async總是與await成對出現,一個async函數總是返回一個Promise,一個await關鍵字總是在嘗試「解開」一個Promise,結局要麼等到有價值的資料,要麼非同步出現非同步,什麼也沒有等到。為了避免出現異常,影響主執行緒的正常執行,一般要用catch規避異常。
著作權歸LIYI所有 基於CC BY-SA 4.0協定 原文連結:https://yishulun.com/posts/2022/33.html