异步任务运行

围绕着生成器的许多兴奋点都与异步编程直接相关。JS 中的异步编程是一把双刃剑:简单任务很容易用异步实现,但复杂任务就会变成代码组织方面的苦差事。由于生成器能让你在执行过程中有效地暂停代码操作,它就开启了与异步编程相关的许多可能性。

执行异步操作的传统方式是调用一个包含回调的函数。例如,考虑在 Node.js 中从磁盘读取一个文件:

let fs = require("fs");

fs.readFile("config.json", function(err, contents) {
    if (err) {
        throw err;
    }

    doSomethingWith(contents);
    console.log("Done");
});

能使用文件名与一个回调函数去调用 fs.readFile() 方法,在读取操作结束之后,回调函数就会被调用。此回调函数查看是否存在错误,若否则处理返回的 contents 数据。当你拥有数量少而有限的任务需要完成时,这么做很有效;然而当你需要嵌套回调函数,或者要按顺序处理一系列的异步任务时,此方式就会非常麻烦了。在这种场合下,生成器与 yield 会很有用。

一个简单的任务运行器

由于 yield 能停止运行,并在重新开始运行前等待 next() 方法被调用,你就可以在没有回调函数的情况下实现异步调用。首先,你需要一个能够调用生成器并启动迭代器的函数,就像这样:

function run(taskDef) {

    // create the iterator, make available elsewhere
    let task = taskDef();

    // start the task
    let result = task.next();

    // recursive function to keep calling next()
    function step() {

        // if there's more to do
        if (!result.done) {
            result = task.next();
            step();
        }
    }

    // start the process
    step();

}

run() 函数接受一个任务定义(即一个生成器函数)作为参数,它会调用生成器来创建一个迭代器,并将迭代器存放在 task 变量上。task 变量放在函数的外层,因此它可以被函数内的其他函数访问到,在后面的章节你会发现这么做的好处。第一次对 next() 的调用启动了迭代器,并将结果存储下来以便稍后使用。step() 函数查看 result.done 是否为 false,如果是就在递归调用自身之前调用 next() 方法。每次调用 next() 都会把返回的结果保存在 result 变量上,它总是会被最新的信息所重写。对于 step() 的初始调用启动了处理过程,该过程会查看 result.done 来判断是否还有更多要做的工作。

配合这个已实现的 run() 函数,你就可以运行一个包含多条 yield 语句的生成器,就像这样:

run(function*() {
    console.log(1);
    yield;
    console.log(2);
    yield;
    console.log(3);
});

此例只是将三个数值输出到控制台,单纯用于表明对 next() 的所有调用都已被执行。然而,仅仅使用几次 yield 并不太有意义,下一步是要把值传进迭代器并获取返回数据。

带数据的任务运行

传递数据给任务运行器最简单的方式,就是把 yield 返回的值传入下一次的 next() 调用。为此,你仅需传递 result.value,正如以下代码:

function run(taskDef) {

    // create the iterator, make available elsewhere
    let task = taskDef();

    // start the task
    let result = task.next();

    // recursive function to keep calling next()
    function step() {

        // if there's more to do
        if (!result.done) {
            result = task.next(result.value);
            step();
        }
    }

    // start the process
    step();

}

现在 result.value 作为参数被传递给了 next(),这样就能在 yield 调用之间传递数据了,就像这样:

run(function*() {
    let value = yield 1;
    console.log(value);         // 1

    value = yield value + 3;
    console.log(value);         // 4
});

此例向控制台输出了两个值:14。数值 1yield 1 语句而来,随之 1 又被传回给了 value 变量。数值 4 是在 value 上加了 3 的结果,并将运算结果再度赋值给 value。现在数据在对 yield 的调用之间流动了起来,仅需再来个微小改动,就能支持异步调用。

异步任务运行器

上个例子只是在 yield 之间来回传递静态数据,但等待一个异步处理与此稍微有点差异。任务运行器需要了解回调函数,并了解如何使用它们。并且由于 yield 表达式将它们的值传递给了任务运行器,这就意味着任意函数调用都必须返回一个值,并以某种方式标明该返回值是个异步操作调用,而任务运行器应当等待此操作。

此处是将返回值标明为异步操作的一种方法:

function fetchData() {
    return function(callback) {
        callback(null, "Hi!");
    };
}

此例的目的是:任何打算让任务运行器调用的函数,都应当返回一个能够执行回调函数的函数。fetchData() 函数所返回的函数能接受一个回调函数作为其参数,当返回的函数被调用时,它会执行回调函数并附加一点额外数据(即 "Hi!" 字符串)。该回调函数需要由任务运行器提供,以确保回调函数能与当前的迭代器正确交互。虽然 fetchData() 函数是同步的,但你能延迟对回调函数的调用, 从而轻易地将它改造为异步函数,就像这样:

function fetchData() {
    return function(callback) {
        setTimeout(function() {
            callback(null, "Hi!");
        }, 50);
    };
}

此版本的 fetchData() 在调用回调函数之前引入了 50 毫秒的延迟,说明此模式在同步或异步代码上都能同样良好运作。你只要保证每个需要被 yield 调用的函数都遵循此模式。

在深入理解函数如何标注自己是一个异步处理后,你就可以结合这种模式来改造任务运行器。只要 result.value 是一个函数,任务运行器就应当执行它,而不是仅仅将它传递给 next() 方法。此处有更新后的代码:

function run(taskDef) {

    // create the iterator, make available elsewhere
    let task = taskDef();

    // start the task
    let result = task.next();

    // recursive function to keep calling next()
    function step() {

        // if there's more to do
        if (!result.done) {
            if (typeof result.value === "function") {
                result.value(function(err, data) {
                    if (err) {
                        result = task.throw(err);
                        return;
                    }

                    result = task.next(data);
                    step();
                });
            } else {
                result = task.next(result.value);
                step();
            }

        }
    }

    // start the process
    step();

}

result.value 是个函数时(使用 === 运算符来判断),它会被使用一个回调函数进行调用。该回调函数遵循了 Node.js 的惯例,将任何潜在错误作为第一个参数(err)传入,而处理结果则作为第二个参数。若 err 非空,也就表示有错误发生,需要使用该错误对象去调用 task.throw(),而不是调用 task.next(),这样错误就会在恰当的位置被抛出;若不存在错误,data 参数将会被传入 task.next(),而其调用结果也会被保存下来。接下来,调用 step() 来继续处理过程。若 result.value 并非函数,它就会被直接传递给 next() 方法。

任务运行器的这个新版本已经为所有的异步任务准备好了。为了在 Node.js 中从一个文件读取数据,你需要创建对于 fs.readFile() 的一个封装,它类似于本节起始处的 fetchData() 函数,能返回另一个函数。例如:

let fs = require("fs");

function readFile(filename) {
    return function(callback) {
        fs.readFile(filename, callback);
    };
}

这个 readFile() 方法接受单个参数,即文件名,并返回一个能执行回调函数的函数。此回调函数会被直接传递给 fs.readFile() 方法,后者会在操作完成后执行回调。接下来你就可以使用 yield 来运行这个任务,如下:

run(function*() {
    let contents = yield readFile("config.json");
    doSomethingWith(contents);
    console.log("Done");
});

此例执行了异步的 readFile() 操作,而在主要代码中并未暴露出任何回调函数。除了 yield 之外,此代码看起来与同步代码并无二致。既然执行异步操作的函数都遵循了同一接口,你就可以用貌似同步的代码来书写处理逻辑。

当然,这些范例中所使用的模式也有缺点:你无法完全确认一个能返回函数的函数是异步的。但现在唯一重要的是让你理解任务运行背后的原理。promise 提供了更强有力的方式来调度异步任务,第十一章会进一步介绍这个主题。