ES6之Generator 函数的异步应用
date
Jun 13, 2018
slug
mgehdbqt
status
Published
tags
JavaScript
summary
type
Post
[toc]
异步编程对 JavaScript 语言太重要。JavaScript 语言的执行环境是“单线程”的,如果没有异步编程,根本没法用,非卡死不可。本章主要介绍 Generator 函数如何完成异步操作。
ES6 诞生以前,异步编程的方法,大概有下面四种。
- 回调函数
- 事件监听
- 发布/订阅
- Promise 对象
基本概念
(1)异步 所谓“异步”,简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。
(2)回调函数 JavaScript 语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。回调函数的英语名字 callback,直译过来就是“重新调用”。
一个有趣的问题是,为什么 Node 约定,回调函数的第一个参数,必须是错误对象 err(如果没有错误,该参数就是 null)?
原因是执行分成两段,第一段执行完以后,任务所在的上下文环境就已经结束了。在这以后抛出的错误,原来的上下文环境已经无法捕捉,只能当作参数,传入第二段。
(3)Promise Promise 对象就是为了解决“回调函数地狱”(callback hell)而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。
Promise 的写法只是回调函数的改进,使用 then 方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,原来的语义变得很不清楚。
Generator 函数
协程
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做“协程”(coroutine),意思是多个线程互相协作,完成异步任务。
协程有点像函数,又有点像线程。它的运行流程大致如下。
- 第一步,协程 A 开始执行。
- 第二步,协程 A 执行到一半,进入暂停,执行权转移到协程 B。
- 第三步,(一段时间后)协程 B 交还执行权。
- 第四步,协程 A 恢复执行。
上面流程的协程 A,就是异步任务,因为它分成两段(或多段)执行。
function* asyncJob() { // ...其他代码 var f = yield readFile(fileA); // ...其他代码}
上面代码的函数 asyncJob 是一个协程,它的奥妙就在其中的 yield 命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield 命令是异步两个阶段的分界线。协程遇到 yield 命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除 yield 命令,简直一模一样。
协程的 Generator 函数实现
Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。
Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针 g 的 next 方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的 yield 语句。换言之,next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象,表示当前阶段的信息(value 属性和 done 属性)。
Generator 函数的数据交换和错误处理
Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。
- next 返回值的 value 属性,是 Generator 函数向外输出数据;next 方法还可以接受参数,向 Generator 函数体内输入数据。
- 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(function(data){ return data.json();}).then(function(data){ g.next(data);});
上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用 next 方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个 Promise 对象,因此要用 then 方法调用下一个 next 方法。可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。
Thunk 函数
Thunk 函数是自动执行 Generator 函数的一种方法。参数的求值策略:传值调用 和 传名调用。
- 传值调用:call by value,即在进入函数体之前,就计算实参的值,再将这个值传入函数。C 语言就采用这种策略。
- 传名调用:call by name,即在将实参的表达式传入函数体,只在用到它的时候求值。Haskell 语言采用这种策略。
Thunk 函数的含义:编译器的“传名调用”实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。它是“传名调用”的一种实现策略,用来替换某个表达式。——参考【函数的扩展 - 作用域】
function f(m) { return m * 2;}f(x + 5);// 等同于var thunk = function () { return x + 5;};function f(thunk) { return thunk() * 2;}
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);
上面代码中,fs 模块的 readFile 方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 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); } };};
生产环境的转换器,建议使用 Thunkify 模块。
Generator 函数的流程管理
ES6 有了 Generator 函数,Thunk 函数现在可以用于 Generator 函数的自动流程管理。Generator 函数可以自动执行:
function* gen() { // ...}var g = gen();var res = g.next();while(!res.done){ console.log(res.value); res = g.next();}
上面必须保证前一步执行完,才能执行后一步,否则上面的自动执行就不可行。以读取文件为例。下面的 Generator 函数封装了两个异步操作。
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());};
这种方法就是 Thunk 函数,因为它可以在回调函数里,将执行权交还给 Generator 函数。发现 Generator 函数的执行过程,其实是将同一个回调函数,反复传入 next 方法的 value 属性。这使得我们可以用递归来自动完成这个过程。
Thunk 函数的自动流程管理
Thunk 函数真正的威力,在于可以自动执行 Generator 函数。
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() { // ...}run(g);
上面代码的 run 函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。next 函数先将指针移到 Generator 函数的下一步(gen.next 方法),然后判断 Generator 函数是否结束(result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数(result.value 属性),否则就直接退出。
有了这个执行器,执行 Generator 函数方便多了。不管内部有多少个异步操作,直接把 Generator 函数传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。
var g = function* (){ var f1 = yield readFileThunk('fileA'); var f2 = yield readFileThunk('fileB'); // ... var fn = yield readFileThunk('fileN');};run(g);
Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。
co 模块
co 模块是著名程序员 TJ Holowaychuk 于 2013 年 6 月发布的一个小工具,用于 Generator 函数的自动执行。co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。
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 模块的原理
为什么 co 可以自动执行 Generator 函数?前面说过,Generator 就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。两种方法可以做到这一点。 (1)回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。 (2)Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交回执行权。
co 模块其实就是将两种自动执行器(Thunk 函数和 Promise 对象),包装成一个模块。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或 Promise 对象。如果数组或对象的成员,全部都是 Promise 对象,也可以使用 co,详见后文的例子。上一节已经介绍了基于 Thunk 函数的自动执行器。下面来看,基于 Promise 对象的自动执行器。这是理解 co 模块必须的。
co 模块的源码
- 首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。
- 接着,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulfilled 函数。这主要是为了能够捕捉抛出的错误。
- 最后,就是关键的 next 函数,它会反复调用自身。
处理并发的异步操作
co 支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。这时,要把并发的操作都放在数组或对象里面,跟在 yield 语句后面。
// 数组的写法co(function* () { var res = yield [ Promise.resolve(1), Promise.resolve(2) ]; console.log(res);}).catch(onerror);// 对象的写法co(function* () { var res = yield { 1: Promise.resolve(1), 2: Promise.resolve(2), }; console.log(res);}).catch(onerror);// 另一个例子co(function* () { var values = [n1, n2, n3]; yield values.map(somethingAsync);});function* somethingAsync(x) { // do something async return y}
上面的代码允许并发三个 somethingAsync 异步操作,等到它们全部完成,才会进行下一步。