Generator 函式的含義與用法

阮一峰發表於2015-04-24

本文是《深入掌握 ECMAScript 6 非同步程式設計》系列文章的第一篇。

非同步程式設計對 JavaScript 語言太重要。JavaScript 只有一根執行緒,如果沒有非同步程式設計,根本沒法用,非卡死不可。

以前,非同步程式設計的方法,大概有下面四種

  • 回撥函式
  • 事件監聽
  • 釋出/訂閱
  • Promise 物件

ECMAScript 6 (簡稱 ES6 )作為下一代 JavaScript 語言,將 JavaScript 非同步程式設計帶入了一個全新的階段。這組系列文章的主題,就是介紹更強大、更完善的 ES6 非同步程式設計方法。

新方法比較抽象,初學時,我常常感到費解,直到很久以後才想通,非同步程式設計的語法目標,就是怎樣讓它更像同步程式設計。這組系列文章,將幫助你深入理解 JavaScript 非同步程式設計的本質。所有將要講到的內容,都已經實現了。也就是說,馬上就能用,套用一句廣告語,就是"未來已來"。

一、什麼是非同步?

所謂"非同步",簡單說就是一個任務分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。比如,有一個任務是讀取檔案進行處理,非同步的執行過程就是下面這樣。

上圖中,任務的第一段是向作業系統發出請求,要求讀取檔案。然後,程式執行其他任務,等到作業系統返回檔案,再接著執行任務的第二段(處理檔案)。

這種不連續的執行,就叫做非同步。相應地,連續的執行,就叫做同步。

上圖就是同步的執行方式。由於是連續執行,不能插入其他任務,所以作業系統從硬碟讀取檔案的這段時間,程式只能乾等著。

二、回撥函式的概念

JavaScript 語言對非同步程式設計的實現,就是回撥函式。所謂回撥函式,就是把任務的第二段單獨寫在一個函式裡面,等到重新執行這個任務的時候,就直接呼叫這個函式。它的英語名字 callback,直譯過來就是"重新呼叫"。

讀取檔案進行處理,是這樣寫的。


fs.readFile('/etc/passwd', function (err, data) {
  if (err) throw err;
  console.log(data);
});

上面程式碼中,readFile 函式的第二個引數,就是回撥函式,也就是任務的第二段。等到作業系統返回了 /etc/passwd 這個檔案以後,回撥函式才會執行。

一個有趣的問題是,為什麼 Node.js 約定,回撥函式的第一個引數,必須是錯誤物件err(如果沒有錯誤,該引數就是 null)?原因是執行分成兩段,在這兩段之間丟擲的錯誤,程式無法捕捉,只能當作引數,傳入第二段。

三、Promise

回撥函式本身並沒有問題,它的問題出現在多個回撥函式巢狀。假定讀取A檔案之後,再讀取B檔案,程式碼如下。


fs.readFile(fileA, function (err, data) {
  fs.readFile(fileB, function (err, data) {
    // ...
  });
});

不難想象,如果依次讀取多個檔案,就會出現多重巢狀。程式碼不是縱向發展,而是橫向發展,很快就會亂成一團,無法管理。這種情況就稱為"回撥函式噩夢"(callback hell)。

Promise就是為了解決這個問題而提出的。它不是新的語法功能,而是一種新的寫法,允許將回撥函式的橫向載入,改成縱向載入。採用Promise,連續讀取多個檔案,寫法如下。


var readFile = require('fs-readfile-promise');

readFile(fileA)
.then(function(data){
  console.log(data.toString());
})
.then(function(){
  return readFile(fileB);
})
.then(function(data){
  console.log(data.toString());
})
.catch(function(err) {
  console.log(err);
});

上面程式碼中,我使用了 fs-readfile-promise 模組,它的作用就是返回一個 Promise 版本的 readFile 函式。Promise 提供 then 方法載入回撥函式,catch方法捕捉執行過程中丟擲的錯誤。

可以看到,Promise 的寫法只是回撥函式的改進,使用then方法以後,非同步任務的兩段執行看得更清楚了,除此以外,並無新意。

Promise 的最大問題是程式碼冗餘,原來的任務被Promise 包裝了一下,不管什麼操作,一眼看去都是一堆 then,原來的語義變得很不清楚。

那麼,有沒有更好的寫法呢?

四、協程

傳統的程式語言,早有非同步程式設計的解決方案(其實是多工的解決方案)。其中有一種叫做"協程"(coroutine),意思是多個執行緒互相協作,完成非同步任務。

協程有點像函式,又有點像執行緒。它的執行流程大致如下。

第一步,協程A開始執行。

第二步,協程A執行到一半,進入暫停,執行權轉移到協程B。

第三步,(一段時間後)協程B交還執行權。

第四步,協程A恢復執行。

上面流程的協程A,就是非同步任務,因為它分成兩段(或多段)執行。

舉例來說,讀取檔案的協程寫法如下。


function asnycJob() {
  // ...其他程式碼
  var f = yield readFile(fileA);
  // ...其他程式碼
}

上面程式碼的函式 asyncJob 是一個協程,它的奧妙就在其中的 yield 命令。它表示執行到此處,執行權將交給其他協程。也就是說,yield命令是非同步兩個階段的分界線。

協程遇到 yield 命令就暫停,等到執行權返回,再從暫停的地方繼續往後執行。它的最大優點,就是程式碼的寫法非常像同步操作,如果去除yield命令,簡直一模一樣。

五、Generator函式的概念

Generator 函式是協程在 ES6 的實現,最大特點就是可以交出函式的執行權(即暫停執行)。


function* gen(x){
  var y = yield x + 2;
  return y;
}

上面程式碼就是一個 Generator 函式。它不同於普通函式,是可以暫停執行的,所以函式名之前要加星號,以示區別。

整個 Generator 函式就是一個封裝的非同步任務,或者說是非同步任務的容器。非同步操作需要暫停的地方,都用 yield 語句註明。Generator 函式的執行方法如下。


var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面程式碼中,呼叫 Generator 函式,會返回一個內部指標(即遍歷器 )g 。這是 Generator 函式不同於普通函式的另一個地方,即執行它不會返回結果,返回的是指標物件。呼叫指標 g 的 next 方法,會移動內部指標(即執行非同步任務的第一段),指向第一個遇到的 yield 語句,上例是執行到 x + 2 為止。

換言之,next 方法的作用是分階段執行 Generator 函式。每次呼叫 next 方法,會返回一個物件,表示當前階段的資訊( value 屬性和 done 屬性)。value 屬性是 yield 語句後面表示式的值,表示當前階段的值;done 屬性是一個布林值,表示 Generator 函式是否執行完畢,即是否還有下一個階段。

六、Generator 函式的資料交換和錯誤處理

Generator 函式可以暫停執行和恢復執行,這是它能封裝非同步任務的根本原因。除此之外,它還有兩個特性,使它可以作為非同步程式設計的完整解決方案:函式體內外的資料交換和錯誤處理機制。

next 方法返回值的 value 屬性,是 Generator 函式向外輸出資料;next 方法還可以接受引數,這是向 Generator 函式體內輸入資料。


function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

上面程式碼中,第一個 next 方法的 value 屬性,返回表示式 x + 2 的值(3)。第二個 next 方法帶有引數2,這個引數可以傳入 Generator 函式,作為上個階段非同步任務的返回結果,被函式體內的變數 y 接收。因此,這一步的 value 屬性,返回的就是2(變數 y 的值)。

Generator 函式內部還可以部署錯誤處理程式碼,捕獲函式體外丟擲的錯誤。


function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){ 
    console.log(e);
  }
  return y;
}

var g = gen(1);
g.next();
g.throw('出錯了');
// 出錯了

上面程式碼的最後一行,Generator 函式體外,使用指標物件的 throw 方法丟擲的錯誤,可以被函式體內的 try ... catch 程式碼塊捕獲。這意味著,出錯的程式碼與處理錯誤的程式碼,實現了時間和空間上的分離,這對於非同步程式設計無疑是很重要的。

七、Generator 函式的用法

下面看看如何使用 Generator 函式,執行一個真實的非同步任務。


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

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

上面程式碼中,Generator 函式封裝了一個非同步操作,該操作先讀取一個遠端介面,然後從 JSON 格式的資料解析資訊。就像前面說過的,這段程式碼非常像同步操作,除了加上了 yield 命令。

執行這段程式碼的方法如下。


var g = gen();
var result = g.next();

result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

上面程式碼中,首先執行 Generator 函式,獲取遍歷器物件,然後使用 next 方法(第二行),執行非同步任務的第一階段。由於 Fetch 模組返回的是一個 Promise 物件,因此要用 then 方法呼叫下一個next 方法。

可以看到,雖然 Generator 函式將非同步操作表示得很簡潔,但是流程管理卻不方便(即何時執行第一階段、何時執行第二階段)。本系列的後面部分,就將介紹如何自動化非同步任務的流程管理。另外,本文對 Generator 函式的介紹很簡單,詳盡的教程請閱讀我寫的《ECMAScript 6入門》

(完)

相關文章