异步任务运行
围绕着生成器的许多兴奋点都与异步编程直接相关。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
});
此例向控制台输出了两个值:1
与 4
。数值 1
由 yield 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
提供了更强有力的方式来调度异步任务,第十一章会进一步介绍这个主题。