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

  • 2019 年 12 月 9 日
  • 筆記

最近在讀一本《基於瀏覽器的深度學習》,書比較薄,但是涉及的內容很多,因此在讀的過程中不得不再查閱一些資料,以加深理解。我目前從事的本職工作就是瀏覽器研發,對於前端技術並不陌生。但是從前段時間開發微信小程式識狗君的過程來看,對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會用這種方式來解決非同步編程吧!這篇文章你看了之後,是醍醐灌頂,還是更加迷糊呢?歡迎留言探討。