Generator函数的异步应用

109 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

本次学习笔记是来源是阮一峰老师的ES6标准入门教程 第三版 第19章节

什么是异步

所谓异步就是指一个任务不是连续完成,用流程图打颗下班做饭的栗子:

1671431021657.jpg

以上可以很直观的看到同步与异步的区别,同步就是严格按照任务的先后顺序依次执行,这样操作效率低所花费的时间更长;异步就是在备菜的同时执行烧水和煮饭任务,无需等待每一项子任务的完成继续向下执行,这样做的好处是同一时间内可以执行更多的操作,效率高,时间少。

在ES6诞生之前,js异步编程的方法有四种:

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

回调函数

老师教程中写到js对异步编程的实现就是回调函数。而回调函数就是把任务的第二段单独写在一个函数里,等重新执行这个任务时,直接调用这个函数。

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

引用原文的代码,这里的回调函数就是readFile函数的第三个参数,等系统返回文件后,回调函数才会执行。但有个问题,为什么回调函数的第一个参数必须是err;老师再教材中的解答是,因为执行分两段,第一段执行完后,任务所在的上下文环境已经结束,在这之后抛出的错误原来的上下文环境已经无法捕获,只能当做参数,传入第二段手动捕获。 ok,这一段基本也懂了,继续。

Promise

Promise的出现本身是为了解决回调函数出现的多个回调函数嵌套的问题。

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

当有多个文件,就会出多重嵌套,且多个异步形成了强耦合,如果其中一个需要修改,则它的上下层回调函数都要进行修改,这种情况称为“回调函数地狱(callback hell)”。

Promise对象它不是一种新的语法功能,而是一种新的写法,它允许将回调函数的嵌套,改成链式调用

var readFile = require('fs-readfile-promise');

readFile(fileA)
.then(function (data) {
  console.log(data.toString());
})
.then(function () {
  return readFile(fileB);
})
.then(function (data) {
  console.log(data.toString());`
})
.catch(function (err) {`
    console.log(err);`
});

这样看的话确实比回调函数要更直观一点,代码是纵向发展了,但存在代码冗余问题。

generator 函数

协程

指多个线程相互协作,完成异步任务 运行流程如下:

  1. 协程A开始执行
  2. 协程A执行到一半,进入暂停,执行权转移到协程B
  3. 协程B执行完成,交还执行权
  4. 协程A恢复执行 上述的协程A就是异步任务,分为两段或多段执行。上代码
function* asyncJob() {
  // ...其他代码
  var f = yield readFile(fileA);
  // ...其他代码
}

上面的asyncJob就是一个协程,在遇到yield语句时将执行权交给其他协程,等执行权返回再从暂停的地方继续执行。从写法上看和同步操作几乎一样。

协程的Generator函数实现

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

整个Generator函数就是一个封装的异步容器,或者说是异步任务的容器。需要暂停的地方都用yield语句注明。

function* gen(x) {
  var y = yield x + 2;
  return y;
}`

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面的代码返回的数据,在我的上一篇文章有做具体的解释,这里不做具体讲解,大家只要记得Generator函数返回的遍历器,只要一遇到yield语句就会暂停转交执行权给其它任务。

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

Generator函数除了可以暂停执行和恢复执行这两大特性外,还有函数体外的数据交换和错误处理机制。

首先需要知道next返回的value属性,是Generator函数向外输出数据,next方法还可以接受参数,向函数内部输入数据。

function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

上面代码,第一个next方法的value属性,返回表达式x + 2的值3,在调用gen函数时传入了一个1,由于调用时并不执行这个函数,而是返回这个函数的遍历器对象(即内部指针),所以第一个next方法不需要传入参数,返回的value3;如果调用时不传参返回的valueNaN;第二个next带有参数2,这个参作为上个阶段异步任务的结果,传入Generator函数,被函数体内的y接收,以此这一步返回的value2。这是向函数体内输入数据的操作。

Generator函数内部部署错误代码,捕获函数体外抛出的错误。

function* gen1(x){
  try {
    var y = yield x + 2;
  } catch (e){
    console.log(e);
  }
  return y;
}
var g1 = gen1(1);
console.log(g1.next());
console.log(g1.throw('出错了'));// 出错了

上面代码的最后一行,Generator函数体外,使用指针对象的throw方法抛出错误,可以被函数体内的try...catch代码块捕获。实现了出错代码和处理错误代码的分离。

异步任务的封装

var fetch = require('node-fetch');

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

这段代码执行了一个真实的异步任务,Generator函数封装了一个异步操作,该操作先是读取了一个远程接口,就是然后从JSON格式的数据解析信息。 执行方法如下

var g = gen();//获取遍历器对象
var result = g.next(); //执行异步任务的第一阶段
// fetch返回的是Promise对象需要then方法调用下一个next方法
result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

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

由此引入一个新知识点,Thunk函数。

Thunk函数

自动执行Generator函数的方法。

Thunk函数自上世纪60年代就诞生了,当时有一个争论焦点于“求值策略”,函数的参数该何时求值。

var x = 1;

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

f(x + 5)

第一种意见:传值调用

f(x + 5)
//等同于
f(6)

第二种一件:传名调用

f(x + 5)
// 等同于
(x + 5) * 2

Thunk函数的含义

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

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

f(x + 5);

// 等同于

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

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

传名调用的实现策略就是用Thunk函数替换某个表达式。

js语言的Thunk函数

js语言是传值调用,在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函数。

Generator函数的流程管理

Generator函数本身可以自动执行

function* gen() {
  // ...
}

var g = gen();
var res = g.next();

while(!res.done){
  console.log(res.value);
  res = g.next();
}

上面的gen会自动完成所有步骤,但不适合异步操作,如果必须要保证前一步执行完,才能执行后一步,就需要用到Thunk。

var fs = require('fs');
var thunkify = require('thunkify');
var readFileThunk = thunkify(fs.readFile);

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

上面的代码yield用于将程序的执行移除generator函数,那就需要一种方法将执行权交还给Generator函数。

以下手动执行上面的Genertator函数

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负责将指针移到下一步,并返回该布的信息。

Thunk函数的自动流程管理

function run(fn) {
    var gen = fn();

    function next(err, data) {
        var result = gen.next(data);
        if (result.done) return;
        result.value(next);
    }

    next();
}

function* g() {
    var f1 = yield readFileThunk('fileA');
    var f2 = yield readFileThunk('fileB');
    // ...
    var fn = yield readFileThunk('fileN');
}

run(g);

这里的run函数就是一个Generator自动执行器,内部的next就是Thunk函数的回调函数。next先将指针移到Generator函数的下一步,然后判断Generator函数是否结束,如果没结束就再次将next传入Thunk函数,否则就直接退出。这个执行器执行异步操作的前提是,在yield后面的必须是Thunk函数。

Thunk函数并非是自动执行Generator函数的唯一方式;因为自动执行的关键是自动控制Generator函数的流程,接收和交还程序的执行权。回调函数和Promise都可以做到这一点。在真实的开发场景中应该灵活应用。

结语

这篇文章作为第二篇的学习笔记,很多内容还是引用老师教程的原话。自己并没有延伸更深度的思考,对js异步和同步的区别并没做更深入的探讨;但学习都是一个知识点发散到另一个知识点,长期坚持就能构建自己的知识网络。

这次的学习让我对JS的单线程与异步感兴趣;下一个知识点学习已经有了。

加油!!!