從Generator入手讀懂co模組原始碼

蔣鵬飛發表於2020-08-17

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

setTimeout和setImmediate到底誰先執行,本文讓你徹底理解Event Loop

從釋出訂閱模式入手讀懂Node.js的EventEmitter原始碼

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

本文主要會講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方法,這個方法會返回一個物件,這個物件具有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:

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

相關文章