在 JavaScript 中使用 Generator 函数实现异步变同步操作

generator-async-implement

在 ES6 中提供了一种新的异步编程解决方案,那就是 Generator 函数。这种函数又被称为生成器函数、可暂停函数。在它的方法体内可以使用 yield 关键字,配合 yield 可以实现协程。

本文主要介绍对 Generator 函数的实际应用,不过多介绍 Generator 函数的相关基础概念。

01. 一个普通的 HTTP 请求

const axios = require('axios')

axios.get('https://sxyz.blog').then((res) => {
  console.log(res.data)
})

上面代码中使用了 axios 模块发起了一个简单的 GET 请求,当这个请求得到响应时会执行 then 块中的代码,打印出响应正文。

但是如果出现连续操作(请求完一个后接着请求下一个)的时候,那么就要这么写了:

const axios = require('axios')

axios.get('https://sxyz.blog').then((res) => {
  axios.get('https://sxyz.blog').then((res) => {
    console.log(res.data)
  })
})

从上面代码中可以发现,如果当这种连续请求的操作变得多了起来,那么就会出现 then 连环嵌套的尴尬场面。而这种需求在日常编码中却又非常常见。

02. 使用 Generator 函数

const axios = require('axios')

function* request() {
  let res

  res = yield axios.get('https://sxyz.blog')
  console.log(res.data)

  res = yield axios.get('https://sxyz.blog')
  console.log(res.data)
}

let p1, p2
const r = request()

p1 = r.next().value // Promise object
p1.then((v) => {
  p2 = r.next(v).value

  p2.then((v) => {
    r.next(v)

    // p3 = r.next(v).value
    // p3.then(...)
    // ...
  })
})

上面代码中,创建了一个名为 request 的 Generator 函数,然后在后面手动调用 next 方法接收 axios.get 所产生的 Promise 对象,并且调用 then 方法依次执行。

03. 改进 Generator 函数

虽然现在我们的关键代码(request 函数体中的请求代码)已经是同步了,这很棒。但是还有一个问题就是我们需要在代码中手动依次调用 next 来完成这些操作,这个过程非常繁琐且不合理。并且 then 的嵌套问题依旧存在。所以我们需要封装一个函数来帮我们自动完成这些操作:

const axios = require('axios')

function* request() {
  let res

  res = yield axios.get('https://sxyz.blog')
  console.log(res.data)

  res = yield axios.get('https://sxyz.blog')
  console.log(res.data)
}

const co = (gen, ...args) => {
  const g = gen(...args)

  const actuator = (x) => {
    if (x.done) {
      return
    }

    x.value.then((value) => {
      actuator(g.next(value))
    })
  }

  actuator(g.next())
}

// 现在只需
co(request)

// 如果有参数
co(request, 'aaa', 'bbbb')

其中 co 函数用来自动触发所有的 next 方法,并且解决了 then 不停嵌套的问题。

那么现在还有一些问题,比如我该如何得到 Generator 函数所产生的返回值呢,例如下面这样的代码:

const axios = require('axios')

function* foo() {
  return yield axios.get('https://sxyz.blog')
}

function* bar() {
  const { data } = yield foo()
  console.log(data)

  return yield axios.get('https://sxyz.blog')
}

co(
  (function* () {
    console.log((yield bar()).data)
  })()
)

想要在 co 的匿名函数中得到 bar 的执行结果,同时 bar 函数也可以得到 foo 的函数结果。

如果要实现这样的功能,需要对 co 函数进行一些修改:

const isGenerator = (f) => f.constructor && f.constructor.name === 'GeneratorFunction'
const isGeneratorObject = (o) => o.constructor && o.constructor.constructor && o.constructor.constructor.name === 'GeneratorFunction'

const co = (g) => {
  let resolve
  const p = new Promise((r) => (resolve = r))

  const actuator = (x) => {
    if (x.done) {
      return resolve(x.value)
    }

    Promise.resolve(isGeneratorObject(x.value) ? co(x.value) : x.value).then(
      (value) => {
        actuator(g.next(value))
      },
      (reason) => {
        g.throw(reason)
      }
    )
  }

  actuator(g.next())
  return p
}

现在统一了每个 co 函数都返回 Promise 对象。加进了对 Generator 对象的检查,如若 yield 后面的不是 Promise 对象而是 Generator 对象时,则再次调用 co 自身来转成 Promise 对象。

04. 包裹层:对类的扩展

我们将上面请求的代码进一步封装成一个类:

class Test {
  *foo() {
    return yield axios.get('https://sxyz.blog')
  }

  *bar() {
    const { data } = yield this.foo()
    console.log(data)

    return yield axios.get('https://sxyz.blog')
  }
}

co(
  (function* () {
    const t = new Test()
    console.log((yield t.bar()).data)
  })()
)

但是此时发现,所有与类有关的操作都需要写到 co 的函数体内,有点麻烦,有没有办法可以直接使用呢?有的,可以使用 ES6 中的 Proxy 对象来实现:

const get = (target, prop, receiver) => {
  return (...args) => {
    if (!prop in target) {
      return
    }

    return isGenerator(target[prop]) ? co(target[prop].call(target, ...args)) : target[prop].call(target, ...args)
  }
}

const te = (c) =>
  new Proxy(c, {
    construct(cls, args) {
      return new Proxy(new cls(...args), { get })
    },
  })

const Test = te(
  class {
    *foo() {
      return yield axios.get('https://sxyz.blog')
    }

    *bar() {
      const { data } = yield this.foo()
      console.log(data)

      return yield axios.get('https://sxyz.blog')
    }
  }
)

const t = new Test()
t.bar().then((res) => {
  console.log(res.data)
})

这样就可以不用再单独写 co 函数了,不过可能实际用途不是很大,参考只用。

05. 总结

本文介绍了如何使用 Generator 函数实现将异步操作转变为同步操作的方法。但在实际编码过程中可直接使用 await/async 语法糖。本文算是作为补充 Generator 同步实现的原理来看吧。