ES6 Generator 函数的异步应用
-
定义和使用
所谓"异步",简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。JavaScript 语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。回调函数的英语名字 callback,直译过来就是"重新调用"。读取文件进行处理,是这样写的。上面代码中,readFile 函数的第三个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了 /etc/passwd 这个文件以后,回调函数才会执行。回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取 A 文件之后,再读取 B 文件,代码如下。不难想象,如果依次读取两个以上的文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。这种情况就称为"回调函数地狱"(callback hell)。Promise 对象就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。采用 Promise,连续读取多个文件,写法如下。上面代码中,我使用了 fs-readfile-promise 模块,它的作用就是返回一个 Promise 版本的 readFile 函数。Promise 提供then
方法加载回调函数,catch
方法捕捉执行过程中抛出的错误。可以看到,Promise 的写法只是回调函数的改进,使用 then 方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。
-
Generator 函数
协程传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做"协程"(coroutine),意思是多个线程互相协作,完成异步任务。协程有点像函数,又有点像线程。它的运行流程大致如下。- 第一步,协程 A 开始执行。
- 第二步,协程 A 执行到一半,进入暂停,执行权转移到协程 B。
- 第三步,(一段时间后)协程 B 交还执行权。
- 第四步,协程 A 恢复执行。
上面流程的协程 A,就是异步任务,因为它分成两段(或多段)执行。举例来说,读取文件的协程写法如下。上面代码的函数 asyncJob 是一个协程,它的奥妙就在其中的yield
命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield
命令是异步两个阶段的分界线。协程遇到yield
命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield
命令,简直一模一样。协程的 Generator 函数实现Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield
语句注明。Generator 函数的执行方法如下。上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器)g。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针 g 的next
方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield
语句,上例是执行到 x + 2 为止。换言之,next 方法的作用是分阶段执行 Generator 函数。每次调用 next 方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value 属性是 yield 语句后面表达式的值,表示当前阶段的值;done 属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。
Generator 函数的数据交换和错误处理Generator 函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。next
返回值的 value 属性,是 Generator 函数向外输出数据;next
方法还可以接受参数,向 Generator 函数体内输入数据。上面代码中,第一个next
方法的 value 属性,返回表达式 x + 2 的值 3。第二个next
方法带有参数 2,这个参数可以传入 Generator 函数,作为上个阶段异步任务的返回结果,被函数体内的变量 y 接收。因此,这一步的 value 属性,返回的就是 2(变量y的值)。Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。上面代码的最后一行,Generator 函数体外,使用指针对象的throw
方法抛出的错误,可以被函数体内的try...catch
代码块捕获。这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。异步任务的封装下面看看如何使用 Generator 函数,执行一个真实的异步任务。上面代码中,Generator 函数封装了一个异步操作,该操作先读取一个远程接口,然后从 JSON 格式的数据解析信息。就像前面说过的,这段代码非常像同步操作,除了加上了yield
命令。执行这段代码的方法如下。上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用next
方法(第二行),执行异步任务的第一阶段。由于 Fetch 模块返回的是一个 Promise 对象,因此要用then
方法调用下一个next
方法。可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。
-
Thunk 函数
Thunk 函数是自动执行 Generator 函数的一种方法。编译器的“传名调用”实现,往往是将参数放到一个临时函数之中,再将这个临时函数传入函数体。这个临时函数就叫做 Thunk 函数。上面代码中,函数 f 的参数 x + 5 被一个函数替换了。凡是用到原参数的地方,对 Thunk 函数求值即可。这就是 Thunk 函数的定义,它是“传名调用”的一种实现策略,用来替换某个表达式。
JavaScript 语言的 Thunk 函数JavaScript 语言是传值调用,它的 Thunk 函数含义有所不同。在 JavaScript 语言中,Thunk 函数替换的不是表达式,而是多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。上面代码中,fs 模块的 readFile 方法是一个多参数函数,两个参数分别为文件名和回调函数。经过转换器处理,它变成了一个单参数函数,只接受回调函数作为参数。这个单参数版本,就叫做 Thunk 函数。任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 Thunk 函数转换器。使用上面的转换器,生成 fs.readFile 的 Thunk 函数。下面是另一个完整的例子。Thunkify 模块生产环境的转换器,建议使用 Thunkify 模块。首先是安装。使用方式如下。Thunkify 的源码与上一节那个简单的转换器非常像。它的源码主要多了一个检查机制,变量 called 确保回调函数只运行一次。这样的设计与下文的 Generator 函数相关。请看下面的例子。上面代码中,由于 thunkify 只允许回调函数执行一次,所以只输出一行结果。 -
co 模块
co 模块是著名程序员 TJ Holowaychuk 于 2013 年 6 月发布的一个小工具,用于 Generator 函数的自动执行。下面是一个 Generator 函数,用于依次读取两个文件。co 模块可以让你不用编写 Generator 函数的执行器。上面代码中,Generator 函数只要传入 co 函数,就会自动执行。co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。上面代码中,等到 Generator 函数执行结束,就会输出一行提示。基于 Promise 对象的自动执行还是沿用上面的例子。首先,把 fs 模块的 readFile 方法包装成一个 Promise 对象。然后,手动执行上面的 Generator 函数。手动执行其实就是用 then 方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器。上面代码中,只要 Generator 函数还没执行到最后一步,next 函数就调用自身,以此实现自动执行。处理并发的异步操作co 支持并发的异步操作,即允许某些操作同时进行,等到它们全部完成,才进行下一步。这时,要把并发的操作都放在数组或对象里面,跟在 yield 语句后面。下面是另一个例子。上面的代码允许并发三个 omethingAsync 异步操作,等到它们全部完成,才会进行下一步。实例:处理 StreamNode 提供 Stream 模式读写数据,特点是一次只处理数据的一部分,数据分成一块块依次处理,就好像“数据流”一样。这对于处理大规模数据非常有利。Stream 模式使用 EventEmitter API,会释放三个事件。- data事件:下一块数据块已经准备好了。
- end事件:整个“数据流”处理完了。
- error事件:发生错误。
使用Promise.race()
函数,可以判断这三个事件之中哪一个最先发生,只有当 data 事件最先发生时,才进入下一个数据块的处理。从而,我们可以通过一个 while 循环,完成所有数据的读取。上面代码采用 Stream 模式读取《悲惨世界》的文本文件,对于每个数据块都使用 stream.once 方法,在 data、end、error 三个事件上添加一次性回调函数。变量 res 只有在 data 事件发生时才有值,然后累加每个数据块之中 valjean 这个词出现的次数。
-