美文网首页
Generator 函数的异步应用

Generator 函数的异步应用

作者: 了凡和纤风 | 来源:发表于2019-10-18 19:53 被阅读0次

    一、传统方法

    ES6 诞生以前,异步编程的方法大概有下面4种

    • 回调函数
    • 事件监听
    • 发布/订阅
    • Promise 对象

    二、基本概念

    2.1 异步

    所谓“异步”,简单来说就是一个任务不是连续完成的,可以理解成该任务被人分为两段先执行第一段,然后转而执行其他任务,等做好准备后再执行第二段。
    相应的,连续执行叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能等待。

    2.2、回调函数

    JavaScript 语言对异步编程的实现就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务时便直接调用这个函数。

    fs.readFile('/xxx', 'utf-8', function(err, data) {
      if (err) throw err
      console.log(data)
    })
    

    一个有趣的问题是,为什么 Node 约定回调函数的第一个参数必须是错误对象err(错误优先)呢?
    因为,执行分为两段,第一段执行完以后,任务所在的上下文环境就已经结束了。在这以后抛出的错误,其原本的上下文环境已经无法捕捉,因此只能当做参数被传入第二段。

    2.3、Promise

    回调函数本身没有问题,它的问题出现在多个回调函数嵌套上。

    fs.readFile(fileA, 'utf-8', function(err, data) {
      fs.readFile(fileB, 'utf-8', function(err, data) {
        // ...
      } 
    })
    

    如上,如果出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数就都跟着修改。这种情况就称为回调函数地狱(callback hell)

    Promise 对象就是为了解决这个问题而被提出的。他不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套改写成链式调用。

    采用 Promise 连续读取多个文件的语法如下。

    var readFile = require('fs-readfile-promise')
    
    readFile(fileA)
      .then(data => {
        console.log(data.toString())
        return readFile(fileB)
      })
      .then(data => {
        console.log(data.toSting())
      })
      .catch(function(err) {
        console.error(err)
      })
    

    Promise 的写法只是回调函数的改进,使用 then 方法以后,异步任务的两段执行更清楚了,除此之外,并无新意。

    Promise 的最大问题是:代码冗余,原来的任务被Promise 包装之后,无论什么操作,一眼看去都是许多 then 的堆积,原来的语义变得很不清楚。

    三、Generator 函数

    3.1、协程

    传统的编程语言中早有异步编程的解决方案(其实是多任务的解决方案),其中一种叫做“协程”(coroutine),意思是多个线程互相协作,完成异步任务

    协程类似函数,又有点像线程。运行流程大致如下:

    • 第一步,协程A开始执行
    • 第二步,协程A执行到一半,进入暂停状态,执行权移动协程B中
    • 第三步,(一段时间后)协程B交换执行权
    • 第四步,协程A回复执行

    举例来说,读取文件的协程写法如下

    function* asyncJob() {
      // ...
      var f = yield readFile(fileA)
      // ...
    }
    

    asyncJob 是一个协程,它的奥妙在于其中的 yield 命令。他执行到此处时,执行权将交给其他协程。也就是说,yield 命令式异步两个阶段的分界线。协程遇到 yield 命令就暂停,等执行权返回,再从暂停的地方继续往后执行,它的最大优点是,代码的写法非常像同步操作,如果去除 yield 命令,几乎一模一样。

    3.2、协程的 Generator 函数实现

    Generator 函数时协程在 ES6 中的实现,最大特点就是可以交出函数的执行权(即暂停执行)

    function* gen(x) {
      var y = yield x + 2
      return y
    }
    
    var g = gen(1)
    g.next() // {value: 3, done: false}
    g.next() // {value: undefined, dont: true}
    

    3.3、Generator 函数的数据交换和错误处理

    Gemerator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,还有两个特性使它可以作为异步编程的完整解决方案:函数体内的数据交换和错误处理机制。

    function* gen(x) {
      var y = yield x + 2
      return y
    }
    
    var g = gen(1)
    g.next()
    g.next(2) // {value: 2, done: true}
    

    Generator 函数内还可以部署错误处理代码,捕获函数体外抛出的错误

    function* gen(x) {
      try {
        var y = yield x + 2
      } catch(e) {
        console.error(e)
      }
      return y
    }
    
    var g = gen(1)
    g.next()
    g.throw('出错了')
    // 出错了
    

    出错的代码与处理错误的代码实现了时间和空间上的分离,这对于异步编程式很重要的。

    3.4、异步任务的封装

    使用 Generator 函数执行一个异步任务

    var fetch = require('node-fetch')
    
    function* gen() {
      var url = 'https://api.github.com/users/github'
      var result = yield fetch(url)
      console.log(result.bio)
    }
    
    var g = gen()
    var result = g.next()
    
    result.value.then(data => {
      return data.json()
    }).then(data => {
      g.next(data)
    })
    

    首先执行 Generator 函数获取遍历器对象,然后使用 next 方法执行异步任务的第一阶段。由于 fetch 模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个 next 方法

    虽然,Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段,何时执行第二阶段)。

    四、Thunk 函数

    Thnunk 函数是自动执行的 Generator 函数的一种方法

    4.1、参数的求值策略

    Thunk 函数早很早就存在了,编程语言起步初期,一个争论的焦点时“求值策略”,即函数的参数到底应该在何时求值

    var x = 1
    
    function f(m) {
      return m * 2
    }
    
    f(x+5)
    

    一种意见是“传值调用”(call by value),即在进入函数体之前计算 x + 5的值,再将这个值传入函数 f。C语言就采用了这种策略

    f(x + 5)
    // 传值调用时,等同于
    f(6)
    

    另一种意见是“传名调用”(call by name),即直接将表达式 x + 5 传入函数体,只要用到它的时候求值,Haskell 语言采用这种策略

    f(x + 5)
    // 传名调用
    (x + 5) * 2
    

    这两种方法各有利弊。

    传值调用比较简单,但是对参数求值的事实,实际上还没有用到这个参数,有可能会造成性能损失。

    function f(a, b) {
      return b
    }
    f(2 * x + 45 * 32 - x + 10, x)
    

    函数 f 的第一个参数是一个复杂的表达式,但是函数体内根本没有用到。对这个参数求值实际上是不必要的。因此,有一些计算机科学家倾向于“传名调用”,即只在执行时求值。

    4.2、Thunk 函数的含义

    编译器的 “传名调用” 的实现往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个零时函数就称为 Thunk 函数

    function f(x) {
      return m * 2
    }
    
    f(x + 5)
    
    // 等同于
    
    var thunk = function() {
      return x + 5
    }
    
    function f(thunk) {
      return thunk() * 2
    }
    

    这就是 Thunk 函数的定义,它是“传名调用”的一种实习策略,可以用来替换某个表达式

    4.3、JavaScript 语言的 Thunk 函数

    JavaScript 语言是传值调用,它的Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。

    // 正常版本的 readFile(多参数版本)
    fs.readFile(fileName, callback)
    
    // Thunk 版本的 readFile
    var Thunk = function (fileName) {
      return function(callback) {
        return fs.readFIle(fileName, callback)
      }
    }
    var readFileThunk = Thunk(fileName)
    readFileThunk(callback)
    

    任何函数,只要参数㕛回调函数,就能写成 Thunk 函数的形式。

    // ES5 版本
    var Thunk = function(fn) {
      return function() {
        var args = Array.prototype.slice.call(arguments)
        return function(callback) {
          args.push(callback)
          return fn.apply(this, args)
        }
      }
    }
    
    // ES6 版本
    const Thunk = function(fn) {
      return function (...args) {
        return function (callback) {
          return fn.call(this, ...args, callback)
        }
      }
    }
    // 也就相当于将所有参数累加到最开始传入的函数中作为参数
    
    // 使用
    var readFileThunk = Thunk(fs.readFile)
    readFileThunk(fileA)(callback)
    

    4.4、Thunkify 模块

    生产环境中的转换器建议使用 Thunkify 模块。
    基本使用方式如下:

    var thunkify = require('thunkify')
    var fs = require('fs')
    
    var read = thunkify(fs.readFile)
    read('package.json')(function(err, str) {
      // ... 
    })
    

    Thunkify 的源码与上一节中的简单转换器非常像,区别在于多了一个检查机制。

    4.5、Generator 函数的流程管理

    ES6 中有了 Generator 函数,Thunk 函数可以用于 Generator 函数的自动流程管理,Generator 函数可以自动执行。

    下面的Generator 函数封装了两个 异步操作

    var fs = require('fs')
    var thunkify = require('thunkify')
    var readFileThunk = thunkify(fs.readFile)
    
    var gen = function* () {
      var v1 = yield readFileThunk('/etc/fstab')
      console.log(r1.toString())
      var v2 = yield readFileThunk('/etc/shells')
      console.log(r2.toString())
    }
    

    yield 命令用于将程序的执行权移出 Generator 函数,就需要一种方法将执行权再交给 Generator 函数.

    这种方法就是使用 Thunk 函数,因为它可以在毁掉和桉树里将执行权交给 Generator 函数。

    var g = gen()
    
    var r1 = g.next()
    r1.value(function (err, data) {
      if (err) throw err
      var r2 = g.next(data)
      r2.value(function (err, data) {
        if (err) throw err
        g.next(data)
      })
    })
    

    g 是 Generator 函数的内部指针,标明目前执行到哪一步。next 方法负责将指针移动到下一步,并返回该步的信息(value 属性和 done 属性)
    可以发现 Generator 函数的执行过程其实是将同一个回调函数反复传入 next 方法的value
    属性。这使得我们可以用递归来自动完成这个过程。

    4.6、Thunk 函数的自动流程管理

    Thunk 函数真正的威力在于可以自动执行 Generator 函数。

    function run(fn) {
      var gen = fn()
    
      function next(err, data) {
        var resut = gen.next(data)
        if (result.done) return
        result.value(next)
      }
      next()
    }
    
    function* g() {
      // ...
    }
    

    上面代码,会判断 Generator 函数是否结束(result.done属性),如果没有结束,就将 next 函数再传入 Thunk 函数(result.value 属性)

    前提是每一个异步操作都要是 Thunk 函数,也就是说,yield 后面的必须是 Thunk 函数。

    五、co 模块

    5.1、基本用法

    co 模块 是著名程序员 TJ Holowaychuk 于 2013年 6月发布的一个小工具,用于 Generator 函数的自动执行

    var co = require('co')
    
    var gen = function* () {
      var f1 = yield readFile('/etc/fstab')
      var f2 = yield readFile('/etc/shells')
    
      console.log(f1.toString())
      console.log(f2.toString())
    }
    
    co(gen)
    

    上面的代码中,Generator 函数只要传入 co 函数就会自动执行。

    co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数

    co(gen).then(function() {
      console.log('Generator 函数执行完成')
    })
    

    5.2、co 模块的原理

    Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,这种机制要自动交会执行权

    有两种方法可以做到这一点。

    • 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交会执行权
    • Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交会执行权

    co 模块其实就是将两种自动执行器(Thunk 函数和 Promise对象)包装成一个模块。co的前提条件是,Generator 函数的yield 命令后面只能是 Thunk 函数或 Promise 对象。(co v4.0 版本以后,yield 命令后面只能是 Promise 对象,不在支持 Thunk 函数)

    5.3、基于 Promise 对象的自动执行

    var fs = require('fs')
    
    var readFile = function (fileName) {
      return new Promise(function (resolve, reject) {
        fs.readFile(fileName, function(error, data) {
          if (error) return reject(error)
          resolve(data)
        })
      })
    }
    
    var gen = function* () {
      var f1 = yield readFile('/etc/fstab')
      var f2 = yield readFile('/etc/shells')
      console.log(f1.toString())
      console.log(f2.toStirng())
    }
    

    然后手动执行上面的函数

    var g = gen()
    
    g.next().value.then(function(data) {
      g.next(data).value.then(function(data) {
        g.next(data)
      })
    })
    

    手动执行其实就是用 then 方法层层添加回调函数,由此可以书写如下自动执行器

    function run(gen) {
      var g = gen()
    
      function next(data) {
        var result = g.next(data)
        if (result.done) return result.value
        result.value.then(function(data) {
          next(data)
        })
      }
      next()
    }
    
    run(gen)
    

    只要 Generator 函数还没有执行到最后一步,next 函数就调用自身,以此实现自动执行

    相关文章

      网友评论

          本文标题:Generator 函数的异步应用

          本文链接:https://www.haomeiwen.com/subject/gghimctx.html