Generator函式非同步應用

凱斯發表於2019-03-03

轉載請註明出處:

Generator函式非同步應用-掘金

Generator函式非同步應用-部落格園

Generator函式非同步應用-知乎

上一篇文章詳細的介紹了Generator函式的語法,這篇文章來說一下如何使用Generator函式來實現非同步程式設計。

或許用Generator函式來實現非同步會很少見,因為ECMAScript 2016的async函式對Generator函式的流程控制做了一層封裝,使得非同步方案使用更加方便。

但是呢,我個人認為學習async函式之前,有必要了解一下Generator如何實現非同步,這樣對於async函式的學習或許能給予一些幫助。

文章目錄

  1. 知識點簡單回顧
  2. 非同步任務的封裝
  3. thunk函式實現流程控制
  4. Generator函式的自動流程控制
  5. co模組的自動流程控制

知識點簡單回顧

在Generator函式語法解析篇的文章中有說到,Generator函式可以定義多個內部狀態,同時也是遍歷器物件生成函式。yield表示式可以定義多個內部狀態,同時還具有暫停函式執行的功能。呼叫Generator函式的時候,不會立即執行,而是返回遍歷器物件。

遍歷器物件的原型物件上具有next方法,可以通過next方法恢復函式的執行。每次呼叫next方法,都會在遇到yield表示式時停下來,再次呼叫的時候,會在停下的位置繼續執行。呼叫next方法會返回具有value和done屬性的物件,value屬性表示當前的內部狀態,可能的值有yield表示式後面的值、return語句後面的值和undefined;done屬性表示遍歷是否結束。

yield表示式預設是沒有返回值的,或者說,返回值為undefined。因此,想要獲得yield表示式的返回值,就需要給next方法傳遞引數。next方法的參數列示上一個yield表示式的返回值。因此在呼叫第一個next方法時可以不傳遞引數(即使傳遞引數也不會起作用),此時表示啟動遍歷器物件。所以next方法會比yield表示式的使用要多一次。

更加詳細的語法可以參考這篇文章。傳送門:Generator函式語法解析

非同步任務的封裝

yield表示式可以暫停函式執行,next方法可以恢復函式執行。這使得Generator函式非常適合將非同步任務同步化。接下來會使用setTimeout來模擬非同步任務。

const person = sex => {
  return new Promise((resolve, reject) => {
    window.setTimeout(() => {
      const data = {
        sex,
        name: `keith`,
        height: 180
      }
      resolve(data)
    }, 1000)
  })
}
function *gen () {
  const data = yield person(`boy`)
  console.log(data)
}
const g = gen()
const next1 = g.next() // {value: Promise, done: false}
next1.value.then(data => {
  g.next(data)
})
複製程式碼

從上面程式碼可以看出,第一次呼叫next方法時,啟動了遍歷器物件,此時返回了包含value和done屬性的物件,由於value屬性值是promise物件,因此可以使用then方法獲取到resolve傳遞過來的值,再使用帶有data引數的next方法給上一個yield表示式傳遞返回值。

此時在const data = yield person()這句語句中,就可以得到非同步任務傳遞的引數值了,實現了非同步任務的同步化。

但是上面的程式碼會有問題。每次獲取非同步的值時,都要手動執行以下步驟

const g = gen()
const next1 = g.next() {value: Promise, done: false}
next1.value.then(data => {
  g.next(data)
})
複製程式碼

上面的程式碼實質上就是每次都會重複使用value屬性值和next方法,所以每次使用Generator實現非同步都會涉及到流程控制的問題。每次都手動實現流程控制會顯得麻煩,有沒有什麼辦法可以實現自動流程控制呢?實際上是有的: )

thunk函式實現流程控制

thunk函式實際上有些類似於JavaScript函式柯里化,會將某個函式作為引數傳遞到另一個函式中,然後通過閉包的方式為引數(函式)傳遞引數進而實現求值。

函式柯里化實現的過程如下

function curry (fn) {
  const args1 = Array.prototype.slice.call(arguments, 1)
  return function () {
    const args2 = Array.from(arguments)
    const arr = args1.concat(args2)
    return fn.apply(this, arr)
  }
}
複製程式碼

使用curry函式來舉一個例子: )

// 需要柯里化的sum函式
const sum = (a, b) => {
  return a + b
}
curry(sum, 1)(2)   // 3
複製程式碼

而thunk函式簡單的實現思路如下:

// ES5實現
const thunk = fn => {
  return function () {
    const args = Array.from(arguments)
    return function (callback) {
      args.push(callback)
      return fn.apply(this, args)
    }
  }
}

// ES6實現
const thunk = fn => {
  return function (...args) {
    return function (callback) {
      return fn.call(this, ...args, callback)
    }
  }
}

複製程式碼

從上面thunk函式中,會發現,thunk函式比函式curry化多用了一層閉包來封裝函式作用域。

使用上面的thunk函式,可以生成fs.readFile的thunk函式。

const fs = require(`fs`)
const readFileThunk = thunk(fs.readFile)
readFileThunk(fileA)(callback)
複製程式碼

使用thunk函式將fs.readFile包裝成readFileThunk函式,然後在通過fileA傳入檔案路徑,callback引數則為fs.readFile的回撥函式。

當然,還有一個thunk函式的升級版本thunkify函式,可以使得回撥函式只執行一次。原理和上面的thunk函式非常像,只不過多了一個flag引數用於限制回撥函式的執行次數。下面我對thunkify函式做了一些修改。原始碼地址: node-thunkify

const thunkify = fn => {
  return function () {
    const args = Array.from(arguments)
    return function (callback) {
      let called = false
      // called變數限制callback的執行次數
      args.push(function () {
        if (called) return
        called = true
        callback.apply(this, arguments)
      })
      try {
        fn.apply(this, args)
      } catch (err) {
        callback(err)
      }
    }
  }
}
複製程式碼

舉個例子看看: )

function sum (a, b, callback) {
  const total = a + b
  console.log(total)
  console.log(total)
}

// 如果使用thunkify函式
const sumThunkify = thunkify(sum)
sumThunkify(1, 2)(console.log)
// 列印出3

// 如果使用thunk函式
const sumThunk = thunk(sum)
sumThunk(1, 2)(console.log)
// 列印出 3, 3
複製程式碼

再來看一個使用setTimeout模擬非同步並且使用thunkify模組來完成非同步任務同步化的例子。

const person = (sex, fn) => {
  window.setTimeout(() => {
    const data = {
      sex,
      name: `keith`,
      height: 180
    }
    fn(data)
  }, 1000)
}
const personThunk = thunkify(person)
function *gen () {
  const data = yield personThunk(`boy`)
  console.log(data)
}
const g = gen()
const next = g.next()
next.value(data => {
  g.next(data)
})
複製程式碼

從上面程式碼可以看出,value屬性實際上就是thunkify函式的回撥函式(也是person的第二個引數),而`boy`則是person的第一個引數。

Generator函式的自動流程控制

在上面的程式碼中,我們可以將呼叫遍歷器物件生成函式,返回遍歷器和手動執行next方法以恢復函式執行的過程封裝起來。

const run = gen => {
  const g = gen()
  const next = data => {
    let result = g.next(data)
    if (result.done) return result.value
    result.value(next)
  }
  next()
}
複製程式碼

使用run函式封裝起來之後,run內部的next函式實際上就是thunk(thunkify)函式的回撥函式了。因此,呼叫run即可實現Generator的自動流程控制。

const person = (sex, fn) => {
  window.setTimeout(() => {
    const data = {
      sex,
      name: `keith`,
      height: 180
    }
    fn(data)
  }, 1000)
}
const personThunk = thunkify(person)
function *gen () {
  const data = yield personThunk(`boy`)
  console.log(data)
}
run(gen)
// {sex: `boy`, name: `keith`, height: 180}
複製程式碼

有了這個執行器,執行Generator函式就方便多了。不管內部有多少個非同步操作,直接把Generator函式傳入run函式即可。當然,前提是每一個非同步操作,都要是thunk(thunkify)函式。也就是說,跟在yield表示式後面的必須是thunk(thunkify)函式。

const gen = function *gen () {
  const f1 = yield personThunk(`boy`) // 跟在yield表示式後面的非同步行為必須使用thunk(thunkify)函式封裝
  const f2 = yield personThunk(`boy`)
  // ...
  const fn = yield personThunk(`boy`)
}
run(gen)  // run函式的自動流程控制
複製程式碼

上面程式碼中,函式gen封裝了n個非同步行為,只要執行run函式,這些操作就會自動完成。這樣一來,非同步操作不僅可以寫得像同步操作,而且一行程式碼就可以執行。

co模組的自動流程控制

在上面的例子說過,表示式後面的值必須是thunk(thunkify)函式,這樣才能實現Generator函式的自動流程控制。thunk函式的實現是基於回撥函式的,而co模組則更進一步,可以相容thunk函式和Promise物件。先來看看co模組的基本用法

const co = require(`co`)
const gen = function *gen () {
  const f1 = yield person(`boy`) // 呼叫person,返回一個promise物件
  const f2 = yield person(`boy`)
}
co(gen)   // 將thunk(thunkify)函式和run函式封裝成了co模組,yield表示式後面可以是thunk(thunkify)函式或者Promise物件
複製程式碼

co模組可以不用編寫Generator函式的執行器,因為它已經封裝好了。將Generator函式co模組中,函式就會自動執行。

co函式返回一個Promise物件,因此可以用then方法新增回撥函式。

co(gen).then(function (){
  console.log(`Generator 函式執行完成`)
})
複製程式碼

co模組原理;co模組其實就是將兩種自動執行器(thunk(thunkify)函式和Promise物件),包裝成一個模組。使用co模組的前提條件是,Generator函式的yield表示式後面,只能是thunk(thunkify)或者Promise物件,如果是陣列或物件的成員全部都是promise物件,也可以使用co模組。

基於Promise物件的自動執行

還是使用上面例子,不過這次是將回撥函式改成Promise物件來實現自動流程控制。

const person = (sex, fn) => {
  return new Promise((resolve, reject) => {
    window.setTimeout(() => {
      const data = {
        name: `keith`,
        height: 180
      }
      resolve(data)
    }, 1000)
  })
}
function *gen () {
  const data = yield person(`boy`)
  console.log(data)   // {name: `keith`, height: 180}
}
const g = gen()
g.next().value.then(data => {
  g.next(data)
})
複製程式碼

手動執行實際上就是層層使用then方法和next方法。根據這個可以寫出自動執行器。

const run = gen => {
  const g = gen()
  const next = data => {
    let result = g.next(data)
    if (result.done) return result.value
    result.value.then(data => {
      next(data)
    })
  }
  next()
}
run(gen)  // {name: `keith`, height: 180}
複製程式碼

如果對co模組感興趣的朋友,可以閱讀一下它的原始碼。傳送門:co

關於Generator非同步應用的相關知識也就差不多了,現在稍微總結一下。

  1. 由於yield表示式可以暫停執行,next方法可以恢復執行,這使得Generator函式很適合用來將非同步任務同步化。
  2. 但是Generator函式的流程控制會稍顯麻煩,因為每次都需要手動執行next方法來恢復函式執行,並且向next方法傳遞引數以輸出上一個yiled表示式的返回值。
  3. 於是就有了thunk(thunkify)函式和co模組來實現Generator函式的自動流程控制。
  4. 通過thunk(thunkify)函式分離引數,以閉包的形式將引數逐一傳入,再通過apply或者call方法呼叫,然後配合使用run函式可以做到自動流程控制。
  5. 通過co模組,實際上就是將run函式和thunk(thunkify)函式進行了封裝,並且yield表示式同時支援thunk(thunkify)函式和Promise物件兩種形式,使得自動流程控制更加的方便。

參考資料

  1. Generator 函式的非同步應用
  2. node-thunkify
  3. co

相關文章