本文是《深入掌握 ECMAScript 6 非同步程式設計》系列文章的第一篇。
- Generator函式的含義與用法
- Thunk函式的含義與用法
- co函式庫的含義與用法
- async函式的含義與用法
非同步程式設計對 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入門》。
(完)