這篇文章是講JS非同步原理和實現方式的第四篇文章,前面三篇是:
setTimeout和setImmediate到底誰先執行,本文讓你徹底理解Event Loop
從釋出訂閱模式入手讀懂Node.js的EventEmitter原始碼
本文主要會講Generator的運用和實現原理,然後我們會去讀一下co模組的原始碼,最後還會提一下async/await。
本文全部例子都在GitHub上:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/JavaScript/Generator
Generator
非同步程式設計一直是JS的核心之一,業界也是一直在探索不同的解決方法,從“回撥地獄”到釋出訂閱模式,再到Promise,都是在優化非同步程式設計。儘管Promise已經很優秀了,也不會陷入“回撥地獄”,但是巢狀層數多了也會有一連串的then
,始終不能像同步程式碼那樣直接往下寫就行了。Generator是ES6引入的進一步改善非同步程式設計的方案,下面我們先來看看基本用法。
基本用法
Generator的中文翻譯是“生成器”,其實他要乾的事情也是一個生成器,一個函式如果加了*
,他就會變成一個生成器函式,他的執行結果會返回一個迭代器物件,比如下面的程式碼:
// gen是一個生成器函式
function* gen() {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
}
let itor = gen(); // 生成器函式執行後會返回一個迭代器物件,即itor。
next
ES6規範中規定迭代器必須有一個next
方法,這個方法會返回一個物件,這個物件具有done
和value
兩個屬性,done
表示當前迭代器內容是否已經執行完,執行完為true
,否則為false
,value
表示當前步驟返回的值。在generator
具體運用中,每次遇到yield
關鍵字都會暫停執行,當呼叫迭代器的next
時,會將yield
後面表示式的值作為返回物件的value
,比如上面生成器的執行結果如下:
我們可以看到第一次調next
返回的就是第一個yeild
後面表示式的值,也就是1。需要注意的是,整個迭代器目前暫停在了第一個yield
這裡,給變數a
賦值都沒執行,要呼叫下一個next
的時候才會給變數a
賦值,然後一直執行到第二個yield
。那應該給a
賦什麼值呢?從程式碼來看,a
的值應該是yield
語句的返回值,但是yield
本身是沒有返回值的,或者說返回值是undefined
,如果要給a
賦值需要下次調next
的時候手動傳進去,我們這裡傳一個4,4就會作為上次yield
的返回值賦給a
:
可以看到第二個yield
後面的表示式a + 2
的值是6,這是因為我們傳進去的4被作為上一個yield
的返回值了,然後計算a + 2
自然就是6了。
我們繼續next
,把這個迭代器走完:
上圖是接著前面執行的,圖中第一個next
返回的value
是NaN
是因為我們調next
的時候沒有傳引數,也就是說b
為undefined
,undefined + 3
就為NaN
了 。最後一個next
其實是把函式體執行完了,這時候的value
應該是這個函式return
的值,但是因為我們沒有寫return
,預設就是return undefined
了,執行完後done
會被置為true
。
throw
迭代器還有個方法是throw
,這個方法可以在函式體外部丟擲錯誤,然後在函式裡面捕獲,還是上面那個例子:
function* gen() {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
}
let itor = gen();
我們這次不用next
執行了,直接throw
錯誤出來:
這個錯誤因為我們沒有捕獲,所以直接拋到最外層來了,我們可以在函式體裡面捕獲他,稍微改下:
function* gen() {
try {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
} catch (e) {
console.log(e);
}
}
let itor = gen();
然後再來throw
下:
這個圖可以看出來,錯誤在函式裡裡面捕獲了,走到了catch
裡面,這裡面只有一個console
同步程式碼,整個函式直接就執行結束了,所以done
變成true
了,當然catch
裡面可以繼續寫yield
然後用next
來執行。
return
迭代器還有個return
方法,這個方法就很簡單了,他會直接終止當前迭代器,將done
置為true
,這個方法的引數就是迭代器的value
,還是上面的例子:
function* gen() {
let a = yield 1;
let b = yield a + 2;
yield b + 3;
}
let itor = gen();
這次我們直接呼叫return
:
yield*
簡單理解,yield*
就是在生成器裡面呼叫另一個生成器,但是他並不會佔用一個next
,而是直接進入被呼叫的生成器去執行。
function* gen() {
let a = yield 1;
let b = yield a + 2;
}
function* gen2() {
yield 10 + 5;
yield* gen();
}
let itor = gen2();
上面程式碼我們第一次呼叫next
,值自然是10 + 5
,即15,然後第二次呼叫next
,其實就走到了yield*
了,這其實就相當於呼叫了gen
,然後執行他的第一個yield
,值就是1。
協程
其實Generator就是實現了協程,協程是一個比執行緒還小的概念。一個程式可以有多個執行緒,一個執行緒可以有多個協程,但是一個執行緒同時只能有一個協程在執行。這個意思就是說如果當前協程可以執行,比如同步程式碼,那就執行他,如果當前協程暫時不能繼續執行,比如他是一個非同步讀檔案的操作,那就將它掛起,然後去執行其他協程,等這個協程結果回來了,可以繼續了再來執行他。yield
其實就相當於將當前任務掛起了,下次呼叫再從這裡開始。協程這個概念其實很多年前就已經被提出來了,其他很多語言也有自己的實現。Generator相當於JS實現的協程。
非同步應用
前面講了Generator的基本用法,我們用它來處理一個非同步事件看看。我還是使用前面文章用到過的例子,三個網路請求,請求3依賴請求2的結果,請求2依賴請求1的結果,如果使用回撥是這樣的:
const request = require("request");
request('https://www.baidu.com', function (error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 1');
request('https://www.baidu.com', function(error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 2');
request('https://www.baidu.com', function(error, response) {
if (!error && response.statusCode == 200) {
console.log('get times 3');
}
})
}
})
}
});
我們這次使用Generator來解決“回撥地獄”:
const request = require("request");
function* requestGen() {
function sendRequest(url) {
request(url, function (error, response) {
if (!error && response.statusCode == 200) {
console.log(response.body);
// 注意這裡,引用了外部的迭代器itor
itor.next(response.body);
}
})
}
const url = 'https://www.baidu.com';
// 使用yield發起三個請求,每個請求成功後再繼續調next
const r1 = yield sendRequest(url);
console.log('r1', r1);
const r2 = yield sendRequest(url);
console.log('r2', r2);
const r3 = yield sendRequest(url);
console.log('r3', r3);
}
const itor = requestGen();
// 手動調第一個next
itor.next();
這個例子中我們在生成器裡面寫了一個請求方法,這個方法會去發起網路請求,每次網路請求成功後又繼續呼叫next執行後面的yield
,最後是在外層手動調一個next
觸發這個流程。這其實就類似一個尾呼叫,這樣寫可以達到效果,但是在requestGen
裡面引用了外面的迭代器itor
,耦合很高,而且不好複用。
thunk函式
為了解決前面說的耦合高,不好複用的問題,就有了thunk函式。thunk函式理解起來有點繞,我先把程式碼寫出來,然後再一步一步來分析它的執行順序:
function Thunk(fn) {
return function(...args) {
return function(callback) {
return fn.call(this, ...args, callback)
}
}
}
function run(fn) {
let gen = fn();
function next(err, data) {
let result = gen.next(data);
if(result.done) return;
result.value(next);
}
next();
}
// 使用thunk方法
const request = require("request");
const requestThunk = Thunk(request);
function* requestGen() {
const url = 'https://www.baidu.com';
let r1 = yield requestThunk(url);
console.log(r1.body);
let r2 = yield requestThunk(url);
console.log(r2.body);
let r3 = yield requestThunk(url);
console.log(r3.body);
}
// 啟動執行
run(requestGen);
這段程式碼裡面的Thunk函式返回了好幾層函式,我們從他的使用入手一層一層剝開看:
requestThunk
是Thunk執行的返回值,也就是第一層返回值,引數是request
,也就是:function(...args) { return function(callback) { return request.call(this, ...args, callback); // 注意這裡呼叫的是request } }
run
函式的引數是生成器,我們看看他到底幹了啥:- run裡面先呼叫生成器,拿到迭代器
gen
,然後自定義了一個next
方法,並呼叫這個next
方法,為了便於區分,我這裡稱這個自定義的next
為區域性next
區域性
next
會呼叫生成器的next
,生成器的next
其實就是yield requestThunk(url)
,引數是我們傳進去的url
,這就調到我們前面的那個方法,這個yield
返回的value
其實是:function(callback) { return request.call(this, url, callback); }
- 檢測迭代器是否已經迭代完畢,如果沒有,就繼續呼叫第二步的這個函式,這個函式其實才真正的去
request
,這時候傳進去的引數是區域性next
,區域性next
也作為了request
的回撥函式。 - 這個回撥函式在執行時又會調
gen.next
,這樣生成器就可以繼續往下執行了,同時gen.next
的引數是回撥函式的data
,這樣,生成器裡面的r1
其實就拿到了請求的返回值。
- run裡面先呼叫生成器,拿到迭代器
Thunk函式就是這樣一種可以自動執行Generator的函式,因為Thunk函式的包裝,我們在Generator裡面可以像同步程式碼那樣直接拿到yield
非同步程式碼的返回值。
co模組
co模組是一個很受歡迎的模組,他也可以自動執行Generator,他的yield後面支援thunk和Promise,我們先來看看他的基本使用,然後再去分析下他的原始碼。
官方GitHub:https://github.com/tj/co
基本使用
支援thunk
前面我們講了thunk函式,我們還是從thunk函式開始。程式碼還是用我們前面寫的thunk函式,但是因為co支援的thunk是隻接收回撥函式的函式形式,我們使用時需要調整下:
// 還是之前的thunk函式
function Thunk(fn) {
return function(...args) {
return function(callback) {
return fn.call(this, ...args, callback)
}
}
}
// 將我們需要的request轉換成thunk
const request = require('request');
const requestThunk = Thunk(request);
// 轉換後的requestThunk其實可以直接用了
// 用法就是 requestThunk(url)(callback)
// 但是我們co接收的thunk是 fn(callback)形式
// 我們轉換一下
// 這時候的baiduRequest也是一個函式,url已經傳好了,他只需要一個回撥函式做引數就行
// 使用就是這樣:baiduRequest(callback)
const baiduRequest = requestThunk('https://www.baidu.com');
// 引入co執行, co的引數是一個Generator
// co的返回值是一個Promise,我們可以用then拿到他的結果
const co = require('co');
co(function* () {
const r1 = yield baiduRequest;
const r2 = yield baiduRequest;
const r3 = yield baiduRequest;
return {
r1,
r2,
r3,
}
}).then((res) => {
// then裡面就可以直接拿到前面返回的{r1, r2, r3}
console.log(res);
});
支援Promise
其實co官方是建議yield後面跟Promise的,雖然支援thunk,但是未來可能會移除。使用Promise,我們程式碼寫起來其實更簡單,直接用fetch就行,不用包裝Thunk。
const fetch = require('node-fetch');
const co = require('co');
co(function* () {
// 直接用fetch,簡單多了,fetch返回的就是Promise
const r1 = yield fetch('https://www.baidu.com');
const r2 = yield fetch('https://www.baidu.com');
const r3 = yield fetch('https://www.baidu.com');
return {
r1,
r2,
r3,
}
}).then((res) => {
// 這裡同樣可以拿到{r1, r2, r3}
console.log(res);
});
原始碼分析
本文的原始碼分析基於co模組4.6.0版本,原始碼:https://github.com/tj/co/blob/master/index.js
仔細看原始碼會發現他程式碼並不多,總共兩百多行,一半都是在進行yield後面的引數檢測和處理,檢測他是不是Promise,如果不是就轉換為Promise,所以即使你yield後面傳的thunk,他還是會轉換成Promise處理。轉換Promise的程式碼相對比較獨立和簡單,我這裡不詳細展開了,這裡主要還是講一講核心方法co(gen)
。下面是我複製的去掉了註釋的簡化程式碼:
function co(gen) {
var ctx = this;
var args = slice.call(arguments, 1);
return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.apply(ctx, args);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
onFulfilled();
function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
return null;
}
function onRejected(err) {
var ret;
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
}
function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "' + String(ret.value) + '"'));
}
});
}
- 從整體結構看,co的引數是一個Generator,返回值是一個Promise,幾乎所有邏輯程式碼都在這個Promise裡面,這也是我們使用時用then拿結果的原因。
- Promise裡面先把Generator拿出來執行,得到一個迭代器
gen
手動呼叫一次
onFulfilled
,開啟迭代onFulfilled
接收一個引數res
,第一次呼叫是沒有傳這個引數,這個引數主要是用來接收後面的then返回的結果。- 然後呼叫
gen.next
,注意這個的返回值ret的形式是{value, done},然後將這個ret傳給區域性的next
然後執行區域性next,他接收的引數是yield返回值{value, done}
- 這裡先檢測迭代是否完成,如果完成了,就直接將整個promise resolve。
- 這裡的value是yield後面表示式的值,可能是thunk,也可能是promise
- 將value轉換成promise
- 將轉換後的promise拿出來執行,成功的回撥是前面的
onFulfilled
- 我們再來看下
onFulfilled
,這是第二次執行onFulfilled
了。這次執行的時候傳入的引數res是上次非同步promise的執行結果,對應我們的fetch就是拿回來的資料,這個資料傳給第二個gen.next
,效果就是我們程式碼裡面的賦值給了第一個yield
前面的變數r1
。然後繼續區域性next,這個next其實就是執行第二個非同步Promise了。這個promise的成功回撥又繼續呼叫gen.next
,這樣就不斷的執行下去,直到done
變成true
為止。 - 最後看一眼
onRejected
方法,這個方法其實作為了非同步promise的錯誤分支,這個函式裡面直接呼叫了gen.throw
,這樣我們在Generator裡面可以直接用try...catch...
拿到錯誤。需要注意的是gen.throw
後面還繼續呼叫了next(ret)
,這是因為在Generator的catch
分支裡面還可能繼續有yield
,比如錯誤上報的網路請求,這時候的迭代器並不一定結束了。
async/await
最後提一下async/await
,先來看一下用法:
const fetch = require('node-fetch');
async function sendRequest () {
const r1 = await fetch('https://www.baidu.com');
const r2 = await fetch('https://www.baidu.com');
const r3 = await fetch('https://www.baidu.com');
return {
r1,
r2,
r3,
}
}
// 注意async返回的也是一個promise
sendRequest().then((res) => {
console.log('res', res);
});
咋一看這個跟前面promise版的co是不是很像,返回值都是一個promise,只是Generator換成了一個async
函式,函式裡面的yield
換成了await
,而且外層不需要co來包裹也可以自動執行了。其實async函式就是Generator加自動執行器的語法糖,可以理解為從語言層面支援了Generator的自動執行。上面這段程式碼跟co版的promise其實就是等價的。
總結
- Generator是一種更現代的非同步解決方案,在JS語言層面支援了協程
- Generator的返回值是一個迭代器
- 這個迭代器需要手動調
next
才能一條一條執行yield
next
的返回值是{value, done},value
是yield後面表示式的值yield
語句本身並沒有返回值,下次調next
的引數會作為上一個yield
語句的返回值- Generator自己不能自動執行,要自動執行需要引入其他方案,前面講
thunk
的時候提供了一種方案,co
模組也是一個很受歡迎的自動執行方案 - 這兩個方案的思路有點類似,都是先寫一個區域性的方法,這個方法會去呼叫
gen.next
,同時這個方法本身又會傳到回撥函式或者promise的成功分支裡面,非同步結束後又繼續呼叫這個區域性方法,這個區域性方法又呼叫gen.next
,這樣一直迭代,直到迭代器執行完畢。 async/await
其實是Generator和自動執行器的語法糖,寫法和實現原理都類似co模組的promise模式。
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges