跳转到内容

Generator函数的异步应用

JavaScript 是单线程语言,如果没有异步编程,则会造成卡死,因此需要异步编程。

传统方法

在之前,异步编程的实现主要通过以下几个方法:

  1. 回调函数
  2. 事件监听
  3. 发布/订阅
  4. Promise 对象

基本概念

异步

异步就是不连续的执行一个任务,一个任务被分为几段,一开始先执行一段,然后暂停去执行其他任务,最后再回来执行剩下的任务。

常见的示例有读取文件、网络请求等。第一段任务都是发送请求(读取文件权限请求和向服务器发送网络请求),等到请求通过后才能开始执行第二段。

这种不连续执行的任务就叫异步任务。相反的,任务连续执行的就是同步任务。

回调函数

JavaScript 中,异步任务是通过回调函数来实现的。回调函数就是将第二段任务写进一个函数内,在第一段任务执行完毕后通过回调函数执行第二段任务。回调函数英文名为 callback ,意为“被调用的函数”。

js
window.addEventListener('load', () => {
  // some code...
});

上面的代码示例中,addEventListener 第二个参数就是回调函数,等到窗口加载完毕 load 事件触发后,执行该回调函数。

Promise

回调函数本身没问题,但是如果回调函数嵌套太多,会造成回调地狱问题,代码会变得难以阅读和维护。

js
window.addEventListener('load', () => {
  input.addEventListener('click', () => {
    axios({
      url: 'xxx',
      method: 'get',
      success: (res) => {
        img.onload = () => {
          img.src = res.data;
        }
      }
    })
  })
});

可以看出,代码层层嵌套,耦合性很高,后续只要有一个操作发生改变,会牵扯它上层回调和下层回调。Promise 就是为了解决这个问题,Promise 允许将回调函数写成链式调用。

js
window.addEventListener('load', () => {
  input.addEventListener('click', () => {
    axios({
      url: 'xxx',
      method: 'get'
    })
    .then(res => {
      img.onload = () => {
        img.src = res.data;
      }
    })
  })
});

虽然这样改了之后看起来相对更清楚了,但是如果 then 链式太多,一眼看出都是 then 的堆积,原语义变得很不清楚。

Generator函数

协程

传统的编程语言早已有异步编程(多任务)的解决方案,其中一种叫协程,多个线程协作完成异步任务。运行思路如下:

  1. 协程A开始执行
  2. 协程A执行到一半,暂停执行,执行权交给协程B
  3. 协程B执行到一半,暂停执行,执行权交给协程A
  4. 协程A恢复执行

可以看出,协程A就是分为两段的异步任务。

js
function * fn () {
  // some code
  yield readFile('xxx');
  // some code
}

上方示例代码协程遇到 yield 暂停执行去读取文件。读取完毕后再返回继续执行。

协程Cenerator函数的实现

Generator函数是协程在ES6的实现,最大的特点就是可以交出函数的执行权(即暂停执行)。Generator函数内部可以暂停执行(yield),也可以恢复执行(next)。

js
function * fn () {
  yield 1;
  yield 2;
  yield 3;
}

let g = fn();
g.next(); // {value: 1, done: false}
g.next(); // {value: 2, done: false}
g.next(); // {value: 3, done: false}
g.next(); // {value: undefined, done: true}

next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法都会返回 个对象,表示当前阶段的信息(value 属性和 done 属性)。value 属性是 yield 语句后面表达式的值,表示当前阶段的值;done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下个阶段。

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

Generator 函数可以暂停执行,也可以恢复执行,这意味着可以在暂停执行的地方交出数据,也可以在恢复执行的地方接收数据。除此之外,Generator 函数还能函数体内外数据交换和错误机制处理。

  • 数据交换

    js
    function * fn (x) {
      let y = yield x + 2;
      return y
    }
    
    let g = fn(1);
    g.next(); // {value: 3, done: false}
    g.next('a'); // {value: 'a', done: true}
    g.next('b'); // {value: undefined, done: true}
  • 错误处理

    js
    function * fn () {
      try {
        yield;
      } catch (e) {
        console.log(e);
      }
    }
    
    let g = fn();
    g.next();
    g.throw('出错了'); // 出错了

异步任务封装

下面先写一段异步读取文件的代码:

js
var fs = require('fs');

var readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) reject(error);
      resolve(data);
    });
  });
};

var gen = function* () {
  var f1 = yield readFile('/etc/fstab'); // 读取文件1
  var f2 = yield readFile('/etc/shells'); // 读取文件2
};

let result1 = gen().next()
let result2 = gen().next()

result1.value.then(data => {
  console.log(data);
  // some code
})

result2.value.then(data => {
  console.log(data);
  // some code
})

可以看到,整体代码和同步任务的写法非常类似,除了增加 yield 命令之外。使用 next 方法,依次执行每个异步任务。虽然这样异步操作很简洁,但是流程管理很不方便,即何时执行、何时返回、何时抛出错误,以及异常处理等。

Thunk函数

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

参数的求值策略

编程语言中,函数的参数什么时候求值比较好一直是一个争论点。

js
let x = 1

function f(n) {
  return n + 1
}

f(x + 5)

目前有两个流派:

  • 参数早求值,又称传值调用(call by value):函数参数在传入函数体之前就计算出来

    以上方代码为例,传值调用是进入函数体前先计算 x + 5,然后再传入函数 f。实际上等同于下方代码。

    js
      let x = 1
    
      function f(n) {
        return n + 1
      }
    
      f(6)
  • 参数惰性求值,又称传名调用(call by name):函数参数在函数体内部求值

    传名调用是把参数传入函数体,用到的时候再求值。等同于下方代码。

    js
      let x = 1
    
      function f(m) {
        return x + 5 + 1
      }
    
      f(x + 5)

二者各有利弊,传值调用比较简单,但是有可能函数内根本用不到,造成性能损失。传名调用在函数体内部,可以方便的引用任何函数,但是实现起来比较复杂。

Thunk函数的含义

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

js
function f(m) {
  return m * 2;
}

f(x + 5);

// 等同于

var thunk = function () {
  return x + 5;
};

function f(thunk) {
  return thunk() * 2;
}

Thunk 函数就是传名调用的一种策略,可用来替换某个表达式。

JavaScript的Thunk函数

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

js
// 正常版本的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 函数。任何函数只要参数有回调函数,都可以写成 Thunk 函数的形式。

Thunkify模块

生产环境的转换器,建议使用 Thunkify 模块。首先 npm i thunkify 下载依赖。,使用方式如。

js
import thunkify from 'thunkify';
import fs from 'fs';

var readFile = thunkify(fs.readFile);
readFile('/etc/fstab')(function (err, data) {
  if (err) throw err;
  console.log(data.toString());
});
Thunkify 源码
js
function thunkify (it) {
  return function () {
    let args = new Array(arguments.length);
    let self = this;
    
    for(let i = 0; i < args.length; i++) {
      args[i] = arguments[i];
    }

    return function (done) {
      let called;

      args.push(function () {
        if (called) return;
        called = true;
        done.apply(null, arguments);
      });

      try {
        it.apply(self, args);
      } catch (ex) {
        done(ex);
      }
    }
  }
}

Generator函数的流程管理

有了 Generator 函数后,Thunk 函数便有了用武之地,自动流程管理 Generator 函数。

自动流程管理 代码
js
import thunkify from 'thunkify';
import fs from 'fs';

var readFile = thunkify(fs.readFile);

var gen = function* () {
  var r1 = yield readFile('/etc/fstab');
  console.log(r1.toString());
  var r2 = yield readFile('/etc/shells');
  console.log(r2.toString());
};

let g = gen();

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

上面的代码中,函数封装了一个异步的读取文件操作,只要执行 run 函数,这些操作就 会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且只需要一行代码就可以执行。 Thunk 函数井不是 Generator 函数自动执行的唯一方案,因为自动执行的关键是,必须有种机制自动控制 Generator 函数的流程,接收和交还程序的执行权,回调函数可以做到这点,Promise 对象也可以做到。

co模块

co 模块可以让你不用编写 Generator 函数的执行器。

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

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

co 模块其实就是将两种自动执行器(Thunk 函数和 Promise 对象)包装成一个模块。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或 Promise 对象。

如果数组或对象的成员,全部都是 Promise 对象,也可以使用 co 函数。

总结

JavaScript 是单线程语言,需要异步编程来避免阻塞。传统的异步编程方法包括回调函数、事件监听、发布/订阅和 Promise 对象。

Generator 函数是 ES6 中实现协程的方式,允许函数执行权的交出和恢复,通过 yieldnext 实现。

Generator 函数可以用于异步任务封装,使得异步代码的写法更接近同步代码。 Thunk 函数是将多参数函数转换为单参数版本的函数,常用于自动执行 Generator 函数。

co 模块是一个自动执行器,可以处理 Generator 函数中的 Thunk 函数和 Promise 对象,简化异步操作的流程管理。