從Generator入手讀懂co模塊源碼

這篇文章是講JS異步原理和實現方式的第四篇文章,前面三篇是:javascript

setTimeout和setImmediate到底誰先執行,本文讓你完全理解Event Loopjava

從發佈訂閱模式入手讀懂Node.js的EventEmitter源碼node

手寫一個Promise/A+,完美經過官方872個測試用例git

本文主要會講Generator的運用和實現原理,而後咱們會去讀一下co模塊的源碼,最後還會提一下async/await。github

本文所有例子都在GitHub上:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/JavaScript/Generator編程

Generator

異步編程一直是JS的核心之一,業界也是一直在探索不一樣的解決方法,從「回調地獄」到發佈訂閱模式,再到Promise,都是在優化異步編程。儘管Promise已經很優秀了,也不會陷入「回調地獄」,可是嵌套層數多了也會有一連串的then,始終不能像同步代碼那樣直接往下寫就好了。Generator是ES6引入的進一步改善異步編程的方案,下面咱們先來看看基本用法。segmentfault

基本用法

Generator的中文翻譯是「生成器」,其實他要乾的事情也是一個生成器,一個函數若是加了*,他就會變成一個生成器函數,他的運行結果會返回一個迭代器對象,好比下面的代碼:promise

// gen是一個生成器函數
function* gen() {
  let a = yield 1;
  let b = yield a + 2;
  yield b + 3;
}

let itor = gen();   // 生成器函數運行後會返回一個迭代器對象,即itor。

next

ES6規範中規定迭代器必須有一個next方法,這個方法會返回一個對象,這個對象具備donevalue兩個屬性,done表示當前迭代器內容是否已經執行完,執行完爲true,不然爲falsevalue表示當前步驟返回的值。在generator具體運用中,每次遇到yield關鍵字都會暫停執行,當調用迭代器的next時,會將yield後面表達式的值做爲返回對象的value,好比上面生成器的執行結果以下:網絡

image-20200419153257750

咱們能夠看到第一次調next返回的就是第一個yeild後面表達式的值,也就是1。須要注意的是,整個迭代器目前暫停在了第一個yield這裏,給變量a賦值都沒執行,要調用下一個next的時候纔會給變量a賦值,而後一直執行到第二個yield。那應該給a賦什麼值呢?從代碼來看,a的值應該是yield語句的返回值,可是yield自己是沒有返回值的,或者說返回值是undefined,若是要給a賦值須要下次調next的時候手動傳進去,咱們這裏傳一個4,4就會做爲上次yield的返回值賦給a:app

image-20200419154159553

能夠看到第二個yield後面的表達式a + 2的值是6,這是由於咱們傳進去的4被做爲上一個yield的返回值了,而後計算a + 2天然就是6了。

咱們繼續next,把這個迭代器走完:

image-20200419155225702

上圖是接着前面運行的,圖中第一個next返回的valueNaN是由於咱們調next的時候沒有傳參數,也就是說bundefinedundefined + 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錯誤出來:

image-20200419160330384

這個錯誤由於咱們沒有捕獲,因此直接拋到最外層來了,咱們能夠在函數體裏面捕獲他,稍微改下:

function* gen() {
  try {
    let a = yield 1;
    let b = yield a + 2;
    yield b + 3;
  } catch (e) {
    console.log(e);
  }
}

let itor = gen();

而後再來throw下:

image-20200419160604004

這個圖能夠看出來,錯誤在函數裏裏面捕獲了,走到了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:

image-20200419161105691

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。

image-20200419161624637

協程

其實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函數返回了好幾層函數,咱們從他的使用入手一層一層剝開看:

  1. requestThunk是Thunk運行的返回值,也就是第一層返回值,參數是request,也就是:

    function(...args) {
      return function(callback) {
        return request.call(this, ...args, callback);   // 注意這裏調用的是request
      }
    }
  2. run函數的參數是生成器,咱們看看他到底幹了啥:

    1. run裏面先調用生成器,拿到迭代器gen,而後自定義了一個next方法,並調用這個next方法,爲了便於區分,我這裏稱這個自定義的next爲局部next
    2. 局部next會調用生成器的next,生成器的next其實就是yield requestThunk(url),參數是咱們傳進去的url,這就調到咱們前面的那個方法,這個yield返回的value實際上是:

      function(callback) {
        return request.call(this, url, callback);   
      }
    3. 檢測迭代器是否已經迭代完畢,若是沒有,就繼續調用第二步的這個函數,這個函數其實才真正的去request,這時候傳進去的參數是局部next,局部next也做爲了request的回調函數。
    4. 這個回調函數在執行時又會調gen.next,這樣生成器就能夠繼續往下執行了,同時gen.next的參數是回調函數的data,這樣,生成器裏面的r1其實就拿到了請求的返回值。

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) + '"'));
    }
  });
}
  1. 從總體結構看,co的參數是一個Generator,返回值是一個Promise,幾乎全部邏輯代碼都在這個Promise裏面,這也是咱們使用時用then拿結果的緣由。
  2. Promise裏面先把Generator拿出來執行,獲得一個迭代器gen
  3. 手動調用一次onFulfilled,開啓迭代

    1. onFulfilled接收一個參數res,第一次調用是沒有傳這個參數,這個參數主要是用來接收後面的then返回的結果。
    2. 而後調用gen.next,注意這個的返回值ret的形式是{value, done},而後將這個ret傳給局部的next
  4. 而後執行局部next,他接收的參數是yield返回值{value, done}

    1. 這裏先檢測迭代是否完成,若是完成了,就直接將整個promise resolve。
    2. 這裏的value是yield後面表達式的值,多是thunk,也多是promise
    3. 將value轉換成promise
    4. 將轉換後的promise拿出來執行,成功的回調是前面的onFulfilled
  5. 咱們再來看下onFulfilled,這是第二次執行onFulfilled了。此次執行的時候傳入的參數res是上次異步promise的執行結果,對應咱們的fetch就是拿回來的數據,這個數據傳給第二個gen.next,效果就是咱們代碼裏面的賦值給了第一個yield前面的變量r1。而後繼續局部next,這個next其實就是執行第二個異步Promise了。這個promise的成功回調又繼續調用gen.next,這樣就不斷的執行下去,直到done變成true爲止。
  6. 最後看一眼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其實就是等價的。

總結

  1. Generator是一種更現代的異步解決方案,在JS語言層面支持了協程
  2. Generator的返回值是一個迭代器
  3. 這個迭代器須要手動調next才能一條一條執行yield
  4. next的返回值是{value, done},value是yield後面表達式的值
  5. yield語句自己並無返回值,下次調next的參數會做爲上一個yield語句的返回值
  6. Generator本身不能自動執行,要自動執行須要引入其餘方案,前面講thunk的時候提供了一種方案,co模塊也是一個很受歡迎的自動執行方案
  7. 這兩個方案的思路有點相似,都是先寫一個局部的方法,這個方法會去調用gen.next,同時這個方法自己又會傳到回調函數或者promise的成功分支裏面,異步結束後又繼續調用這個局部方法,這個局部方法又調用gen.next,這樣一直迭代,直到迭代器執行完畢。
  8. async/await實際上是Generator和自動執行器的語法糖,寫法和實現原理都相似co模塊的promise模式。

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

做者博文GitHub項目地址: https://github.com/dennis-jiang/Front-End-Knowledges

相關文章
相關標籤/搜索