深度學習的JavaScript基礎:從callbacks到sync/await

最近在讀一本《基於瀏覽器的深度學習》,書比較薄,但是涉及的內容很多,因此在讀的過程中不得不再查閱一些資料,以加深理解。我目前從事的本職工作就是瀏覽器研發,對於前端技術並不陌生。但是從前段時間開發微信小程序識狗君的過程來看,對JavaScript還是掌握得太少,特別是對一些前端框架以及一些比較新的JavaScript語法和編程模型,瞭解的不夠。在修改tfjs-core源碼時,就體會到這種痛苦。好吧,既然無法避開,那就正面剛吧。

這篇文章就談一談JavaScript中的異步編程。文章參考了網上的一些資料,主要示例代碼來自Async JavaScript: From Callbacks, to Promises, to Async/Await一文,點擊公衆號的閱讀原文,可以跳轉該文章。

在編寫微信小程序時,就被代碼中的回調、sync/await整得一臉懵。對於程序員來說,多線程應該是再熟不過的概念,碰到耗時的IO操作,爲了不阻塞用戶界面的響應,首先想到的方法多半是採用多線程。然而對於前端開發來說,這種方法是不可行的,因爲Javascript採用了單線程運行模型。注意,JavaScript只在一個線程上運行,不代表JavaScript引擎只有一個線程。事實上,JavaScript引擎有多個線程,單個腳本只能在一個線程上運行,其他線程都是在後臺配合。

JavaScript之所以採用單線程,而不是多線程,跟歷史有關係。JavaScript從誕生起就是單線程,原因是不想讓瀏覽器變得太複雜,因爲多線程需要共享資源、且有可能修改彼此的運行結果,對於一種網頁腳本語言來說,這就太複雜了。後來 HTML5 引入了web workers,爲Web內容在後臺線程中運行腳本提供了一種簡單的方法。但這種方法還未被廣泛使用,不在本文討論範圍之內。

雖然JavaScript腳本運行在單線程中,但一些耗時或需要等待的操作,可以通過異步回調方式實現,這就是本文將要談到的第一種方法:callbacks。

callbacks

在JavaScript中,callbacks是一個比較寬泛的概念,當你將函數的引用作爲參數傳遞給一個函數時,這個作爲參數傳遞的函數就稱作回調函數。比如:

function add (x, y) {return x + y
}function addFive (x, addReference) {return addReference(x, 5) // 15 - Press the button, run the machine.}

addFive(10, add) // 15

上述代碼中add函數就可以稱作回調函數。所以說,callabcks通常有兩種用途,一種就是作爲處理函數,對數據進行處理,前端程序員應該很熟悉如下的用法:

[1,2,3].map((i) => i + 5)

_.filter([1,2,3,4], (n) => n % 2 === 0 );

代碼中使用了lambda表達式,算是一種匿名函數。另一種使用方法更爲廣泛,延遲執行某個函數,到特定的時間、或者等到數據,或者是等用戶進行了操作:

$('#btn').on('click', () =>console.log('Callbacks are everywhere')
)const id = 'tylermcginnis'$.getJSON({
url: `https://api.github.com/users/${id}`,success: updateUI,
error: showError,
})

這也是本文所談到的異步編程。在上面的代碼中getJSON調用會立即返回,不會阻塞主線程運行,數據獲取成功之後,會調用updateUI,如果失敗,則調用showError。

看似異步編程在JavaScript中得到了解決,但callbacks這種方案並不完美。第一個不足之處,就是所謂的“回調地獄”。看以下一段代碼:

// updateUI, showError, and getLocationURL are irrelevant.// Pretend they do what they sound like.const id = 'tylermcginnis'$("#btn").on("click", () => {
$.getJSON({
url: `https://api.github.com/users/${id}`,success: (user) => {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success (weather) {
updateUI({
user,
weather: weather.query.results
})
},
error: showError,
})
},
error: showError
})
})

有沒有覺得暈,人通常習慣於線性思維,順序執行的代碼容易理解,但上面的代碼嵌套太多。這還不是嵌套最多的,我之前編寫微信小程序,參考的代碼有嵌套七八層的,看得令人絕望。這種多層嵌套容易出錯,也不好調試。雖然我們可以採用一些模塊化技術,改善代碼的閱讀性,但無法從根本上解決這一問題。

callbacks的另一個問題是“控制反轉”,當你的代碼調用另一個函數,如果這個函數並不是你編寫的,你就失去了控制權。萬一你調用的回調函數執行了非常耗時的操作,但又沒有考慮異步,你也無法控制。如果你調用的是jQuery、lodash以及JavaScript內置庫時,可以放心的假設它們會及時返回。但是,對於衆多第三方庫,你還會這麼放心嗎?第三方庫可能有意或無意破壞了它們與回調的交互方式。

Promise

爲了解決callbacks的種種不足,一些聰明人提出了Promise的思路。爲了理解這一方案,我們先從日常生活的一個場景出發,作爲一名都市人,估計大家都有去餐館等位子的經歷吧!最傻的一種方式就是叫號,這也是大多數餐廳採用的方法,大家都排在餐廳的門口,有了空位再按先來後到的順序就餐。後來有的商家做了改進,留下電話號碼,快到有位子的時候,通過短信或者微信通知。在等待的這段時間,客戶可以在附近逛逛,只要不是離得太遠。仔細想想,第一種方式類似於編程中的同步模型,客戶需要一直死等,第二種方式類似於前面的回調模型。回調模式的問題在哪?想想我們平常收到的推銷電話,有沒有可能就是你在一次不經意的留下電話號碼招來的?我們無法保證每個餐廳都能按良心辦事,只用於這次的餐廳等位通知。

兩種方式都存在不足,於是有人想出了第三種方案,就是如下圖所示的蜂鳴器:

這種小裝備在國內不多見,反正我是沒見過。不過簡單解釋一下,很容易明白其工作原理。當蜂鳴器嗡嗡作響併發光時,表明已經有桌子空出來。實際上,蜂鳴器將處於三種不同狀態之一:待處理、接受或拒絕。

  • 待處理是默認的初始狀態。當他們給您蜂鳴器時,它就處於這種狀態。

  • 蜂鳴器閃爍表明您的桌子準備就緒,蜂鳴器處於接受狀態。

  • 出現問題時(也許餐廳快要關門了,或者他們忘了有人把餐廳租了一晚),蜂鳴器將處於拒絕狀態。

在現實中,這種方案有很多細節需要考慮,蜂鳴器通訊範圍多廣(會不會走太遠,收不到信號?)、客人拿了蜂鳴器不歸還怎麼辦?但是將這種方案用在解決JavaScript中的異步問題,就不存在上述問題,又能很好的解決控制權反轉問題,這就是JavaScript中的Promise。

Promise有三種狀態:pending, fulfilled和rejected。如果異步請求仍在進行中,則Promise的狀態將爲pending。如果異步請求已成功完成,則Promise將變爲fulfilled狀態。如果異步請求失敗,則Promise將變爲rejected狀態。是不是和前面用於解決餐廳等位問題的蜂鳴器很像?

瞭解Promise存在的原因以及它們可能處於的不同狀態後,我們還需要回答三個問題:

  • 如何創建Promise?

  • 如何更改Promise的狀態?

  • 當Promise狀態發生變化時,您該如何監聽?

創建Promise

第一個問題很好回答,直接new一個Promise的實例即可:

const promise = new Promise()

注意並非所有瀏覽器都支持Promise對象,自 Chrome 32、Opera 19、Firefox 29、Safari 8 和 Microsoft Edge 起,promise 默認啓用,所以使用前請確認你所使用的瀏覽器內核。

修改Promise的狀態

Promise構造函數接受一個參數,即(回調)函數。該函數將傳遞兩個參數:resolve和reject。

  • resolve: 將Promise狀態修改爲fulfilled的函數。

  • reject: 將Promise狀態修改爲rejected的函數。

在下面的代碼中,我們使用setTimeout等待2秒,然後調用resolve,Promise狀態將變爲fulfilled。

const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve() // Change status to 'fulfilled'}, 2000)
})

我們可以通過在創建Promise後立即輸出Promise值,然後在大約2秒鐘後resolve被調用後再次輸出Promise值,來觀察到這種變化。

注意到沒有,Promise從pending狀態變爲resolved

監聽Promise狀態變化

這是最重要的問題。如果狀態更改後我們不知道如何做,那毫無用處。

創建新的Promise時,實際上只是在創建一個普通的JavaScript對象。該對象可以調用then和catch這兩個方法,這兩個方法都接受一個回調函數作爲參數。當Promise的狀態變爲fulfilled時,傳遞給.then的函數將被調用。當一個Promise的狀態更改爲rejected時,將調用傳遞給.catch的函數。

讓我們來看一個例子。我們將再次使用setTimeout兩秒鐘(2000毫秒)後將Promise狀態變爲fulfilled。

function onSuccess () {console.log('Success!')
}function onError () {console.log('????')
}const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 2000)
})

promise.then(onSuccess)
promise.catch(onError)

嘗試着運行上面的代碼,大約2秒鐘後,在瀏覽器控制檯中可看到“Success!”。

這個過程發生了什麼?首先,當我們創建Promise時,我們在約2000毫秒後調用了resolve,這將Promise的狀態更改爲fulfilled。其次,我們將onSuccess函數傳遞給promises的.then方法。這樣做,我們告訴了Promise,當Promise的狀態更改爲fulfilled時調用onSuccess,它在大約2000毫秒後執行。

再來看看rejected情況下的代碼:

function onSuccess () {console.log('Success!')
}function onError () {console.log('????')
}const promise = new Promise((resolve, reject) => {
setTimeout(() => {
reject()
}, 2000)
})

promise.then(onSuccess)
promise.catch(onError)

這種情況下,onError將會被調用,因爲2000毫秒後,reject被調用了。


回頭再看看前面的異步代碼:

function getUser(id, onSuccess, onFailure) {
$.getJSON({
url: `https://api.github.com/users/${id}`,success: onSuccess,
error: onFailure
})
}function getWeather(user, onSuccess, onFailure) {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success: onSuccess,
error: onFailure,
})
}

$("#btn").on("click", () => {
getUser("tylermcginnis", (user) => {
getWeather(user, (weather) => {
updateUI({
user,
weather: weather.query.results
})
}, showError)
}, showError)
})

用Promise的方式該如何改寫呢?首先看看getUser這個函數的改寫:

function getUser(id) {return new Promise((resolve, reject) => {
$.getJSON({
url: `https://api.github.com/users/${id}`,success: resolve,
error: reject
})
})
}

注意到沒有,getUser的參數有所變化,僅接收ID,不再需要其他兩個回調函數,保證不會發生控制反轉。如果請求成功,則將調用resolve;如果發生錯誤,則將調用reject。

同樣的方式改寫getWether函數:

function getWeather(user) {return new Promise((resolve, reject) => {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success: resolve,
error: reject,
})
})
}

接着改寫按鈕點擊處理:

$("#btn").on("click", () => {const userPromise = getUser('tylermcginnis')

userPromise.then((user) => {const weatherPromise = getWeather(user)
weatherPromise.then((weather) => {
updateUI({
user,
weather: weather.query.results
})
})

weatherPromise.catch(showError)
})

userPromise.catch(showError)
})

代碼的邏輯就是根據id獲取用戶信息,然後通過用戶所在的地理位置獲取天氣信息,最後更新到用戶界面上。

整條邏輯就像是一個線性處理過程,事實上,通過Promise的鏈式結構,我們可以將代碼寫得更緊湊一些。

$("#btn").on("click", () => {
getUser("tylermcginnis")
.then(getWeather)
.then((weather) => {// We need both the user and the weather here.// Right now we just have the weatherupdateUI() // ????})
.catch(showError)
})

上面的代碼看起來很簡練,但實際上隱藏着一個問題。在第二個.then中,我們要調用updateUI。問題是我們需要同時給updateUI傳遞用戶和天氣。但上面的代碼中,我們只傳遞了天氣信息,而沒有用戶信息。我們需要以某種方式找到一種實現方法,以便在getWeather返回的Promise在resolve時,用戶和天氣都可以傳遞。

解決問題的關鍵在於,resolve只是一個函數,傳遞給它的任何參數都將傳遞給給.then的函數。這意味着在getWeather內部,如果我們調用自己的resolve方法,則可以將天氣和用戶傳遞給它。這樣,鏈中的第二個.then方法將同時接收用戶和天氣作爲參數。

function getWeather(user) {return new Promise((resolve, reject) => {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success(weather) {
resolve({ user, weather: weather.query.results })
},
error: reject,
})
})
}

$("#btn").on("click", () => {
getUser("tylermcginnis")
.then(getWeather)
.then((data) => {// Now, data is an object with a// "weather" property and a "user" property.updateUI(data)
})
.catch(showError)
})

比較以下Callbacks和Promise的實現代碼,是不是Promise更容易理解?

// Callbacks ????getUser("tylermcginnis", (user) => {
getWeather(user, (weather) => {
updateUI({
user,
weather: weather.query.results
})
}, showError)
}, showError)// Promises ✅getUser("tylermcginnis")
.then(getWeather)
.then((data) => updateUI(data))
.catch(showError);

async/await

上面的Promise方案解決了Callbacks的兩大重要缺陷,但還存在不足,我們需要將用戶數據從第一個異步請求一直傳遞到最後一個.then。這使得我們修改getWeather函數,使其可以傳遞用戶。

有沒有什麼方法可以讓我們以編寫同步代碼的方式編寫異步代碼呢?假如我們以同步方式實現上述的功能,大概寫法如下:

$("#btn").on("click", () => {const user = getUser('tylermcginnis')const weather = getWeather(user)

updateUI({
user,
weather,
})
})

如何讓Javascript引擎知道這裏getUser和getWeather實際上是一個異步方法呢?這時就該async/await登場了。

$("#btn").on("click", async () => {const user = await getUser('tylermcginnis')const weather = await getWeather(user.location)

updateUI({
user,
weather,
})
})

首先,函數前的async修飾告訴引擎,該函數中存在異步調用。其次,代碼中的await則表示這個調用是一個異步調用,將返回一個Promise。在await的地方,代碼將等待,直到異步調用返回Promise。

函數前加上async,代表函數將返回一個Promise,即使像下面這樣的空函數,也會隱式返回一個Promise:

async function getPromise(){}const promise = getPromise()

如果async函數返回了值呢?如以下代碼所示,該值將封裝到Promise中:

async function add (x, y) {return x + y
}

add(2,3).then((result) => {console.log(result) // 5})

需要注意的是,await只能用在async函數中,比如下面的代碼,會出錯:

$("#btn").on("click", () => {const user = await getUser('tylermcginnis') // SyntaxError: await is a reserved wordconst weather = await getWeather(user.location) // SyntaxError: await is a reserved wordupdateUI({
user,
weather,
})
})

也就是說,當async加到函數時,會產生兩種結果:

  • 使函數本身返回(或包裝返回的內容)一個promise

  • 可以在其中使用await。

小結

好了,關於JavaScript中的異步編程就探討到這兒,是不是和我們平常採用的Python、Java或C++語言不太一樣。有人說,學一門語言,實際上是學習一種編程思路,你沒有想到JavaScript會用這種方式來解決異步編程吧!這篇文章你看了之後,是醍醐灌頂,還是更加迷糊呢?歡迎留言探討。

發佈了180 篇原創文章 · 獲贊 447 · 訪問量 36萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章