ES6之Generator和async

一、概述

Generator和async是ES6提供的新的異步解決方案。

Generator函數可以理解爲一個可以輸出多個值的狀態機。它的返回值是一個遍歷器對象(Iterator),每次調用該遍歷器的next方法就會輸出一個值。當有多個異步操作需要按序執行時,只要在完成一個時調一次next方法即可執行下一個。不過想要自動化執行Generator函數則需要藉助一些工具。

async函數則是Generator函數的語法糖,它爲Generator函數內置了自動執行器。用async函數寫出的異步代碼幾乎與同步代碼沒有什麼差別,使用async函數,不需要任何外部工具,即可寫出格式優雅的異步代碼。

總的來說,Generator函數定義了一種新的異步模型,而async函數通過對該模型的再封裝,提供了一種優雅的異步解決方案。

下面我們分別對兩者展開詳細探討。

二、Generator函數

1. 基本原理

衆所周知,在JavaScript中,任何函數最多只能有一個返回值(其實幾乎所有的語言都是這樣)。如果沒有顯式地使用return返回一個值,那函數的返回值默認是undefined。

與普通函數相比,Generator函數特別的地方在於,它返回的始終是一個遍歷器對象。看下面的簡單例子:

function* gen(){
  yield 'Hello';
  yield 'World';
}

let iterator = gen();  
iterator.next(); // {value: ‘Hello’, done: false}
iterator.next(); // {value: ‘World’, done: false}
iterator.next(); // {value: undefined, done: true}

關鍵字function的後面帶了一個*,表示這是一個Generator函數。Generator函數內部可以使用特殊的關鍵字yield,來規定遍歷器每次調用next方法時要返回的值(這裏可以使用任何有效的表達式,如異步應用中就常返回一個Promise對象)。

爲了說明這個函數的原理,我們用一個普通函數來改寫上面的Generator函數:

function gen(){
  let index = 0;
  
  return {
    next(){
      switch(index){
        case 0:
          index++;
          return {value: 'Hello', done: false}
        case 1:
          index++;
          return {value: 'World', done: false}
        default:
          return {value: undefined, done: true}
      }
    }
  }
}

let iterator = gen();  
iterator.next(); // {value: ‘Hello’, done: false}
iterator.next(); // {value: ‘World’, done: false}
iterator.next(); // {value: undefined, done: true}

可以看到,執行過程是完全一樣的。Generator函數其實可以視爲這類返回遍歷器對象的普通函數的語法糖。而yield語句就是在定義遍歷器的next方法的輸出。

從抽象的角度來說,Generator函數是可中斷的,而yield語句就像函數內的斷點。第一次調用next方法將從函數首部開始執行,並在遇到第一個yield語句時中斷,之後引擎將轉而執行其他代碼。當再次調用遍歷器的next方法時,引擎就從上次中斷的位置繼續向下執行,遇到一個yield語句後將再次輸出表達式的值並中斷。該過程不斷重複,直到走到return語句或執行到函數末尾。

仍以上面的Generator函數爲例:

function* gen(){
  yield 'Hello';
  yield 'World';
}

let iterator = gen();  
iterator.next(); // {value: ‘Hello’, done: false}
iterator.next(); // {value: ‘World’, done: false}
iterator.next(); // {value: undefined, done: true}

語句let iterator = gen()得到了Generator返回的遍歷器對象,保存在變量iterator中。

第一次調用該遍歷器的next方法,函數將從第一句開始執行。由於第一句就是yield語句yield 'Hello',因此函數直接向外輸出字符串’Hello’(實際上輸出的是封裝後的對象{value: ‘Hello’, done: false},這是遍歷器規範決定的),並在該處發生中斷。

緊接着我們進行了第二次next調用(在這之間你可以插入任何其他語句,甚至把第二次next調用放在一個異步任務裏,這完全取決於你的業務邏輯),這次函數將從上一個yield語句開始繼續執行,執行到下一個yield語句yield 'World'時,函數向外輸出字符串‘World’,並再次中斷。

隨後我們進行了第三次next調用,函數從上次的斷點處繼續執行,由於此時函數已經結束,因此函數直接返回對象{value: undefined, done: true},表示Generator函數執行結束。

爲什麼要使用yield這個關鍵字呢?這個要從yield的含義說起。yield的中文解釋爲“生產,產出”,它表示代碼解析到這裏需要向外產出一個值。

Generator函數也是因此得名。單詞Generator的中文解釋爲“生成器”,表示它是一個可以“產出”多個值的特殊函數,它的這種功能其實是藉助遍歷器機制實現的(所以如果你完全掌握了遍歷器機制,那麼Generator函數其實並不神祕,如果你不瞭解遍歷器,推薦你閱讀我之前寫的文章ES6之遍歷器Iterator)。

注意:按照ES6規範,Generator函數的星號只要求位於function關鍵字後面,不一定要挨着function,如function *gen()function*gen()也是合法的。我們推薦使用function* gen()的寫法,因爲星號是用來修飾function關鍵字的。

2. 使用語法

(1)yield表達式

Generator函數中最重要的語法可能就是yield表達式了,它是Generator函數的中斷標誌。

從開發者的角度來說,yield連同它後面的表達式,構成了一個yield表達式。js引擎執行到一個含有yield表達式的語句時,會直接輸出yield關鍵字後面表達式的值,然後在此中斷。下一次調用next方法時,引擎會從上一次中斷的位置繼續執行,並把傳入next方法的參數作爲yield表達式的值。如:

function* gen(){
  let a = 'Hello';
  let b = yield a + 'World';
  return 'getMessage: ' + b;
}

let it = gen();
it.next();   //{value: 'Hello World', done: false}
it.next('123'); //{value: 'getMessage: 123', done: true}

當我們執行let it = gen()時,只是得到了一個遍歷器對象。

隨後我們調用它的next方法,函數從let a = 'Hello';開始執行,在讀取第二行語句時發現它包含了一個yield表達式yield a + 'World',於是引擎中斷,輸出yield關鍵字後面的表達式a + 'World'的值。因此我們看到,第一次調用next方法時的返回值是:{value: 'Hello World', done: false},它的value就是表達式a + 'World'的值,done爲false表示遍歷未結束。

再次調用next方法時我們傳入了一個字符串參數’123’,它會作爲上個yield表達式的值。也就是說,yield a + 'World'的值爲我們傳入的字符串’123’。於是js引擎接下來就是在執行這樣一個賦值語句:let b = '123'。隨後繼續向下執行,這時遇到了return語句。遇到return語句表示Generator函數執行結束,js引擎返回return後面表達式的值並結束Generator函數遍歷。所以該next調用的返回值就是{value: 'getMessage: 123', done: true},value的值爲return表達式的值,done爲true表示執行結束。

從js引擎的角度來說,yield表達式還有另外一種理解方式。這種思路的主要思想爲,js引擎本身並不真正執行yield語句,而是藉助它來生成遍歷器對象。類似於開始的那個函數,我們可以構造以下這個對象來實現上述Generator函數:

{
  index: 0,
  next(value){
    switch(index){
      case 0: 
        index++;
        let a = 'Hello';
        return {value: a + 'World', done: false};
      case 1:
        index++;
        let b = value;
        return {value: 'getMessage: ' + b, done: true};
      default: 
        return {value: undefined, done: true};
    }
  }
}

實際上有個這個遍歷器對象後,Generator函數的作用已經結束了!我們不用再考慮yield語句,也沒有什麼中斷邏輯。我們唯一在做的就是連續調用這個遍歷器對象的next方法,每次調用會導致index加一,於是下次函數就會走到下一個case分支。如果我們的Generator函數有非常多的yield表達式,只需要在switch內增加對應數量的case語句,語句的內容就是兩個yield表達式中間的語句。

說到這裏,yield表達式的原理是不是就更簡單了?實際上它對引擎來說只是一個標記,界定了遍歷器的next方法每次需要執行的代碼範圍。js引擎順序解析Generator函數,通過yield表達式設定每步next方法的行爲,Generator函數的功能就結束了。

yield表達式本質上就是一個表達式,所以你可以將它以任意的形式使用,不過當它被放在另一個表達式中時,請添加圓括號。

function* gen(){
  console.log('Hello ' + (yield));
}

let g = gen();
g.next(); //{value: undefined, done: false}
g.next('peter'); //控制檯輸出"Hello peter"
				 //表達式的值爲{value: undefined, done: true}

此時如果yield表達式不帶圓括號就會報錯。

(2)yield*表達式

yield*語句的主要目的是在一個Generator函數中調用其他Generator函數。比如現在有兩個Generator函數:

function* gen1(){
  yield 1;
  yield 2;
}

function* gen2(){
  for(let item of gen()){
    console.log(item);
  }
  yield 3;
  yield 4;
}

for(let item of gen2()){
  console.log(item);
}
//輸出:1 2 3 4

我們需要在gen2內部調用gen1,必須通過手動調用for … of循環來實現,這對多層的Generator函數嵌套很不方便。爲此ES提供了yield*語法,它可以看作是for … of語句的語法糖。即:

yield* gen()
//等價於
for(let value of gen()){
  yield value;
}

所以yield*語法跟手動實現的for … of沒有什麼差別,只是寫起來要簡單很多。另外,嵌套的Generator函數也可以展開成一個Generator函數,即:

function* gen1(){
  yield 1;
  yield 2;
}

function* gen2(){
  for(let item of gen()){
    console.log(item);
  }
  yield 3;
  yield 4;
}

//等價於

function* gen2(){
  yield 1;
  yield 2;  //這兩行是從gen1複製過來的結果
  
  yield 3;
  yield 4;
}

也就是說,yield*後面跟一個Generator函數的返回值就相當於直接把這個Generator函數的代碼“複製進來”。

本質上,yield*就是在調用它後面跟的那個對象的Iterator接口,因此它可以遍歷任何具有Iterator接口的對象。如:

yield* [1, 2, 3]
//等價於
yield 1;
yield 2;
yield 3;

引擎一旦解析遇到yield*表達式,就會調用它後面的對象的Iterator接口,因此只有實現了Iterator接口的對象纔可以使用yield*表達式。

(3)for … of循環

Generator函數可以無縫使用for … of循環,這是因爲調用Generator函數時返回的就是一個遍歷器,而for … of循環本身就是用來“消費”遍歷器的。比如下面的例子:

function* gen(){
  yield 1;
  yield 2;
  yield 3;
  return 4;
}

for(let n of gen()){
  console.log(n)
}
// 1 2 3

需要特別注意的是,這裏並沒有輸出return返回的值4,這是爲什麼呢?

這是for … of的語法特性導致的,它只會在當調用遍歷器的next方法返回的對象的done屬性爲false時纔會執行循環。而gen函數依次調用next方法時的返回值如下:

let it = gen();
gen.next();  //{value: 1, done: false}
gen.next();  //{value: 2, done: false}
gen.next();  //{value: 3, done: false}
gen.next();  //{value: 4, done: true}

前三次返回值的done屬性都是false,因此會執行循環體。第四次由於是return語句的輸出,因此引擎認爲Generator函數執行結束,返回的done屬性爲true。於是for … of語句就直接跳出了循環,沒有調用console.log進行輸出。

普通的Iterable對象沒有出現這個現象是因爲,它們必須多遍歷一次才能知道是否輸出完畢,比如:

let arr = [1,2,3];
let it = arr[Symbol.iterator]();  //獲取arr的遍歷器

it.next();  //{value: 1, done: false}
it.next();  //{value: 2 done: false}
it.next();  //{value: 3, done: false}
it.next();  //{value: undefined, done: true}

注意,儘管數組只有三個值,但是調用了四次next方法纔得到done爲true的輸出。根本原因是,引擎在輸出每個元素時不會去檢查它是否爲最後一個元素,只有當某次輸出時發現已經沒有值了,纔會確定輸出完畢。這種機制使引擎避免了很多次不必要的檢查,對for … of的性能有很大提升。

(4)return和throw

關於return,我們上面已經提到了一點,它可以直接結束Generator函數的執行,但是會導致for … of語句無法輸出return後面的值。如果最後一個值對你也很重要,請使用yield語句來輸出它。如:

function* gen(){
  yield 1;
  
  return 2;
}
//替換爲
function* gen(){
  yield 1;
  
  yield 2;
}

另外return也是Generator返回的遍歷器原型上的一個原型方法,如:

let g = gen();
g.return(1);  //{value: 1, done: true}

這如同人爲在中斷處插入了一個return語句,因此會導致Generator函數調用提前結束。

throw方法用於拋出異常,它是Generator函數返回的遍歷器對象的原型方法。如:

function* gen(){
  try {
    yield;
  } catch (e){
    console.log("內部:" + e);
  }
}

let g = gen();
g.next();   //現在Generator函數走到第一個yield表達式

try {
  g.throw('a');  //相當於g.next( throw 'a' ),拋出的異常被傳遞給了Generator函數
  g.throw('b');  //同上,但是由於Generator函數已經執行完畢,這個異常只能在外部捕獲
} catch(e){
  console.log('外部', e)
}

//內部:a
//外部:b

所以g.throw(e)可以看做是g.next( throw e )的語法糖。

3. Generator函數的異步應用

上面我們所介紹的都是Generator函數的基本原理和語法。從語言的角度來說,它是js中狀態機(可中斷,可記錄狀態)的一種很好的實現(Promise也是一種狀態機,但是Generator函數比Promise更加靈活,它可以多次中斷),這使得它可以很好地用於處理異步應用。

假設我們現在需要按次序讀取三個文件(比如第一個文件存儲着第二個文件的路徑,第二個文件存儲着第三個文件的路徑,這時你就必須在前一個文件讀取成功後才能讀取下一個文件)。由於讀取文件是個很耗時的操作,我們不可能採取同步的方式讓引擎一直等待文件讀取,於是我們採用異步的方式來讀取這三個文件。

具體的執行過程是,當引擎發出第一個讀文件請求後,就轉而去執行其他的代碼(在瀏覽器環境下,讀文件是由瀏覽器層通過發送http請求來完成的,與js引擎無關)。一旦文件讀取完畢,引擎會重新回到該處繼續執行,從讀取到的文件中解析第二個文件的路徑,接着發送第二個讀文件請求,依次類推。

最初我們使用回調函數的方式來實現上述過程,這也是瀏覽器提供的最基本、最重要的異步機制。但是後來我們發現,隨着異步應用規模的增大,代碼嵌套導致程序的可讀性變差,可維護性大大降低,形成了所謂的“回調地獄”。

後來ES6推出了Promise語法來解決“回調地獄”,它將嵌套的異步調用改寫成了鏈式結構(感興趣的話可以參考我之前的前端異步方案之Promise(附實現代碼)一文),同時對回調函數方案進行了一定的功能增強。Promise本質上就是一個狀態機,不過它只能算一個單狀態機,因爲它的狀態只是能從“pending”轉爲成功或者失敗。由於一個Promise對象只能維護一個異步操作的狀態,所以當有多個異步操作時,就必須通過鏈式語法來實現。比如像下面這樣:

let p = new Promise(function(resolve, reject){
  ...
}).then(function(value){
  return new Promise(function(resolve, reject){
    ...
  })
}).then(function(value){
  return new Promise(function(resolve, reject){
    ...
  })
}).then(function(value){
  ...
})

誠然,這種結構比“回調地獄”容易讓人接受得多,但是它帶來了不少的冗餘代碼,畢竟那麼多的then方法,也會讓人看上去頭皮發麻。另外,Promise並沒有實現對多個異步操作的封裝,它只是“把這些異步操作串在一根繩子上,卻沒有封在一個黑盒裏”,因此以面向對象的角度來說,這不是一個很好的解決方案。

當出現了Generator函數後,我們發現,它與異步應用是完美契合的。我們想一下,Generator函數的特點是,可以在每個yield語句處中斷,產生一次輸出和輸入,並且可以記錄該中斷位置,下一次可以從該位置繼續向下執行。是不是和多個異步操作按序執行的要求如出一轍?

不過其實真正用Generator函數來封裝異步操作並沒有我們想象的那麼美好。Generator函數沒有自動執行的能力,它需要在Promise的then方法中一步步驅動執行。比如下面的例子:

let fetch = require('node-fetch');  //這是node環境下的模塊,類似於ajax、axios等

//這個函數就是我們封裝的兩個需要按序執行的異步任務
function* gen(){  
  let page1 = yield fetch("https://www.baidu.com");
  console.log(page1);
  let page2 = yield fetch("https://www.csdn.net");
  console.log(page2);
}

//接下來我們要執行這兩個異步任務
let g = gen();
let promise1 = g.next();  //函數執行到第一個fetch語句,去取百度首頁

promise1.then(function(data){
  let promise2 = g.next(data);  //繼續驅動Gnenerator函數執行,取csdn首頁,
  								//並將上個異步操作的結果回傳到Generator函數
  promise2.then(function(data2)){
    g.next(data2);   //繼續驅動Generator函數執行
  }
})

函數gen是我們封裝的異步任務,可以看到,除了使用了yield語句外,已經與同步代碼沒有任何差別,代碼邏輯非常容易理解。不過函數的執行過程,難免讓人覺得有點“糟心”。

這裏的fetch方法返回的其實是一個Promise對象,通過向它註冊then方法,我們可以知道fetch任務何時執行完。一旦fetch任務執行完了,我們就驅動Generator函數執行下一個異步任務。好吧,我們封裝了近乎完美的異步邏輯,卻敗給了糟糕的驅動邏輯!

實際上如果異步請求的返回結果不是Promise對象,那麼Generator函數的執行將舉步維艱。關於如何自動執行Generator函數,有兩個非官方的解決方案:Thunk函數和co模塊,如果感興趣可以參考阮一峯 Generator函數的異步應用,這裏不再詳解。我們直接來看ES工作組給出的解決方案:async函數。

三、async函數

1. 基本原理

上面已經提到,Generator函數可以很好地將異步操作封裝成接近同步操作的形式。但是封裝歸封裝,如果函數的執行仍然需要依賴回調或者鏈式語法,那這種封裝仍然很難說完美。

不過如果是下面這樣的代碼呢?

let fetch = require('node-fetch');

async function gen(){
  let page1 = await fetch("https://www.baidu.com");
  console.log(page1);
  let page2 = await fetch("https://www.csdn.net");
  console.log(page2);
  return page2;
}

gen(); //你只需要這一行代碼,就可以執行兩個異步任務

對比Generator函數的實現,這裏的gen只是把function關鍵字後面的星號*替換成了前面的async關鍵字,然後把函數中的yield關鍵字替換成了await關鍵字 – 仍然保留了接近同步代碼的形式。最不可思議的是,你可以用真正的同步方式來調用這個async函數!

只需要gen()這一行代碼,你就可以驅動函數開始執行。函數執行到第一個await語句時將發送獲取百度首頁的fetch請求,隨後引擎將轉而執行其他代碼。等fetch請求成功後,引擎將自動驅動gen函數向下執行,將fetch獲取到的數據保存在page1中,隨後執行console.log(page1)語句,接着再發送第二個獲取csdn首頁的請求。隨後又遇到第二個await語句,於是發送第二個fetch請求,並轉而執行其他的代碼。等第二個請求成功後重新回到gen函數,接着向下執行。

幾乎完全接近同步代碼的書寫方式,以及最簡單的調用方式!

顯而易見,async函數有着很好的語義。在function前面加上async意味着這個函數內包含了異步操作(async的中文釋義就是“異步”),在異步操作前面加await關鍵字表示需要等待該操作執行完成(await的中文釋義爲“等待”)。所以如果你有一定的英語基礎,那麼不用任何人解釋,你也應該可以看出這個函數在做什麼(之前我在寫C#代碼時其實也用過async函數,用法跟js中的完全一致,所以我懷疑js的async語法是不是從C#抄的,哈哈)。

實際上async返回的也是一個Promise對象,從這個Promise對象你可以獲取多個異步操作的最終結果。從字面上來看,async返回的只是一個普通的值(如上面所返回的變量page2),但是引擎會默認將其封裝成Promise,並且將返回值作爲then方法的參數傳進去,因此你可以像下面這樣爲async函數註冊回調函數:

gen().then(value => {
  ...
})

我們在這裏爲其註冊了then方法,引擎就會把page2作爲參數傳遞給then方法,也就是這裏的value,於是我們就可以在async函數全部執行完畢後對結果執行某些操作。同理,如果async函數拋出錯誤,會被註冊的catch回調所捕獲。

2. 語法規範

(1)await命令

await命令後面一般會跟一個異步調用,並且這個異步調用的返回值一般是Promise對象。當異步調用執行成功,Promise對象的狀態就會變成成功,這樣引擎就會自動向下執行async函數。

如果await後面返回的不是一個Promise對象,但是個帶有then方法的對象(這樣的對象稱爲thenable對象,它具有和Promise類似的能力),引擎會直接將其按照Promise的規則來處理。如果既不是Promise對象,也不是thenable對象,那麼引擎會將其視爲一個立即resolved的Promise對象,並直接返回這個值,如:

let a = await 123;
//等價於
let a = 123;

這裏123只是個普通的數值,它會被當做一個已經resolved的Promise,並且結果是數值123。實際上你可以認爲,在這樣的變量前加await關鍵字會被無視,引擎會當做沒有“看到”await關鍵字而順序向下執行。

(2)錯誤處理

如果async中的某個異步操作拋出了異常,那麼就等同於整個async函數失敗,這會導致async函數返回的Promise對象觸發reject,async函數會立即結束執行,並觸發我們爲async函數的返回的Promise註冊的onreject。如:

async function f() {
  //這個Promise拋出的異常導致整個async函數被reject
  await new Promise(function (resolve, reject) {
    throw new Error('出錯了');
  });
}

f()
.then(v => console.log(v))
.catch(e => console.log(e))  //捕獲到異常後立即執行了這裏的catch
// Error:出錯了

如果你不希望某個異步操作的失敗導致整個async函數直接退出,可以把這個異步操作放在try … catch代碼塊中,這樣當操作失敗後,async還可以繼續執行。

需要注意的是,多個await語句是同步觸發的,也就是說,在前一個await對應的異步操作完成之前,後一個是不會執行的。這對於需要按序觸發的異步操作來說很重要,但是如果這些await操作之間不存在繼發關係,使用async函數就會導致程序的性能下降。這時請使用Promise.all或Promise.race來封裝多個異步任務,以提升程序執行性能。總的來說,async函數的目的是封裝多個存在繼發關係的異步任務,如果不存在繼發關係,請儘量避免使用async函數。

關於async函數是如何實現的,這裏我們就不再詳解,感興趣的可以參考阮一峯 async異步函數。本質上來說,async函數就是爲Generator函數內置了一個自動執行器,使得函數的自動執行變得更簡單。

總結

Generator函數是ES6中一個全新的概念,它是一個藉助Iterator實現的狀態機。有了Generator函數後,異步應用完全可以以接近同步應用的代碼邏輯來實現,不過Generator函數的問題在於不便於自動執行。爲此,官方在ES2017中推出了async函數,通過爲Generator函數內置自動執行器來解決這個問題。

目前Generator函數和async函數在瀏覽器環境下的使用還不是很廣泛(主要是兼容性問題),不過在nodejs環境下它已經得到廣泛應用。一方面,nodejs是服務端的JavaScript環境,它包含了大量的異步操作;另一方面,升級nodejs版本要遠遠比升級瀏覽器版本簡單,因此受兼容性的影響很小。鑑於nodejs在前端技術棧中的地位越來越高,並且現代瀏覽器正在快速普及,掌握Generator和async函數這兩大利器勢在必行。本文只是對兩者的簡介,希望感興趣的可以深入研究。

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