JavaScript非同步程式設計的6種方法

穆笙發表於2019-03-03

前言

你應該知道,Javascript語言的執行環境是”單執行緒“(single thread)。

所謂”單執行緒”,就是指一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。

JavaScript非同步程式設計的6種方法

這種模式的好處是實現起來比較簡單,執行環境相對單純;壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等著,會拖延整個程式的執行。常見的瀏覽器無響應(假死),往往就是因為某一段Javascript程式碼長時間執行(比如死迴圈),導致整個頁面卡在這個地方,其他任務無法執行。

為了解決這個問題,Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和非同步(Asynchronous)。

“同步模式”就是上一段的模式,後一個任務等待前一個任務結束,然後再執行,程式的執行順序與任務的排列順序是一致的、同步的;”非同步模式”則完全不同,每一個任務有一個或多個回撥函式(callback),前一個任務結束後,不是執行後一個任務,而是執行回撥函式,後一個任務則是不等前一個任務結束就執行,所以程式的執行順序與任務的排列順序是不一致的、非同步的

JavaScript非同步程式設計的6種方法

非同步模式”非常重要。在瀏覽器端,耗時很長的操作都應該非同步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在伺服器端,”非同步模式”甚至是唯一的模式,因為執行環境是單執行緒的,如果允許同步執行所有http請求,伺服器效能會急劇下降,很快就會失去響應。

本文總結了”非同步模式”程式設計的4種方法,理解它們可以讓你寫出結構更合理、效能更出色、維護更方便的Javascript程式。

1、回撥函式

這是非同步程式設計最基本的方法。

假定有兩個函式f1和f2,後者等待前者的執行結果。

  f1();

  f2();複製程式碼

如果f1是一個很耗時的任務,可以考慮改寫f1,把f2寫成f1的回撥函式。

  function f1(callback){

    setTimeout(function () {

      // f1的任務程式碼

      callback();

    }, 1000);

  }複製程式碼

執行程式碼就變成下面這樣:

f1(f2);複製程式碼

採用這種方式,我們把同步操作變成了非同步操作,f1不會堵塞程式執行,相當於先執行程式的主要邏輯,將耗時的操作推遲執行。

回撥函式的優點是簡單、容易理解和部署,缺點是不利於程式碼的閱讀和維護,各個部分之間高度耦合(Coupling),流程會很混亂,而回撥函式有一個致命的弱點,就是容易寫出回撥地獄

2、事件監聽

另一種思路是採用事件驅動模式。任務的執行不取決於程式碼的順序,而取決於某個事件是否發生。

還是以f1和f2為例。首先,為f1繫結一個事件(這裡採用的jQuery的寫法)。

f1.on(`done`, f2);複製程式碼

上面這行程式碼的意思是,當f1發生done事件,就執行f2。然後,對f1進行改寫:

  function f1(){

    setTimeout(function () {

      // f1的任務程式碼

      f1.trigger(`done`);

    }, 1000);

  }複製程式碼

f1.trigger(`done`)表示,執行完成後,立即觸發done事件,從而開始執行f2。

這種方法的優點是比較容易理解,可以繫結多個事件,每個事件可以指定多個回撥函式,而且可以”去耦合”(Decoupling),有利於實現模組化。缺點是整個程式都要變成事件驅動型,執行流程會變得很不清晰。

2、釋出/訂閱

上一節的”事件”,完全可以理解成”訊號”。

我們假定,存在一個”訊號中心”,某個任務執行完成,就向訊號中心”釋出”(publish)一個訊號,其他任務可以向訊號中心”訂閱”(subscribe)這個訊號,從而知道什麼時候自己可以開始執行。這就叫做”釋出/訂閱模式”(publish-subscribe pattern),又稱”觀察者模式”(observer pattern)。

這個模式有多種實現,下面採用的是Ben Alman的Tiny Pub/Sub,這是jQuery的一個外掛。

首先,f2向”訊號中心”jQuery訂閱”done”訊號。

  jQuery.subscribe("done", f2);複製程式碼

然後,f1進行如下改寫:

  function f1(){

    setTimeout(function () {

      // f1的任務程式碼

      jQuery.publish("done");

    }, 1000);

  }複製程式碼

jQuery.publish(“done”)的意思是,f1執行完成後,向”訊號中心”jQuery釋出”done”訊號,從而引發f2的執行。

此外,f2完成執行後,也可以取消訂閱(unsubscribe)。

jQuery.unsubscribe("done", f2);複製程式碼

這種方法的性質與”事件監聽”類似,但是明顯優於後者。因為我們可以通過檢視”訊息中心”,瞭解存在多少訊號、每個訊號有多少訂閱者,從而監控程式的執行

4、Promises物件

Promises物件是CommonJS工作組提出的一種規範,目的是為非同步程式設計提供統一介面。

Promise物件有以下兩個特點。

(1)物件的狀態不受外界影響。Promise物件代表一個非同步操作,

有三種狀態:Pending(進行中)、Resolved(已完成,又稱Fulfilled)和Rejected(已失敗)。

只有非同步操作的結果,可以決定當前是哪一種狀態,任何其他操作都無法改變這個狀態。

這也是Promise這個名字的由來,它的英語意思就是“承諾”,表示其他手段無法改變。

(2)一旦狀態改變,就不會再變,任何時候都可以得到這個結果。

Promise物件的狀態改變,只有兩種可能:從Pending變為Resolved和從Pending變為Rejected

只要這兩種情況發生,狀態就凝固了,不會再變了,會一直保持這個結果。就算改變已經發生了,你再對Promise物件新增回撥函式,也會立即得到這個結果。

這與事件(Event)完全不同,事件的特點是,如果你錯過了它,再去監聽,是得不到結果的。

JavaScript非同步程式設計的6種方法

有了Promise物件,就可以將非同步操作以同步操作的流程表達出來,避免了層層巢狀的回撥函式。此外,Promise物件提供統一的介面,使得控制非同步操作更加容易。

Promise也有一些缺點。首先,無法取消Promise,一旦新建它就會立即執行,無法中途取消。其次,如果不設定回撥函式,Promise內部丟擲的錯誤,不會反應到外部。第三,當處於Pending狀態時,無法得知目前進展到哪一個階段(剛剛開始還是即將完成)。

基本用法

var promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 非同步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});複製程式碼

Promise例項生成以後,可以用then方法分別指定Resolved狀態和Reject狀態的回撥函式。

promise.then(function(value) {
  // success
}, function(error) {
  // failure
});複製程式碼

Promise.prototype.then()

Promise例項具有then方法,也就是說,then方法是定義在原型物件Promise.prototype上的。它的作用是為Promise例項新增狀態改變時的回撥函式。前面說過,then方法的第一個引數是Resolved狀態的回撥函式,第二個引數(可選)是Rejected狀態的回撥函式。

then方法返回的是一個新的Promise例項(注意,不是原來那個Promise例項)。因此可以採用鏈式寫法,即then方法後面再呼叫另一個then方法。

getJSON("/posts.json").then(function(json) {
  return json.post;
}).then(function(post) {
  // ...
});複製程式碼

Promise.prototype.catch()

Promise.prototype.catch方法是.then(null, rejection)的別名,用於指定發生錯誤時的回撥函式。

getJSON("/posts.json").then(function(posts) {
  // ...
}).catch(function(error) {
  // 處理 getJSON 和 前一個回撥函式執行時發生的錯誤
  console.log(`發生錯誤!`, error);
});複製程式碼

5、Generator 函式

Generator函式是ES6提供的一種非同步程式設計解決方案,語法行為與傳統函式完全不同

Generator函式有多種理解角度。從語法上,首先可以把它理解成,Generator函式是一個狀態機,封裝了多個內部狀態。

執行Generator函式會返回一個遍歷器物件,也就是說,Generator函式除了狀態機,還是一個遍歷器物件生成函式。返回的遍歷器物件,可以依次遍歷Generator函式內部的每一個狀態。

形式上,Generator函式是一個普通函式,但是有兩個特徵。一是,function關鍵字與函式名之間有一個星號;二是,函式體內部使用yield語句,定義不同的內部狀態(yield語句在英語裡的意思就是“產出”)。

function* helloWorldGenerator() {
  yield `hello`;
  yield `world`;
  return `ending`;
}

var hw = helloWorldGenerator();複製程式碼

上面程式碼定義了一個Generator函式helloWorldGenerator,它內部有兩個yield語句“hello”和“world”,即該函式有三個狀態:hello,world和return語句(結束執行)。

然後,Generator函式的呼叫方法與普通函式一樣,也是在函式名後面加上一對圓括號。不同的是,呼叫Generator函式後,該函式並不執行,返回的也不是函式執行結果,而是一個指向內部狀態的指標物件,也就是上一章介紹的遍歷器物件(Iterator Object)。

下一步,必須呼叫遍歷器物件的next方法,使得指標移向下一個狀態。也就是說,每次呼叫next方法,內部指標就從函式頭部或上一次停下來的地方開始執行,直到遇到下一個yield語句(或return語句)為止。換言之,Generator函式是分段執行的,yield語句是暫停執行的標記,而next方法可以恢復執行。

hw.next()
// { value: `hello`, done: false }

hw.next()
// { value: `world`, done: false }

hw.next()
// { value: `ending`, done: true }

hw.next()
// { value: undefined, done: true }複製程式碼

上面程式碼一共呼叫了四次next方法。

第一次呼叫,Generator函式開始執行,直到遇到第一個yield語句為止。next方法返回一個物件,它的value屬性就是當前yield語句的值hello,done屬性的值false,表示遍歷還沒有結束。

第二次呼叫,Generator函式從上次yield語句停下的地方,一直執行到下一個yield語句。next方法返回的物件的value屬性就是當前yield語句的值world,done屬性的值false,表示遍歷還沒有結束。

第三次呼叫,Generator函式從上次yield語句停下的地方,一直執行到return語句(如果沒有return語句,就執行到函式結束)。next方法返回的物件的value屬性,就是緊跟在return語句後面的表示式的值(如果沒有return語句,則value屬性的值為undefined),done屬性的值true,表示遍歷已經結束。

第四次呼叫,此時Generator函式已經執行完畢,next方法返回物件的value屬性為undefined,done屬性為true。以後再呼叫next方法,返回的都是這個值。

總結一下,呼叫Generator函式,返回一個遍歷器物件,代表Generator函式的內部指標。以後,每次呼叫遍歷器物件的next方法,就會返回一個有著valuedone兩個屬性的物件。value屬性表示當前的內部狀態的值,是yield語句後面那個表示式的值;done屬性是一個布林值,表示是否遍歷結束

yield語句

由於Generator函式返回的遍歷器物件,只有呼叫next方法才會遍歷下一個內部狀態,所以其實提供了一種可以暫停執行的函式。yield語句就是暫停標誌。

遍歷器物件的next方法的執行邏輯如下。

(1)遇到yield語句,就暫停執行後面的操作,並將緊跟在yield後面的那個表示式的值,作為返回的物件的value屬性值。

(2)下一次呼叫next方法時,再繼續往下執行,直到遇到下一個yield語句。

(3)如果沒有再遇到新的yield語句,就一直執行到函式結束,直到return語句為止,並將return語句後面的表示式的值,作為返回的物件的value屬性值。

(4)如果該函式沒有return語句,則返回的物件的value屬性值為undefined

需要注意的是,yield語句後面的表示式,只有當呼叫next方法、內部指標指向該語句時才會執行,因此等於為JavaScript提供了手動的“惰性求值”(Lazy Evaluation)的語法功能。

function* gen() {
  yield  123 + 456;
}複製程式碼

上面程式碼中,yield後面的表示式123 + 456,不會立即求值,只會在next方法將指標移到這一句時,才會求值。

yield語句與return語句既有相似之處,也有區別。相似之處在於,都能返回緊跟在語句後面的那個表示式的值。區別在於每次遇到yield,函式暫停執行,下一次再從該位置繼續向後執行,而return語句不具備位置記憶的功能。一個函式裡面,只能執行一次(或者說一個)return語句,但是可以執行多次(或者說多個)yield語句。正常函式只能返回一個值,因為只能執行一次return;Generator函式可以返回一系列的值,因為可以有任意多個yield。從另一個角度看,也可以說Generator生成了一系列的值,這也就是它的名稱的來歷(在英語中,generator這個詞是“生成器”的意思)。

6、async與await

ES7提供了async函式,使得非同步操作變得更加方便。async函式是什麼?一句話,async函式就是Generator函式的語法糖。

依次讀取兩個檔案。

var fs = require(`fs`);

var readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) reject(error);
      resolve(data);
    });
  });
};

var gen = function* (){
  var f1 = yield readFile(`/etc/fstab`);
  var f2 = yield readFile(`/etc/shells`);
  console.log(f1.toString());
  console.log(f2.toString());
};複製程式碼

寫成async函式,就是下面這樣。

var asyncReadFile = async function (){
  var f1 = await readFile(`/etc/fstab`);
  var f2 = await readFile(`/etc/shells`);
  console.log(f1.toString());
  console.log(f2.toString());
};複製程式碼

一比較就會發現,async函式就是將Generator函式的星號(*)替換成async,將yield替換成await,僅此而已。

async函式對 Generator 函式的改進,體現在以下四點

(1)內建執行器。Generator函式的執行必須靠執行器,所以才有了co模組,而async函式自帶執行器。也就是說,async函式的執行,與普通函式一模一樣,只要一行。

(2)更好的語義。asyncawait,比起星號和yield,語義更清楚了。async表示函式裡有非同步操作,await表示緊跟在後面的表示式需要等待結果。

(3)更廣的適用性。 co模組約定,yield命令後面只能是Thunk函式或Promise物件,而async函式的await命令後面,可以是Promise物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)。

(4)返回值是Promise。async函式的返回值是Promise物件,這比Generator函式的返回值是Iterator物件方便多了。你可以用then方法指定下一步的操作。

進一步說,async函式完全可以看作多個非同步操作,包裝成的一個Promise物件,而await命令就是內部then命令的語法糖。

參考文章

JavaScript非同步程式設計的4種方法

相關文章