ES6-Generator 函式 和 async 函式

木子七發表於2018-06-06

generator 函式是什麼?

generator -> 生產者 yield -> 產出

generator 函式是 ES6 提供的一種非同步程式設計解決方案

執行 generator 函式返回的是一個遍歷器物件, 也就是說, 我們可以使用 next 方法, 來遍歷 generator 函式內部的每一個狀態

既然 generator 函式內部具有多個狀態, 那麼總該有一個標識來決定函式在遍歷過程中應該在哪裡停下來, 所以我們需要 yield


yield 語句

下面通過一個簡單例項, 詳細解釋 yield 的工作原理

function* foo() {
    yield 'hello'
    
    console.log('come from second yield')
    yield 'world'
    
    return 'ending'
}

const g = foo()

// 執行過程
> g.next()
< { value: 'hello', done: false }

> g.next()
  log: come from second yield
< { value: 'world', done: false }

> g.next()
< { value: 'ending', done: true }

> g.next()
< { value: undefined, done: true }
複製程式碼
  • 執行 foo 函式會返回一個遍歷器物件, 呼叫遍歷器物件的 next 方法, 使指標移向下一個狀態
  • 每次呼叫 next 方法, 內部指標就從函式頭部上一次停下來的地方開始執行, 直到遇到下一條 yield 語句或 return 為止
  • 關於 next 方法的返回物件:
    • value: 指緊跟在 yield 或者 return 後的值, 或者表示式的結果
    • done: 指遍歷是否結束, 布林型別
  • 如果內部狀態遇到了 return 語句, 則會直接結束遍歷, 也就是說, 之後無論後面還有沒有表示式, 或 yield 語句, 再次呼叫 next 方法, 都只會返回 { value: undefined, done: true }

從 generator 的行為可以看出, 實際上等同於 javascript 提供了一個手動 "惰性求值" 的語法功能


注意事項:

  1. yield 語句不能在普通函式中使用, 否則會報錯
  2. yield 語句如果在一個表示式中, 必須放在圓括號裡面, console.log('hello' + (yiled 123))

generator 函式傳參和 next 方法傳參

generator 函式是可以傳遞引數的, 但 generator 函式有兩種傳參的方式

generator 函式傳參

generator 函式傳參跟普通函式傳參是一樣的, 一個引數能在 generator 函式的任何狀態中讀取到

function* foo(x) {
    console.log(x)
    yield 'step 1'
    
    console.log(x)
    yield 'step 2'
    
    console.log(x)
    return 'step 3'
}

const g = foo('hello world')
複製程式碼

以上程式碼片段, 無論在函式體的哪一個狀態, 都能讀取到引數 hello world

next 方法傳參

next 方法傳遞引數, 就跟普通函式傳參完全不一樣了

yield 語句本身沒有返回值, 或者說總是返回 undefined, next 方法可以帶一個引數, 該引數會被當做該狀態之前所有 yield 語句的返回值

yield 語句沒有返回值?

我們先來看看下面的表示式

function* foo() {
  const x = yield 10

  console.log(x)
  return 'ending'
}

const g = foo()
複製程式碼

如果我們沒有事先知道結果, 肯定會認為這裡列印的 `x` 是 10 然而答案卻是 undefined, 這也是為什麼說 yield 是沒有返回值的 ``` javascript > g.next() < { value: 10, done: false }

g.next() log: undefined < { value: 'ending', done: true }

<br>
如果我們希望列印出來的 `x` 的值是 `hello world`, 就必須使用 next 方法傳遞引數
該引數會被當做上一條 yield 語句的返回值
``` javascript
> g.next()
< { value: 10, done: false }

> g.next('hello world')
  log: hello world
< { value: 'ending', done: true }
複製程式碼

練習題

利用 generator 計算

function* foo(x) {
    let y = 2 * (yield (x + 5))
    let z = yield y / 4 + 3
    return (x + y - z)
}

const g = foo(10)
複製程式碼
g.next()  // 1
g.next(4) // 2
g.next(8) // 3
複製程式碼

運算過程:

1.
x = 10
yield (10 + 5) => 15
> { value: 15, done: false }

2.
y = 2 * 4 => 8
yield (8 / 4 + 3) => 5
> {value: 5, done: false}

3.
x = 10
y = 8 // 保留了上一次 next 方法執行後的值
z = 8
return (10 + 8 - 8) => 10
> { value: 10, done: true }
複製程式碼

generator 內部錯誤捕獲

  • generator 函式能夠在函式體內部捕獲錯誤
  • 錯誤一經捕獲, generator 函式就會停止遍歷, done = true
function* foo() {
  try {    
    yield console.log(variate)
    yield console.log('hello world')
  } catch(err) {
    console.log(err)    
  }
}

const g = foo()

g.next()

> ReferenceError: variate is not defined
    at foo (index.js:3)
    at foo.next (<anonymous>)
    at <anonymous>:1:3
> { value: undefined, done: true }
複製程式碼

如果在內部 catch 片段中將錯誤使用全域性方法 throw 丟擲, 該錯誤依然能夠被外部 try...catch 所捕獲:

function* foo() {
  try {    
    yield console.log(variate)
    yield console.log('hello world')
  } catch(err) {
    throw err 
  }
}

const g = foo()

try {
    g.next()
} catch(err) {
    console.log('外部捕獲', err)
}

> 外部捕獲 ReferenceError: variate is not defined
    at foo (index.js:3)
    at foo.next (<anonymous>)
    at index.js:13
複製程式碼

for...of 迴圈

因為 generator 函式執行後返回的是一個遍歷器物件, 所以我們可以使用 for...of 迴圈來遍歷它

function* foo() {
    yield 1
    yield 2
    yield 3
    return 'ending'
}

const g = foo()

for (let v of g) {
    console.log(v)
}

// < 1
// < 2
// < 3
複製程式碼
  • 使用 for...of 迴圈, 不需要使用 next 語句
  • 一旦 next 方法的返回物件的 done 屬性為 true, for...of 迴圈就會終止
  • for...of 迴圈終止後不會返回 物件屬性 donetrue 的值, 所以上面的例子沒有返回 return 裡的 ending 值

yield* 語句

該語句用於在 generator 函式內部呼叫另一個 generator 函式

function* bar() {
    yield 3
    yield 4
}

function* foo() {
    yield 1
    yield 2
    yield* bar()
    yield 5
    return 'ending'
}

for (let v of foo()) {
    console.log(v)
}
// < 1 2 3 4 5
複製程式碼

yield* 的真相:

  • 該語句實際上完成了對 遍歷器物件 的迴圈
  • 所以它可以被看做是 for...of 的語法糖
  • 它完全可以被 for...of 替代
yield* bar()

# 完全等價於:
for (let v of bar()) {
    yield v
}
複製程式碼
  • 甚至它能遍歷陣列:
function* gen() {
    yield* [1, 2, 3]
    yield* 'abc'
}

for (let v of gen()) {
    console.log(v)
}
// < 1 2 3 a b c
複製程式碼
  • 也就是說, 任何只要具備 Iterator 介面的資料結構, 都能夠被 yield* 遍歷

yield* 可以儲存返回值

yield 不同的是(yield 本身沒有返回值, 必須由 next 方法賦予), 如果當被 yield* 代理的 generator 函式有 return 語句時, return 返回的值可以被永久儲存

function* foo() {
  yield 2
  return 'hello yield*'
}

function* gen() {
  const a = yield 1

  console.log(a) // -> undefined
  const b = yield* foo()

  console.log(b) // -> hello yield*
  yield 2

  console.log(b) // -> hello yield*
  yield 3
}

const g = gen()

複製程式碼

使用 yield* 取出巢狀陣列的所有成員

const tree = [1, 2, [3, 4, [5, 6, [7, 8, 9]]], 10, 11]

function* loopTree(tree) {
  if (Array.isArray(tree)) {
    for (let i = 0; i < tree.length; i ++) {
      yield* loopTree(tree[i])
    }
  } else {
    yield tree
  }
}

for (let v of loopTree(tree)) {
  console.log(v)
}
複製程式碼
  • 建立 generator 函式 loopTree, 該函式接收一個陣列, 或是數字作為引數
  • 如果引數是陣列, 則迴圈該陣列, 並使用 yield* 呼叫自身
  • 如果不是陣列, 則返回該值

generator 函式的實際運用

非同步操作同步化表達

/**
 * 普通 xhr 請求封裝
 * @param  {String} url 請求地址
 * @return {void}   void
 */
function call(url) {
  const xhr = new XMLHttpRequest()
  xhr.onreadystatechange = function () {
    if (xhr.readyState == 4) {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
        const res = JSON.parse(xhr.responseText)

        // 3. 請求成功, 將請求結果賦值給 result 變數, 並進入下一個狀態
        g.next(res)
      } else {
        console.log(`error: ${xhr.status}`)
      }
    }
  }
  xhr.open('get', url, true)
  xhr.send(null)
}

function* fetchData() {
  // 2. 傳送 xhr 請求
  const result = yield call('https://www.vue-js.com/api/v1/topics') 
  
  // 4. 列印出請求結果
  console.log(result)
}

const g = fetchData()

// 1. 開始遍歷 generator 函式
g.next()
複製程式碼

部署 Iterator 介面

const obj = {
  name: '木子七',
  age: '25',
  city: '重慶'
}

/**
 * 部署 Iterator 介面
 * @param  {Object} obj 物件
 * @yield  {Array}  將物件屬性轉換為陣列
 */
function* iterEntires(obj) {
  let keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i ++) {
    let key = keys[i]
    yield [key, obj[key]]
  }
}

for (let [key, value] of iterEntires(obj)) {
  console.log(key, value)
}

< name 木子七
< age 25
< city 重慶
複製程式碼

與 Promise 結合

generator 與 Promise 結合後, 實際上就是 async/await 的封裝實現, 下面小結會詳細描述 async/await

原理

// 1. 定義 generator 函式
// - 其返回的原生 fetch 方法是個 Promise 物件
function* fetchTopics() {
  yield fetch('https://www.vue-js.com/api/v1/topics')
}

const g = fetchTopics()

// 2. 呼叫 next 方法
const result = g.next()

// 3. g.next() 返回的 { value: ..., done } 中的 value 就是 Promise 物件
result.value
  .then(res => res.json())
  .then(res => console.log(res))

複製程式碼

封裝的方法實現

/**
 * 封裝用來執行 generator 函式的方法
 * @param {Func} generator generator 函式
 */
function fork(generator) {
  // 1. 傳入 generator 函式, 並執行返回一個遍歷器物件
  const it = generator()

  /**
   * 3. 遍歷 generator 函式中的所有 Promise 狀態
   *    go 函式會不停的使用 next 方法呼叫自身, 直到所有狀態遍歷完成
   * @param {Object} result 執行 next 方法後返回的資料
   */
  function go(result) {
    if (result.done) return result.value

    return result.value.then(
      value => go(it.next(value)),
      error => go(it.throw(error))
    )
  }

  // 2. 初次執行 next 語句, 進入 go 函式邏輯
  go(it.next())
}

/**
 * 普通的 Promise 請求方法
 * @param {String} url  請求路徑
 */
function call(url) {
  return new Promise(resolve => {
    fetch(url)
      .then(res => res.json())
      .then(res => resolve(res))
  })
}

/**
 * 業務邏輯 generator 函式
 * - 先請求 topics 獲取所有主題列表
 * - 再通過 topics 返回的 id, 請求第一個主題的詳情
 */
const fetchTopics = function* () {
  try {
    const topic = yield call('https://www.vue-js.com/api/v1/topics')

    const id = topic.data[0].id
    const detail = yield call(`https://www.vue-js.com/api/v1/topic/${id}`)

    console.log(topic, detail)
  } catch(error) {
    console.log(error)
  }
}

fork(fetchTopics)
複製程式碼

async 函式

async 函式屬於 ES7 的語法, 需要 Babelregenerator 轉碼後才能使用

async 函式就是 Generator 函式的語法糖, 其特點有:

  • Generator 函式的執行必須依靠執行器, 而 async 函式自帶執行器, 也就是說, async 函式的執行與普通函式一樣, 只要一行
  • 不需要呼叫 next 方法, 自動執行
  • async 表示函式裡有非同步操作, await 表示緊跟在後面的表示式需要等待結果
  • 要達到非同步操作同步執行的效果, await 命令後面必須是 Promise 物件, 如果是其他原始型別的值, 其等同於同步操作
function timeout() {
  return new Promise(resolve => {
    setTimeout(resolve, 2000)
  })
}

async function go() {
  await timeout().then(() => console.log(1))
  console.log(2)
}

go()

// 執行輸出, 先輸出1 後輸出2
// -> 1
// -> 2
複製程式碼
  • async 函式的返回值是 Promise 物件, 而 Generator 函式返回的是 Iterator 物件, 所以我們可以用 then 方法指定下一步操作
function timeout() {
  return new Promise(resolve => {
    setTimeout(resolve, 2000)
  })
}

async function go() {
  await timeout().then(() => console.log(1))
  console.log(2)
}

go().then(() => console.log(3))
// -> 1
// -> 2
// -> 3
複製程式碼
  • 可以說, async 函式完全可以看做由多個非同步操作包裝成的一個 Promise 物件, 而 await 命令就是內部 then 命令的語法糖

下面我們使用 async 函式來實現之前的非同步請求例子

const call = url => (
  new Promise(resolve => {
    fetch(url)
      .then(res => res.json())
      .then(res => resolve(res))
  })
)

const fetchTopics = async function() {
  const topic = await call('https://www.vue-js.com/api/v1/topics')

  const id = topic.data[0].id
  const detail = await call(`https://www.vue-js.com/api/v1/topic/${id}`)

  console.log(topic, detail)
}

fetchTopics().then(() => console.log('request success!'))
複製程式碼

總結: async 函式的實現比 Generator 函式簡潔了許多, 幾乎沒有語義不相關的程式碼, 它將 Generator 寫法中的自動執行器改在了語言層面提供, 不暴露給使用者, 因此減少了許多程式碼量

相關文章