co原始碼分析及其實踐

Mask發表於2019-02-16

本文始發於我的個人部落格,如需轉載請註明出處。
為了更好的閱讀體驗,可以直接進去我的個人部落格看。

前言

知識儲備

閱讀本文需要對GeneratorPromise有一個基本的瞭解。

這裡我簡單地介紹一下兩者的用法。

Generator

關於Generator的用法,推薦MDN上面的解釋function *函式,裡面非常詳細。

用一句話總結就是,generator函式是回撥地獄的一種解決方案,它跟promise類似,但是卻可以以同步的方式來書寫程式碼,而避免了promise的鏈式呼叫。

它的執行過程在於呼叫生成器函式(generator function)後,會返回一個iterator(迭代)物件,即Generator物件,但是它並不會立刻執行裡面的程式碼。

它有幾個方法,next(), throw()return()。呼叫next()方法後,它會找到第一個yield關鍵字(直到找到程式底部或者return語句),每次程式執行到yield關鍵字時,程式便會暫停,儲存當前環境裡面的變數的值,然後可以跳出當前執行環境去執行yield後面的程式碼,再把結果返回回來。

返回的結果是一個物件,類似於{value: ``, done: false}, value表示本次yield後面執行之後返回的結果。如果是Promise例項,則是返回resolved後的值。done表示迭代器是否執行完畢,若為true,則表示當前生成器函式已經產生了最後輸出的值,即生成器函式已經返回。

下面是一個簡單的例子:

const gen = function *() {
  let index = 0;
  while(index < 3)
    yield index++;
  return `All done.`
};

const g = gen();
console.log(g.constructor); // output: GeneratorFunction {}
console.log(g.next()); // output: { value: 0, done: false }
console.log(g.next()); // output: { value: 1, done: false }
console.log(g.next()); // output: { value: 2, done: false }
console.log(g.next()); // output: { value: `All done.`, done: true }
console.log(g.next()); // output: { value: undefined, done: true }

Promise

關於Promise的用法,可以查閱我之前寫過的一篇文章《關於ES6中Promise的用法》,寫得比較詳細。

Promise物件用於一個非同步操作的最終完成(或失敗)及其結果值的表示(簡單點說就是處理非同步請求)。Promise核心就在於裡面狀態的變換,是rejectedresolved還是pending,還有就是原型鏈上的then()方法,它可以傳遞本次狀態轉換後返回的值。

進入主題

由於實際需要,這幾天學習了koa2.x框架,但是它已經不推薦使用generator函式了,推薦用async/await組合。

koa2.x的最新用法:

async/await(node v7.6+):

const Koa = require(`koa`);
const app = new Koa();

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

common 用法:

const Koa = require(`koa`);
const app = new Koa();

// response
app.use(ctx => {
  ctx.body = `Hello Koa`;
});

app.listen(3000);

由於本地的Node版本是v6.11.5,而使用async/await則需要Node版本v7.6以上,所以我想有沒有什麼模組能夠把koa2.x版本的語法相容koa1.x的語法。koa1.x語法的關鍵在於generator/yield組合。通過yield可以很方便地暫停程式的執行,並改變執行環境。

這時候我找到了TJ大神寫的co模組,它可以讓非同步流程同步化,還有koa-convert模組等等,這裡著重介紹co模組。

co在koa2.x裡面的用法如下:

const Koa = require(`koa`);
const app = new Koa();
const co = require(`co`);

// response
app.use(co.wrap(function *(ctx, next) {
  yield next();
  // yield someAyncOperation;
  //  ... 
  ctx.body = `co`;
}));

app.listen(3000);

co模組不僅可以配合koa框架充當中介軟體的轉換函式使用,還支援批量執行generator函式,這樣就無需手動呼叫多次next()來獲取結果了。

它支援的引數有函式、promise、generator、陣列和物件

// co的原始碼
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函式的例子:

// 這裡模擬一個generator函式呼叫
const co = require(`co`);

co(gen).then(data => {
  // output: then: ALL Done.
  console.log(`then: ` + data);
});

function *gen() {
  let data1 = yield pro1();
  // output: pro1 had resolved, data1 = I am promise1
  console.log(`pro1 had resolved, data1 = ` + data1);

  let data2 = yield pro2();
  // output: pro2 had resolved, data2 = I am promise2
  console.log(`pro2 had resolved, data2 = ` + data2);

  return `ALL Done.`
}

function pro1() {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, 2000, `I am promise1`);
  });
}

function pro2() {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, 1000, `I am promise2`);
  });
}

我覺得co()函式很神奇,裡面究竟經過了什麼樣的轉換?抱著一顆好奇心,讀了一下co的原始碼。

co原始碼分析

主要脈絡

co函式呼叫後,返回一個Promise例項

co的思想就是將一個傳遞進來的引數進行合法化,再通過轉換成Promise例項返回出去。如果引數fn是generator函式的話,裡面還可以自動進行遍歷,執行generator函式裡面的yield關鍵字後面的內容,並返回結果,也就是不斷地呼叫fn().next()方法,再通過傳遞返回的Promise例項resolved後的值,從而達到同步執行generator函式的效果。

這裡要注意,co裡面最主要的是要理解Promise例項和Generator物件,它們是co函式裡面的程式自動遍歷執行的關鍵

下面解釋一下co模組裡面的最重要的兩部分,一個是generator函式的自動呼叫,另外一個是引數的Promise化

第一,generator函式的自動呼叫(中文部分是我的解釋):

function co(gen) {
  // 儲存當前的執行環境
  var ctx = this;
  // 切割出函式呼叫時傳遞的引數
  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例項
  return new Promise(function(resolve, reject) {
    // 如果gen是一個函式,則返回一個新的gen函式的副本,
    // 裡面繫結了this的指向,即ctx
    if (typeof gen === `function`) gen = gen.apply(ctx, args);
    
    // 如果gen不存在或者gen.next不是一個函式
    // 就說明gen已經呼叫完成,
    // 那麼直接可以resolve(gen),返回Promise
    if (!gen || typeof gen.next !== `function`) return resolve(gen);

    // 首次呼叫gen.next()函式,假如存在的話
    onFulfilled();

    /**
     * @param {Mixed} res
     * @return {Promise}
     * @api private
     */

    function onFulfilled(res) {
      var ret;
      try {
        // 嘗試著獲取下一個yield後面程式碼執行後返回的值
        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
     */

    // 這個next()函式是最為關鍵的一部分,
    // 裡面幾乎包含了generator自動呼叫實現的核心
    function next(ret) {
      // 如果ret.done === true, 
      // 證明generator函式已經執行完畢
      // 即已經返回了值
      if (ret.done) return resolve(ret.value);
      
      // 把ret.value轉換成Promise物件繼續呼叫
      var value = toPromise.call(ctx, ret.value);
      
      // 如果存在,則把控制權交給onFulfilled和onRejected,
      // 實現遞迴呼叫
      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) + `"`));
    }
  });
}

對於以上程式碼中的onFulfilledonRejected,我們可以把它們看成是co模組對於resolvereject封裝的加強版。

第二,引數Promise化,我們來看一下co中的toPromise的實現:

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;
}

toPromise的本質上就是通過判定引數的型別,然後再通過轉移控制權給不同的引數處理函式,從而獲取到期望返回的值。

關於引數的型別的判斷,看一下原始碼就能理解了,比較簡單。

我們著重來分析一下objectToPromise的實現:

function objectToPromise(obj){
  // 獲取一個和傳入的物件一樣構造器的物件
  var results = new obj.constructor();
  
  // 獲取物件的所有可以遍歷的key
  var keys = Object.keys(obj);
  var promises = [];
  for (var i = 0; i < keys.length; i++) {
    var key = keys[i];
    
    // 對於陣列的每一個項都呼叫一次toPromise方法,變成Promise物件
    var promise = toPromise.call(this, obj[key]);
    
    // 如果裡面是Promise物件的話,則取出e裡面resolved後的值
    if (promise && isPromise(promise)) defer(promise, key);
    else results[key] = obj[key];
  }
  
  // 並行,按順序返回結果,返回一個陣列
  return Promise.all(promises).then(function () {
    return results;
  });

  // 根據key來獲取Promise例項resolved後的結果,
  // 從而push進結果陣列results中
  function defer(promise, key) {
    // predefine the key in the result
    results[key] = undefined;
    promises.push(promise.then(function (res) {
      results[key] = res;
    }));
  }
}

上面理解的關鍵就在於把key遍歷,如果key對應的value也是Promise物件的話,那麼呼叫defer()方法來獲取resolved後的值。

編寫自己的generator函式執行器

通過以上的簡單介紹,我們就可以嘗試來寫一個屬於自己的generator函式執行器了,目標功能是能夠自動執行function*函式,並且裡面的yield子句後面跟著的都是Promise例項

具體程式碼(my-co.js)如下:

// my-co.js
module.exports = my-co;

let my-co = function (gen) {
  // gen是一個具有Promise的生成器函式
  const g = gen(); // 迭代器
  
  // 首次呼叫next
  next();

  function next(val) {
    let ret = g.next(val); // 呼叫ret
    if (ret.done) {
      return ret.value;
    }

    if (ret && `function` === typeof ret.value.then) {
      ret.value.then( (data) => {
        // 繼續迴圈下去
        return next(data); // promise resolved
      });
    }
  }
};

這樣我們就可以在test.js檔案中呼叫了:

// test.js
const myCo = require(`./my-co`);
const fs = require(`fs`);

let gen = function *() {
  let data1 = yield pro1();
  console.log(`data1: ` + data1);

  let data2 = yield pro2();
  console.log(`data2: ` + data2);

  let data3 = yield pro3();
  console.log(`data3: ` + data3);

  let data4 = yield pro4(data1 + `
` + data2 + `
` + data3);
  console.log(`data4: ` + data4);

  return `All done.`
};

// 呼叫myCo
myCo(gen);

// 延遲兩秒resolve
function pro1() {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, 2000, `promise1 resolved`);
  });
}

// 延遲一秒resolve
function pro2() {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, 1000, `promise2 resolved`);
  });
}

// 寫入Hello World到./1.txt檔案中
function pro3() {
  return new Promise((resolve, reject) => {
    fs.appendFile(`./1.txt`, `Hello World
`, function(err) {
      resolve(`write-1 success`);
    });
  });
}

// 寫入content到./1.txt檔案中
function pro4(content) {
  return new Promise((resolve, reject) => {
    fs.appendFile(`./1.txt`, content, function(err) {
      resolve(`write-2 success`);
    });
  });
}

控制檯輸出結果:

// output
data1: promise1 resolved
data2: promise2 resolved
data3: write-1 success
data4: write-2 success

./1.txt檔案內容:

Hello World
promise1 resolved
promise2 resolved
write-1 success

由上可知,執行的結果符合我們的期望。

雖然這個執行器很簡單,後面只支援Promise例項,並且也不支援多種引數,但是卻引匯出了一個思路,促使我們思考怎麼去展示我們的程式碼,還有就是很有效地避免了多重then,以同步的方式來書寫非同步程式碼。Promise解決的是回撥地獄的問題(callback hell),而Generator解決的是程式碼的書寫方式。孰優孰劣,全在於個人意願。

總結

以上分析了co部分原始碼的精髓,講到了co函式裡面generator函式自動遍歷執行的機制,還講到了co裡面最為關鍵的objectToPromise()方法。

在文章的後面我們編寫了一個屬於自己的generator函式遍歷器,其中主要的是next()方法,它可以檢測我們yield後面Promise操作是否完成。如果generator的狀態done還沒有置為true,那麼繼續呼叫next(val)方法,並把上一次yield操作獲取到的值傳遞下去。

有時候在引用別人的模組出現問題時,如果在網上找不到自己期望的答案,那麼我們可以根據自己的能力來選擇性地分析一下作者的原始碼,看原始碼是一種很好的成長方式

坦白說,這是我第一次深入分析模組的原始碼,co模組的原始碼包括註釋和空行只有230多行左右,所以這是一個很好的切入點。裡面程式碼雖少,但是理解卻不易。

如果以上所述有什麼問題,歡迎反饋。

感謝支援。

參考連結

  1. MDN – Promise解釋
  2. MDN – Generator物件的用法
  3. TJ – co的原始碼及其用法

相關文章