ES6 帶來了很多新的特性,其中生成器、yield等能對js金字塔式的非同步回撥做到很好地解決,而基於此封裝的co框架能讓我們完全以同步的方式來編寫非同步程式碼。這篇文章對生成器函式(GeneratorFunction)及框架thunkify、co的核心程式碼做了比較徹底的分析。co的使用還是比較廣泛的,除了我們日常的編碼要用到使我們的程式碼邏輯更清晰易懂外,一些知名框架也是基於co實現的,比如被稱為下一代的Nodejs web框架的koa等。
生成器函式
生成器函式是寫成:
1 |
function* func(){} |
格式的函式,其本質也是一個函式,所以它具備普通函式所具有的所有特性。除此之外,它還具有以下有用特性:
- 執行生成器函式後返回一個生成器(Generator),且生成器具有throw()方法,可手動丟擲一個異常,也常被用於判斷是否是生成器;
- 在生成器函式內部可以使用yield(或者yield*),函式執行到yield的時候都會暫停執行,並返回yield的右值(函式上下文,如變數的繫結等資訊會保留),通過生成器的next()方法會返回一個物件,含當前yield右邊表示式的值(value屬性),以及generator函式是否已經執行完(done屬性)等的資訊。每次執行next()方法,都會從上次執行的yield的地方往下,直到遇到下一個yield並返回包含相關執行資訊的物件後暫停,然後等待下一個next()的執行;
- 生成器的next()方法返回的是包含yield右邊表示式值及是否執行完畢資訊的物件;而next()方法的引數是上一個暫停處yield的返回值。
下面用例子說明:
例1:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function test(){ return 'b'; } function* func(){ var a = yield 'a'; console.log('gen:',a);// gen: undefined var b = yield test(); console.log('gen:',b);// gen: undefined } var func1 = func(); var a = func1.next(); console.log('next:', a);// next: { value: 'a', done: false } var b = func1.next(); console.log('next:', b);// next: { value: 'b', done: false } var c = func1.next(); console.log('next:', c);// next: { value: undefined, done: true } |
根據上面說過的第3條執行準則:“生成器的next()方法返回的是包含yield右邊表示式值及是否執行完畢資訊的物件;而next()方法的引數是上一個暫停處yield的返回值”,因為我們沒有往生成器的next()中傳入任何值,所以:var a = yield ‘a’;中a的值為undefined。
那我們可以將例子稍微修改下:
例2:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function test(){ return 'b'; } function* func(){ var a = yield 'a'; console.log('gen:',a);// gen:1 var b = yield test(); console.log('gen:',b);// gen:2 } var func2 = func(); var a = func2.next(); console.log('next:', a);// next: { value: 'a', done: false } var b = func2.next(1); console.log('next:', b);// next: { value: 'b', done: false } var c = func2.next(2); console.log('next:', c);// next: { value: undefined, done: true } |
這個就比較清晰明瞭了,不再做過多解釋。
關於yield*
yield暫停執行並只返回右值,而yield*則將函式委託到另一個生成器或可迭代的物件(如:字串、陣列、類陣列以及ES6的Map、Set等)。舉例如下:
arguments
1 2 3 4 5 6 7 8 9 |
function* genFunc(){ yield arguments; yield* arguments; } var gen = genFunc(1,2); console.log(gen.next().value); // { '0': 1, '1': 2 } console.log(gen.next().value); // 1 console.log(gen.next().value); // 2 |
Generator
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function* gen1(){ yield 2; yield 3; } function* gen2(){ yield 1; yield* gen1(); yield 4; } var g2 = gen2(); console.log(g2.next().value); // 1 console.log(g2.next().value); // 2 console.log(g2.next().value); // 3 console.log(g2.next().value); // 4 |
thunk函式
在co的應用中,為了能像寫同步程式碼那樣書寫非同步程式碼,比較多的使用方式是使用thunk函式(但不是唯一方式,還可以是:Promise)。比如讀取檔案內容的一步函式fs.readFile()方法,轉化為thunk函式的方式如下:
1 2 3 4 5 |
function readFile(path, encoding){ return function(cb){ fs.readFile(path, encoding, cb); }; } |
那什麼叫thunk函式呢?
thunk函式具備以下兩個要素:
- 有且只有一個引數是callback的函式;
- callback的第一個引數是error。
使用thunk函式,同時結合co我們就可以像寫同步程式碼那樣來寫書寫非同步程式碼,先來個例子感受下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
var co = require('co'), fs = require('fs'), Promise = require('es6-promise').Promise; function readFile(path, encoding){ return function(cb){ // thunk函式 fs.readFile(path, encoding, cb); }; } co(function* (){// 外面不可見,但在co內部其實已經轉化成了promise.then().then()..鏈式呼叫的形式 var a = yield readFile('a.txt', {encoding: 'utf8'}); console.log(a); // a var b = yield readFile('b.txt', {encoding: 'utf8'}); console.log(b); // b var c = yield readFile('c.txt', {encoding: 'utf8'}); console.log(c); // c return yield Promise.resolve(a+b+c); }).then(function(val){ console.log(val); // abc }).catch(function(error){ console.log(error); }); |
是不是很酷?真的很酷!
其實,對於每次都去自己書寫一個thunk函式還是比較麻煩的,有一個框架thunkify可以幫我們輕鬆實現,修改後的程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var co = require('co'), thunkify = require('thunkify'), fs = require('fs'), Promise = require('es6-promise').Promise; var readFile = thunkify(fs.readFile); co(function* (){// 外面不可見,但在co內部其實已經轉化成了promise.then().then()..鏈式呼叫的形式 var a = yield readFile('a.txt', {encoding: 'utf8'}); console.log(a); // a var b = yield readFile('b.txt', {encoding: 'utf8'}); console.log(b); // b var c = yield readFile('c.txt', {encoding: 'utf8'}); console.log(c); // c return yield Promise.resolve(a+b+c); }).then(function(val){ console.log(val); // abc }).catch(function(error){ console.log(error); }); |
對於thunkify框架的理解註釋如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
/** * Module dependencies. */ var assert = require('assert'); /** * Expose `thunkify()`. */ module.exports = thunkify; /** * Wrap a regular callback `fn` as a thunk. * * @param {Function} fn * @return {Function} * @api public */ function thunkify(fn) { assert('function' == typeof fn, 'function required'); // 返回一個包含thunk函式的函式,返回的thunk函式用於執行yield,而外圍這個函式用於給thunk函式傳遞引數 return function() { var args = new Array(arguments.length); // 快取當前上下文環境,給fn提供執行環境 var ctx = this; // 將引數類陣列轉化為陣列(實現方式略顯臃腫,可直接用Array.prototype.slice.call(arguments)實現) for (var i = 0; i < args.length; ++i) { args[i] = arguments[i]; } // 真正的thunk函式(有且只有一個引數是callback的函式,且callback的第一個引數為error) // 類似於: // function(cb) {fs.readFile(path, {encoding: 'utf8}, cb)} return function(done) { var called; // 將回撥函式再包裹一層,避免重複呼叫;同時,將包裹了的真正的回撥函式push進引數陣列 args.push(function() { if (called) return; called = true; done.apply(null, arguments); }); try { // 在ctx上下文執行fn(一般是非同步函式,如:fs.readFile) // 並將執行thunkify之後返回的函式的引數(含done回撥)傳入,類似於執行: // fs.readFile(path, {encoding: 'utf8}, done) // 關於done是做什麼用,則是在co庫內 fn.apply(ctx, args); } catch (err) { done(err); } } } }; |
程式碼並不複雜,看註釋應該就能看懂了。
co框架
我們先將對核心部分加了註釋的整個框架列出在下面,你可以先大概看下心裡有個數,也可以在下面分析整個執行邏輯後回過頭來細看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
/** * slice() reference. */ var slice = Array.prototype.slice; /** * Expose `co`. */ module.exports = co['default'] = co.co = co; /** * Wrap the given generator `fn` into a * function that returns a promise. * This is a separate function so that * every `co()` call doesn't create a new, * unnecessary closure. * * @param {GeneratorFunction} fn * @return {Function} * @api public */ co.wrap = function(fn) { createPromise.__generatorFunction__ = fn; return createPromise; function createPromise() { return co.call(this, fn.apply(this, arguments)); } }; /** * Execute the generator function or a generator * and return a promise. * * @param {Function} fn * @return {Promise} * @api public */ // gen必須是一個生成器函式(會執行該函式並返回生成器)或者是一個生成器(generator函式的返回值) function co(gen) { // 記錄上下文環境 var ctx = this; // 除gen之外的其他引數 var args = slice.call(arguments, 1) // we wrap everything in a promise to avoid promise chaining, // which leads to memory leak errors. // see https://github.com/tj/co/issues/180 // 返回一個Promise例項,所以可以以下面這種方式呼叫co: /** * co(function*(){}).then(function(val){ * * }); * */ return new Promise(function(resolve, reject) { // 如果gen是一個函式則將其置為函式的返回值 if (typeof gen === 'function') { gen = gen.apply(ctx, args); } // 如果gen不是生成器,則直接返回 if (!gen || typeof gen.next !== 'function') { return resolve(gen); } // 核心方法,啟動generator的執行 onFulfilled(); /** * @param {Mixed} res * @return {Promise} * @api private */ // res記錄的是:上一個yield的返回值中value的值({done:false,value:''}中value的值) // ret記錄的是:本次yield的返回值(整個{done:false,value:''}) // generator相關:執行生成器的next()方法的時候,會在當前yield處執行完畢並停住, // next()方法返回的是yield執行後的狀態(done)及yield 表示式返回的值(value), // 而next()方法內的引數會作為:var a=yield cb();a的值,所以往下看 /** * 假設:co(function*(){ * var a = yield readFile('a.txt'); * console.log(a); * var b = yield readFile('b.txt); * console.log(b); * }); * 那麼根據上面generator的理論,res就是a,b的值 * */ function onFulfilled(res) { var ret; try { // 返回的是co裡yield後面表示式的值。如果co裡yield的是thunk函式那ret.value就是thunk函式 ret = gen.next(res); } catch (e) { return reject(e); } next(ret); } /** * @param {Error} err * @return {Promise} * @api private */ function onRejected(err) { var ret; try { ret = gen.throw(err); } catch (e) { return reject(e); } next(ret); } /** * Get the next value in the generator, * return a promise. * * @param {Object} ret * @return {Promise} * @api private */ function next(ret) { // 執行完畢的話,如果外層呼叫的是: /** * co(function*(){ * return yield Promise.resolve(1); * }).then(function(val){ * console.log(val); // 1 * }); * */ // 則ret.value就是上面傳遞到then成功回撥裡val的值 if (ret.done) { return resolve(ret.value); } // 還沒結束的話將ret.value轉化為Promise例項,相當於執行: // promise.then(onFulfilled).then(onFulfilled).then(onFulfilled)... var value = toPromise.call(ctx, ret.value); if (value && isPromise(value)) { // 此時onFulfilled裡引數傳入的就是上一個yield的返回值的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) + '"')); } }); } /** * Convert a `yield`ed value into a promise. * * @param {Mixed} obj * @return {Promise} * @api private */ function toPromise(obj) { if (!obj) return obj; if (isPromise(obj)) return obj; if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj); if ('function' == typeof obj) return thunkToPromise.call(this, obj); if (Array.isArray(obj)) return arrayToPromise.call(this, obj); if (isObject(obj)) return objectToPromise.call(this, obj); return obj; } /** * Convert a thunk to a promise. * * @param {Function} * @return {Promise} * @api private */ function thunkToPromise(fn) { var ctx = this; return new Promise(function(resolve, reject) { fn.call(ctx, function(err, res) { if (err) return reject(err); if (arguments.length > 2) res = slice.call(arguments, 1); resolve(res); }); }); } /** * Convert an array of "yieldables" to a promise. * Uses `Promise.all()` internally. * * @param {Array} obj * @return {Promise} * @api private */ function arrayToPromise(obj) { return Promise.all(obj.map(toPromise, this)); } /** * Convert an object of "yieldables" to a promise. * Uses `Promise.all()` internally. * * @param {Object} obj * @return {Promise} * @api private */ function objectToPromise(obj) { var results = new obj.constructor(); var keys = Object.keys(obj); var promises = []; for (var i = 0; i < keys.length; i++) { var key = keys[i]; var promise = toPromise.call(this, obj[key]); if (promise && isPromise(promise)) defer(promise, key); else results[key] = obj[key]; } return Promise.all(promises).then(function() { return results; }); function defer(promise, key) { // predefine the key in the result results[key] = undefined; promises.push(promise.then(function(res) { results[key] = res; })); } } /** * Check if `obj` is a promise. * * @param {Object} obj * @return {Boolean} * @api private */ function isPromise(obj) { return 'function' == typeof obj.then; } /** * Check if `obj` is a generator. * * @param {Mixed} obj * @return {Boolean} * @api private */ function isGenerator(obj) { return 'function' == typeof obj.next && 'function' == typeof obj.throw; } /** * Check if `obj` is a generator function. * * @param {Mixed} obj * @return {Boolean} * @api private */ function isGeneratorFunction(obj) { var constructor = obj.constructor; if (!constructor) return false; if ('GeneratorFunction' === constructor.name || 'GeneratorFunction' === constructor.displayName) return true; return isGenerator(constructor.prototype); } /** * Check for plain object. * * @param {Mixed} val * @return {Boolean} * @api private */ function isObject(val) { return Object == val.constructor; } |
下面,我們基於我們之前的例子對co的執行流程做一下分析。
我們的例子是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
var co = require('co'), thunkify = require('thunkify'), fs = require('fs'), Promise = require('es6-promise').Promise; function readFile(path, encoding){ return function(cb){ fs.readFile(path, encoding, cb); }; } //var readFile = thunkify(fs.readFile); co(function* (){// 外面不可見,但在co內部其實已經轉化成了promise.then().then()..鏈式呼叫的形式 var a = yield readFile('a.txt', {encoding: 'utf8'}); console.log(a); // a var b = yield readFile('b.txt', {encoding: 'utf8'}); console.log(b); // b var c = yield readFile('c.txt', {encoding: 'utf8'}); console.log(c); // c return yield Promise.resolve(a+b+c); }).then(function(val){ console.log(val); // abc }).catch(function(error){ console.log(error); }); |
首先,執行co()函式,內部除了快取當前執行上下文環境、除generator函式之外的引數處理,主要返回一個Promise例項:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 記錄上下文環境 var ctx = this; // 除gen之外的其他引數 var args = slice.call(arguments, 1) // we wrap everything in a promise to avoid promise chaining, // which leads to memory leak errors. // see https://github.com/tj/co/issues/180 // 返回一個Promise例項,所以可以以下面這種方式呼叫co: /** * co(function*(){}).then(function(val){ * * }); * */ return new Promise(function(resolve, reject) { }); |
我們主要看這個Promise內部做了什麼。
1 2 3 |
if (typeof gen === 'function') { gen = gen.apply(ctx, args); } |
首先,判斷co()函式的第一個引數是否是函式,是的話將除gen之外的引數傳給該函式並返回給gen;在這裡因為gen是一個生成器函式,所以返回一個生成器;
1 2 3 |
if (!gen || typeof gen.next !== 'function') { return resolve(gen); } |
後面判斷如果gen此時不是一個生成器,則直接執行Promise的resolve,其實就是將gen傳回給:co().then(function(val){});裡的val了;
我們這個例子gen是一個生成器,則繼續往下執行。
1 |
onFulfilled(); |
後面我們就遇到了co的核心函式:onFulfilled。我們看下這個函式做了什麼。
1 2 3 4 5 6 7 8 9 |
function onFulfilled(res) { var ret; try { ret = gen.next(res); } catch (e) { return reject(e); } next(ret); } |
為了防止分心,裡面錯誤的處理我們先暫時不理。
第一次執行該方法,res值為undefined,然後執行生成器的next()方法,對應我們例子裡就是執行:
1 |
var a = yield readFile('a.txt', {encoding: 'utf8'}); |
那麼ret是一個物件,大概是這樣:
1 2 3 4 5 6 |
{ done: false, value: function(cb){ fs.readFile(path, encoding, cb); } } |
然後將ret傳給next函式。next函式是:
1 2 3 4 5 6 7 8 9 10 11 |
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) + '"')); } |
首先判斷生成器內部是否已經執行完,執行完則將執行結果resolve出去。很明顯我們例子裡才執行到第一個yield,並沒有執行完。沒執行完,則將ret.value轉化為一個Promise例項,我們這裡是一個thunk函式,所以toPromise真正執行的是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
function toPromise(obj) { if (!obj) return obj; if (isPromise(obj)) return obj; if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj); if ('function' == typeof obj) return thunkToPromise.call(this, obj); if (Array.isArray(obj)) return arrayToPromise.call(this, obj); if (isObject(obj)) return objectToPromise.call(this, obj); return obj; } /** * Convert a thunk to a promise. * * @param {Function} * @return {Promise} * @api private */ function thunkToPromise(fn) { var ctx = this; return new Promise(function(resolve, reject) { fn.call(ctx, function(err, res) { if (err) return reject(err); if (arguments.length > 2) res = slice.call(arguments, 1); resolve(res); }); }); } |
執行後其實就是直接返回了一個Promise例項。而這裡面,也對fn做了執行,fn是:function(cb){},對應到這裡,function(err, res){…}就是被傳入到fn中的cb,第一個引數就是error物件,第二個引數res就是讀取檔案後資料,然後執行resolve,將結果傳到下一個then方法的成功函式內,而在這裡對應的是:
1 2 3 |
if (value && isPromise(value)) { return value.then(onFulfilled, onRejected); } |
其實也就是onFulFilled的引數res。根據上面第三條執行準則,我們知道,res是被傳入到生成器的next()方法裡的,其實也就是對應co內生成器函式引數裡的var a = yield readFile(‘a.txt’,{encoding:’utf8′});裡的a的值,從而實現了類似於同步的變成正規化。
這樣,整個基於thunk函式的co框架程式設計也就理通了,其他的Promise、Generator、GeneratorFunction、Object、Array模式的類似,不再做過多分析。
理解了co的執行邏輯,我們就能更好的掌握其用法,對於後續使用koa等基於co編寫的框架我們也能更快速地上手。
co的簡版
為了更方便快捷的理解co的執行邏輯,在網路上還有一個簡版的實現,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function co(generator) { return function(fn) { var gen = generator(); function next(err, result) { if(err){ return fn(err); } var step = gen.next(result); if (!step.done) { step.value(next); } else { fn(null, step.value); } } next(); } } |
但這個實現,僅支援yield後面是thunk函式的情形。使用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var co = require('./co'); // wrap the function to thunk function readFile(filename) {// 輔助傳參,yield真正使用的是其返回的thunk函式 return function(callback) { require('fs').readFile(filename, 'utf8', callback); }; } co(function * () { var file1 = yield readFile('./file/a.txt'); var file2 = yield readFile('./file/b.txt'); console.log(file1); console.log(file2); return 'done'; })(function(err, result) { console.log(result) }); |
會列印出:
1 2 3 |
content in a.txt content in b.txt done |
希望能對理解和學習co的使用方法有很好的幫助。
以上。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式