第十二章:迭代器和生成器
可迭代对象及其相关的迭代器是 ES6 的一个特性,在本书中我们已经多次见到。数组(包括 TypedArrays)、字符串以及 Set 和 Map 对象都是可迭代的。这意味着这些数据结构的内容可以被迭代——使用for/of循环遍历,就像我们在§5.4.4 中看到的那样:
let sum = 0;
for(let i of [1,2,3]) { // Loop once for each of these values
sum += i;
}
sum // => 6
迭代器也可以与...运算符一起使用,将可迭代对象展开或“扩展”到数组初始化程序或函数调用中,就像我们在§7.1.2 中看到的那样:
let chars = [..."abcd"]; // chars == ["a", "b", "c", "d"]
let data = [1, 2, 3, 4, 5];
Math.max(...data) // => 5
迭代器可以与解构赋值一起使用:
let purpleHaze = Uint8Array.of(255, 0, 255, 128);
let [r, g, b, a] = purpleHaze; // a == 128
当你迭代 Map 对象时,返回的值是[key, value]对,这与for/of循环中的解构赋值很好地配合使用:
let m = new Map([["one", 1], ["two", 2]]);
for(let [k,v] of m) console.log(k, v); // Logs 'one 1' and 'two 2'
如果你只想迭代键或值而不是键值对,可以使用keys()和values()方法:
[...m] // => [["one", 1], ["two", 2]]: default iteration
[...m.entries()] // => [["one", 1], ["two", 2]]: entries() method is the same
[...m.keys()] // => ["one", "two"]: keys() method iterates just map keys
[...m.values()] // => [1, 2]: values() method iterates just map values
最后,一些常用于 Array 对象的内置函数和构造函数实际上(在 ES6 及更高版本中)被编写为接受任意迭代器。Set()构造函数就是这样一个 API:
// Strings are iterable, so the two sets are the same:
new Set("abc") // => new Set(["a", "b", "c"])
本章解释了迭代器的工作原理,并演示了如何创建自己的可迭代数据结构。在解释基本迭代器之后,本章涵盖了生成器,这是 ES6 的一个强大新功能,主要用作一种特别简单的创建迭代器的方法。
12.1 迭代器的工作原理
for/of循环和展开运算符与可迭代对象无缝配合,但值得理解实际上是如何使迭代工作的。在理解 JavaScript 中的迭代过程时,有三种不同的类型需要理解。首先是可迭代对象:这些是可以被迭代的类型,如 Array、Set 和 Map。其次,是执行迭代的迭代器对象本身。第三,是保存迭代每一步结果的迭代结果对象。
可迭代对象是任何具有特殊迭代器方法的对象,该方法返回一个迭代器对象。迭代器是任何具有返回迭代结果对象的next()方法的对象。而迭代结果对象是具有名为value和done的属性的对象。要迭代可迭代对象,首先调用其迭代器方法以获取一个迭代器对象。然后,重复调用迭代器对象的next()方法,直到返回的值的done属性设置为true为止。关于这一点的棘手之处在于,可迭代对象的迭代器方法没有传统的名称,而是使用符号Symbol.iterator作为其名称。因此,对可迭代对象iterable进行简单的for/of循环也可以以较困难的方式编写,如下所示:
let iterable = [99];
let iterator = iterable[Symbol.iterator]();
for(let result = iterator.next(); !result.done; result = iterator.next()) {
console.log(result.value) // result.value == 99
}
内置可迭代数据类型的迭代器对象本身也是可迭代的。(也就是说,它有一个名为Symbol.iterator的方法,该方法返回自身。)这在以下代码中偶尔会有用,当你想要遍历“部分使用过”的迭代器时:
let list = [1,2,3,4,5];
let iter = list[Symbol.iterator]();
let head = iter.next().value; // head == 1
let tail = [...iter]; // tail == [2,3,4,5]
12.2 实现可迭代对象
在 ES6 中,可迭代对象非常有用,因此当它们表示可以被迭代的内容时,你应该考虑使自己的数据类型可迭代。在第 9-2 和第 9-3 示例中展示的 Range 类是可迭代的。这些类使用生成器函数使自己可迭代。我们稍后会介绍生成器,但首先,我们将再次实现 Range 类,使其可迭代而不依赖于生成器。
要使类可迭代,必须实现一个方法,其名称为符号Symbol.iterator。该方法必须返回具有next()方法的迭代器对象。而next()方法必须返回具有value属性和/或布尔done属性的迭代结果对象。示例 12-1 实现了一个可迭代的 Range 类,并演示了如何创建可迭代、迭代器和迭代结果对象。
示例 12-1. 一个可迭代的数字范围类
/*
* A Range object represents a range of numbers {x: from <= x <= to}
* Range defines a has() method for testing whether a given number is a member
* of the range. Range is iterable and iterates all integers within the range.
*/
class Range {
constructor (from, to) {
this.from = from;
this.to = to;
}
// Make a Range act like a Set of numbers
has(x) { return typeof x === "number" && this.from <= x && x <= this.to; }
// Return string representation of the range using set notation
toString() { return `{ x | ${this.from} ≤ x ≤ ${this.to} }`; }
// Make a Range iterable by returning an iterator object.
// Note that the name of this method is a special symbol, not a string.
[Symbol.iterator]() {
// Each iterator instance must iterate the range independently of
// others. So we need a state variable to track our location in the
// iteration. We start at the first integer >= from.
let next = Math.ceil(this.from); // This is the next value we return
let last = this.to; // We won't return anything > this
return { // This is the iterator object
// This next() method is what makes this an iterator object.
// It must return an iterator result object.
next() {
return (next <= last) // If we haven't returned last value yet
? { value: next++ } // return next value and increment it
: { done: true }; // otherwise indicate that we're done.
},
// As a convenience, we make the iterator itself iterable.
[Symbol.iterator]() { return this; }
};
}
}
for(let x of new Range(1,10)) console.log(x); // Logs numbers 1 to 10
[...new Range(-2,2)] // => [-2, -1, 0, 1, 2]
除了使您的类可迭代之外,定义返回可迭代值的函数也非常有用。考虑这些基于迭代的替代方案,用于 JavaScript 数组的map()和filter()方法:
// Return an iterable object that iterates the result of applying f()
// to each value from the source iterable
function map(iterable, f) {
let iterator = iterable[Symbol.iterator]();
return { // This object is both iterator and iterable
[Symbol.iterator]() { return this; },
next() {
let v = iterator.next();
if (v.done) {
return v;
} else {
return { value: f(v.value) };
}
}
};
}
// Map a range of integers to their squares and convert to an array
[...map(new Range(1,4), x => x*x)] // => [1, 4, 9, 16]
// Return an iterable object that filters the specified iterable,
// iterating only those elements for which the predicate returns true
function filter(iterable, predicate) {
let iterator = iterable[Symbol.iterator]();
return { // This object is both iterator and iterable
[Symbol.iterator]() { return this; },
next() {
for(;;) {
let v = iterator.next();
if (v.done || predicate(v.value)) {
return v;
}
}
}
};
}
// Filter a range so we're left with only even numbers
[...filter(new Range(1,10), x => x % 2 === 0)] // => [2,4,6,8,10]
可迭代对象和迭代器的一个关键特性是它们本质上是惰性的:当需要计算下一个值时,该计算可以推迟到实际需要该值时。例如,假设您有一个非常长的文本字符串,您希望将其标记为以空格分隔的单词。您可以简单地使用字符串的split()方法,但如果这样做,那么必须在使用第一个单词之前处理整个字符串。并且您最终会为返回的数组及其中的所有字符串分配大量内存。以下是一个函数,允许您惰性迭代字符串的单词,而无需一次性将它们全部保存在内存中(在 ES2020 中,使用返回迭代器的matchAll()方法更容易实现此函数,该方法在 §11.3.2 中描述):
function words(s) {
var r = /\s+|$/g; // Match one or more spaces or end
r.lastIndex = s.match(/[^ ]/).index; // Start matching at first nonspace
return { // Return an iterable iterator object
[Symbol.iterator]() { // This makes us iterable
return this;
},
next() { // This makes us an iterator
let start = r.lastIndex; // Resume where the last match ended
if (start < s.length) { // If we're not done
let match = r.exec(s); // Match the next word boundary
if (match) { // If we found one, return the word
return { value: s.substring(start, match.index) };
}
}
return { done: true }; // Otherwise, say that we're done
}
};
}
[...words(" abc def ghi! ")] // => ["abc", "def", "ghi!"]
12.2.1 “关闭”迭代器:返回方法
想象一个(服务器端)JavaScript 变体的words()迭代器,它不是以源字符串作为参数,而是以文件流作为参数,打开文件,从中读取行,并迭代这些行中的单词。在大多数操作系统中,打开文件以从中读取的程序在完成读取后需要记住关闭这些文件,因此这个假设的迭代器将确保在next()方法返回其中的最后一个单词后关闭文件。
但迭代器并不总是运行到结束:for/of循环可能会被break、return或异常终止。同样,当迭代器与解构赋值一起使用时,next()方法只会被调用足够次数以获取每个指定变量的值。迭代器可能有更多值可以返回,但它们永远不会被请求。
如果我们假设的文件中的单词迭代器从未完全运行到结束,它仍然需要关闭打开的文件。因此,迭代器对象可能会实现一个return()方法,与next()方法一起使用。如果在next()返回具有done属性设置为true的迭代结果之前迭代停止(通常是因为您通过break语句提前离开了for/of循环),那么解释器将检查迭代器对象是否具有return()方法。如果存在此方法,解释器将以无参数调用它,使迭代器有机会关闭文件,释放内存,并在完成后进行清理。return()方法必须返回一个迭代结果对象。对象的属性将被忽略,但返回非对象值是错误的。
for/of循环和展开运算符是 JavaScript 的非常有用的特性,因此在创建 API 时,尽可能使用它们是一个好主意。但是,必须使用可迭代对象、其迭代器对象和迭代器的结果对象来处理过程有些复杂。幸运的是,生成器可以极大地简化自定义迭代器的创建,我们将在本章的其余部分中看到。
12.3 生成器
生成器是一种使用强大的新 ES6 语法定义的迭代器;当要迭代的值不是数据结构的元素,而是计算结果时,它特别有用。
要创建一个生成器,你必须首先定义一个生成器函数。生成器函数在语法上类似于普通的 JavaScript 函数,但是用关键字function*而不是function来定义。(从技术上讲,这不是一个新关键字,只是在关键字function之后和函数名之前加上一个*。)当你调用一个生成器函数时,它实际上不会执行函数体,而是返回一个生成器对象。这个生成器对象是一个迭代器。调用它的next()方法会导致生成器函数的主体从头开始运行(或者从当前位置开始),直到达到一个yield语句。yield在 ES6 中是新的,类似于return语句。yield语句的值成为迭代器上next()调用返回的值。通过示例可以更清楚地理解这一点:
// A generator function that yields the set of one digit (base-10) primes.
function* oneDigitPrimes() { // Invoking this function does not run the code
yield 2; // but just returns a generator object. Calling
yield 3; // the next() method of that generator runs
yield 5; // the code until a yield statement provides
yield 7; // the return value for the next() method.
}
// When we invoke the generator function, we get a generator
let primes = oneDigitPrimes();
// A generator is an iterator object that iterates the yielded values
primes.next().value // => 2
primes.next().value // => 3
primes.next().value // => 5
primes.next().value // => 7
primes.next().done // => true
// Generators have a Symbol.iterator method to make them iterable
primes[Symbol.iterator]() // => primes
// We can use generators like other iterable types
[...oneDigitPrimes()] // => [2,3,5,7]
let sum = 0;
for(let prime of oneDigitPrimes()) sum += prime;
sum // => 17
在这个例子中,我们使用了function*语句来定义一个生成器。然而,和普通函数一样,我们也可以以表达式形式定义生成器。再次强调,我们只需在function关键字后面加上一个星号:
const seq = function*(from,to) {
for(let i = from; i <= to; i++) yield i;
};
[...seq(3,5)] // => [3, 4, 5]
在类和对象字面量中,我们可以使用简写符号来完全省略定义方法时的function关键字。在这种情况下定义生成器,我们只需在方法名之前使用一个星号,而不是使用function关键字:
let o = {
x: 1, y: 2, z: 3,
// A generator that yields each of the keys of this object
*g() {
for(let key of Object.keys(this)) {
yield key;
}
}
};
[...o.g()] // => ["x", "y", "z", "g"]
请注意,没有办法使用箭头函数语法编写生成器函数。
生成器通常使得定义可迭代类变得特别容易。我们可以用一个更简短的*Symbol.iterator]()生成器函数来替换[示例 12-1 中展示的[Symbol.iterator]()方法,代码如下:
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}
查看第九章中的示例 9-3 以查看上下文中基于生成器的迭代器函数。
12.3.1 生成器示例
如果生成器实际上生成它们通过进行某种计算来产生的值,那么生成器就更有趣了。例如,这里是一个产生斐波那契数的生成器函数:
function* fibonacciSequence() {
let x = 0, y = 1;
for(;;) {
yield y;
[x, y] = [y, x+y]; // Note: destructuring assignment
}
}
注意,这里的fibonacciSequence()生成器函数有一个无限循环,并且永远产生值而不返回。如果这个生成器与...扩展运算符一起使用,它将循环直到内存耗尽并且程序崩溃。然而,经过谨慎处理,可以在for/of循环中使用它:
// Return the nth Fibonacci number
function fibonacci(n) {
for(let f of fibonacciSequence()) {
if (n-- <= 0) return f;
}
}
fibonacci(20) // => 10946
这种无限生成器与这样的take()生成器结合使用更有用:
// Yield the first n elements of the specified iterable object
function* take(n, iterable) {
let it = iterable[Symbol.iterator](); // Get iterator for iterable object
while(n-- > 0) { // Loop n times:
let next = it.next(); // Get the next item from the iterator.
if (next.done) return; // If there are no more values, return early
else yield next.value; // otherwise, yield the value
}
}
// An array of the first 5 Fibonacci numbers
[...take(5, fibonacciSequence())] // => [1, 1, 2, 3, 5]
这里是另一个有用的生成器函数,它交错多个可迭代对象的元素:
// Given an array of iterables, yield their elements in interleaved order.
function* zip(...iterables) {
// Get an iterator for each iterable
let iterators = iterables.map(i => i[Symbol.iterator]());
let index = 0;
while(iterators.length > 0) { // While there are still some iterators
if (index >= iterators.length) { // If we reached the last iterator
index = 0; // go back to the first one.
}
let item = iterators[index].next(); // Get next item from next iterator.
if (item.done) { // If that iterator is done
iterators.splice(index, 1); // then remove it from the array.
}
else { // Otherwise,
yield item.value; // yield the iterated value
index++; // and move on to the next iterator.
}
}
}
// Interleave three iterable objects
[...zip(oneDigitPrimes(),"ab",[0])] // => [2,"a",0,3,"b",5,7]
12.3.2 yield* 和递归生成器
除了在前面的示例中定义的zip()生成器之外,可能还有一个类似的生成器函数很有用,它按顺序而不是交错地产生多个可迭代对象的元素。我们可以这样编写这个生成器:
function* sequence(...iterables) {
for(let iterable of iterables) {
for(let item of iterable) {
yield item;
}
}
}
[...sequence("abc",oneDigitPrimes())] // => ["a","b","c",2,3,5,7]
在生成器函数中产生其他可迭代对象的元素的过程在生成器函数中是很常见的,ES6 为此提供了特殊的语法。yield*关键字类似于yield,不同之处在于,它不是产生单个值,而是迭代一个可迭代对象并产生每个结果值。我们使用的sequence()生成器函数可以用yield*简化如下:
function* sequence(...iterables) {
for(let iterable of iterables) {
yield* iterable;
}
}
[...sequence("abc",oneDigitPrimes())] // => ["a","b","c",2,3,5,7]
数组的forEach()方法通常是遍历数组元素的一种优雅方式,因此你可能会尝试像这样编写sequence()函数:
function* sequence(...iterables) {
iterables.forEach(iterable => yield* iterable ); // Error
}
然而,这是行不通的。yield和yield*只能在生成器函数内部使用,但是这段代码中的嵌套箭头函数是一个普通函数,而不是function*生成器函数,因此不允许使用yield。
yield*可以与任何类型的可迭代对象一起使用,包括使用生成器实现的可迭代对象。这意味着yield*允许我们定义递归生成器,你可以使用这个特性来允许对递归定义的树结构进行简单的非递归迭代,例如。
12.4 高级生成器功能
生成器函数最常见的用途是创建迭代器,但生成器的基本特性是允许我们暂停计算,产生中间结果,然后稍后恢复计算。这意味着生成器具有超出迭代器的功能,并且我们将在以下部分探讨这些功能。
12.4.1 生成器函数的返回值
到目前为止,我们看到的生成器函数没有return语句,或者如果有的话,它们被用来导致早期返回,而不是返回一个值。不过,与任何函数一样,生成器函数可以返回一个值。为了理解在这种情况下会发生什么,回想一下迭代的工作原理。next()函数的返回值是一个具有value属性和/或done属性的对象。对于典型的迭代器和生成器,如果value属性被定义,则done属性未定义或为false。如果done为true,则value为未定义。但是对于返回值的生成器,最后一次调用next会返回一个同时定义了value和done的对象。value属性保存生成器函数的返回值,done属性为true,表示没有更多的值可迭代。这个最终值被for/of循环和展开运算符忽略,但对于手动使用显式调用next()的代码是可用的:
function *oneAndDone() {
yield 1;
return "done";
}
// The return value does not appear in normal iteration.
[...oneAndDone()] // => [1]
// But it is available if you explicitly call next()
let generator = oneAndDone();
generator.next() // => { value: 1, done: false}
generator.next() // => { value: "done", done: true }
// If the generator is already done, the return value is not returned again
generator.next() // => { value: undefined, done: true }
12.4.2 yield 表达式的值
在前面的讨论中,我们将yield视为接受值但没有自身值的语句。实际上,yield是一个表达式,它可以有一个值。
当调用生成器的next()方法时,生成器函数运行直到达到yield表达式。yield关键字后面的表达式被评估,该值成为next()调用的返回值。此时,生成器函数在评估yield表达式的过程中停止执行。下次调用生成器的next()方法时,传递给next()的参数成为暂停的yield表达式的值。因此,生成器通过yield向其调用者返回值,调用者通过next()向生成器传递值。生成器和调用者是两个独立的执行流,来回传递值(和控制)。以下代码示例:
function* smallNumbers() {
console.log("next() invoked the first time; argument discarded");
let y1 = yield 1; // y1 == "b"
console.log("next() invoked a second time with argument", y1);
let y2 = yield 2; // y2 == "c"
console.log("next() invoked a third time with argument", y2);
let y3 = yield 3; // y3 == "d"
console.log("next() invoked a fourth time with argument", y3);
return 4;
}
let g = smallNumbers();
console.log("generator created; no code runs yet");
let n1 = g.next("a"); // n1.value == 1
console.log("generator yielded", n1.value);
let n2 = g.next("b"); // n2.value == 2
console.log("generator yielded", n2.value);
let n3 = g.next("c"); // n3.value == 3
console.log("generator yielded", n3.value);
let n4 = g.next("d"); // n4 == { value: 4, done: true }
console.log("generator returned", n4.value);
当运行这段代码时,会产生以下输出,展示了两个代码块之间的来回交互:
generator created; no code runs yet
next() invoked the first time; argument discarded
generator yielded 1
next() invoked a second time with argument b
generator yielded 2
next() invoked a third time with argument c
generator yielded 3
next() invoked a fourth time with argument d
generator returned 4
注意这段代码中的不对称性。第一次调用next()启动了生成器,但传递给该调用的值对生成器不可访问。
12.4.3 生成器的 return()和 throw()方法
我们已经看到可以接收生成器函数产生的值。您可以通过在调用生成器的next()方法时传递这些值来向正在运行的生成器传递值。
除了使用next()向生成器提供输入外,还可以通过调用其return()和throw()方法来更改生成器内部的控制流。如其名称所示,调用这些方法会导致生成器返回一个值或抛出异常,就好像生成器中的下一条语句是return或throw一样。
在本章的前面提到,如果迭代器定义了一个return()方法并且迭代提前停止,那么解释器会自动调用return()方法,以便让迭代器有机会关闭文件或进行其他清理工作。对于生成器来说,你不能定义一个自定义的return()方法来处理清理工作,但你可以结构化生成器代码以使用try/finally语句,在生成器返回时确保必要的清理工作已完成(在finally块中)。通过强制生成器返回,生成器的内置return()方法确保在生成器不再使用时运行清理代码。
就像生成器的next()方法允许我们向正在运行的生成器传递任意值一样,生成器的throw()方法给了我们一种向生成器发送任意信号(以异常的形式)的方法。调用throw()方法总是在生成器内部引发异常。但如果生成器函数编写了适当的异常处理代码,异常不必是致命的,而可以是改变生成器行为的手段。例如,想象一个计数器生成器,产生一个不断增加的整数序列。这可以被编写成使用throw()发送的异常将计数器重置为零。
当生成器使用yield*从其他可迭代对象中产生值时,那么对生成器的next()方法的调用会导致对可迭代对象的next()方法的调用。return()和throw()方法也是如此。如果生成器在可迭代对象上使用yield*,那么在生成器上调用return()或throw()会导致依次调用迭代器的return()或throw()方法。所有迭代器必须有一个next()方法。需要在不完整迭代后进行清理的迭代器应该定义一个return()方法。任何迭代器可以定义一个throw()方法,尽管我不知道任何实际原因这样做。
12.4.4 关于生成器的最后说明
生成器是一种非常强大的通用控制结构。它们使我们能够使用yield暂停计算,并在任意后续时间点以任意输入值重新启动。可以使用生成器在单线程 JavaScript 代码中创建一种协作线程系统。也可以使用生成器掩盖程序中的异步部分,使你的代码看起来是顺序和同步的,尽管你的一些函数调用实际上是异步的并依赖于网络事件。
尝试用生成器做这些事情会导致代码难以理解或解释。然而,已经做到了,唯一真正实用的用例是管理异步代码。然而,JavaScript 现在有async和await关键字(见第十三章)用于这个目的,因此不再有任何滥用生成器的理由。
12.5 总结
在本章中,你学到了:
-
for/of循环和...扩展运算符适用于可迭代对象。 -
如果一个对象有一个名为
[Symbol.iterator]的方法返回一个迭代器对象,那么它就是可迭代的。 -
迭代器对象有一个
next()方法返回一个迭代结果对象。 -
迭代结果对象有一个
value属性,保存下一个迭代的值(如果有的话)。如果迭代已完成,则结果对象必须将done属性设置为true。 -
你可以通过定义一个
[Symbol.iterator]()方法返回一个具有next()方法返回迭代结果对象的对象来实现自己的可迭代对象。你也可以实现接受迭代器参数并返回迭代器值的函数。 -
生成器函数(使用
function*而不是function定义的函数)是定义迭代器的另一种方式。 -
当调用生成器函数时,函数体不会立即运行;相反,返回值是一个可迭代的迭代器对象。每次调用迭代器的
next()方法时,生成器函数的另一个块会运行。 -
生成器函数可以使用
yield运算符指定迭代器返回的值。每次调用next()都会导致生成器函数运行到下一个yield表达式。该yield表达式的值然后成为迭代器返回的值。当没有更多的yield表达式时,生成器函数返回,迭代完成。
第十三章:异步 JavaScript
一些计算机程序,如科学模拟和机器学习模型,是计算密集型的:它们持续运行,不间断,直到计算出结果为止。然而,大多数现实世界的计算机程序都是显著异步的。这意味着它们经常需要在等待数据到达或某个事件发生时停止计算。在 Web 浏览器中,JavaScript 程序通常是事件驱动的,这意味着它们等待用户点击或轻触才会实际执行任何操作。而基于 JavaScript 的服务器通常在等待客户端请求通过网络到达之前不会执行任何操作。
这种异步编程在 JavaScript 中很常见,本章记录了三个重要的语言特性,帮助简化处理异步代码。Promise 是 ES6 中引入的对象,表示尚未可用的异步操作的结果。关键字async和await是在 ES2017 中引入的,通过允许你将基于 Promise 的代码结构化为同步的形式,简化了异步编程的语法。最后,在 ES2018 中引入了异步迭代器和for/await循环,允许你使用看似同步的简单循环处理异步事件流。
具有讽刺意味的是,尽管 JavaScript 提供了这些强大的功能来处理异步代码,但核心语法本身没有异步特性。因此,为了演示 Promise、async、await和for/await,我们将首先进入客户端和服务器端 JavaScript,解释 Web 浏览器和 Node 的一些异步特性。(你可以在第十五章和第十六章了解更多关于客户端和服务器端 JavaScript 的内容。)
13.1 使用回调进行异步编程
在 JavaScript 中,异步编程的最基本层次是通过回调完成的。回调是你编写并传递给其他函数的函数。当满足某些条件或发生某些(异步)事件时,另一个函数会调用(“回调”)你的函数。你提供的回调函数的调用会通知你条件或事件,并有时,调用会包括提供额外细节的函数参数。通过一些具体的例子更容易理解,接下来的小节演示了使用客户端 JavaScript 和 Node 进行基于回调的异步编程的各种形式。
13.1.1 定时器
最简单的异步之一是当你想在一定时间后运行一些代码时。正如我们在§11.10 中看到的,你可以使用setTimeout()函数来实现:
setTimeout(checkForUpdates, 60000);
setTimeout()的第一个参数是一个函数,第二个是以毫秒为单位的时间间隔。在上述代码中,一个假设的checkForUpdates()函数将在setTimeout()调用后的 60,000 毫秒(1 分钟)后被调用。checkForUpdates()是你的程序可能定义的回调函数,setTimeout()是你调用以注册回调函数并指定在何种异步条件下调用它的函数。
setTimeout()调用指定的回调函数一次,不传递任何参数,然后忘记它。如果你正在编写一个真正检查更新的函数,你可能希望它重复运行。你可以使用setInterval()而不是setTimeout()来实现这一点:
// Call checkForUpdates in one minute and then again every minute after that
let updateIntervalId = setInterval(checkForUpdates, 60000);
// setInterval() returns a value that we can use to stop the repeated
// invocations by calling clearInterval(). (Similarly, setTimeout()
// returns a value that you can pass to clearTimeout())
function stopCheckingForUpdates() {
clearInterval(updateIntervalId);
}
13.1.2 事件
客户端 JavaScript 程序几乎普遍是事件驱动的:而不是运行某种预定的计算,它们通常等待用户执行某些操作,然后响应用户的动作。当用户在键盘上按键、移动鼠标、点击鼠标按钮或触摸触摸屏设备时,Web 浏览器会生成一个事件。事件驱动的 JavaScript 程序在指定的上下文中为指定类型的事件注册回调函数,当指定的事件发生时,Web 浏览器会调用这些函数。这些回调函数称为事件处理程序或事件监听器,并使用addEventListener()进行注册:
// Ask the web browser to return an object representing the HTML
// <button> element that matches this CSS selector
let okay = document.querySelector('#confirmUpdateDialog button.okay');
// Now register a callback function to be invoked when the user
// clicks on that button.
okay.addEventListener('click', applyUpdate);
在这个例子中,applyUpdate()是一个我们假设在其他地方实现的虚构回调函数。调用document.querySelector()返回一个表示 Web 页面中单个指定元素的对象。我们在该元素上调用addEventListener()来注册我们的回调。然后addEventListener()的第一个参数是一个字符串,指定我们感兴趣的事件类型——在这种情况下是鼠标点击或触摸屏点击。如果用户点击或触摸 Web 页面的特定元素,那么浏览器将调用我们的applyUpdate()回调函数,传递一个包含有关事件的详细信息(如时间和鼠标指针坐标)的对象。
13.1.3 网络事件
JavaScript 编程中另一个常见的异步来源是网络请求。在浏览器中运行的 JavaScript 可以使用以下代码从 Web 服务器获取数据:
function getCurrentVersionNumber(versionCallback) { // Note callback argument
// Make a scripted HTTP request to a backend version API
let request = new XMLHttpRequest();
request.open("GET", "http://www.example.com/api/version");
request.send();
// Register a callback that will be invoked when the response arrives
request.onload = function() {
if (request.status === 200) {
// If HTTP status is good, get version number and call callback.
let currentVersion = parseFloat(request.responseText);
versionCallback(null, currentVersion);
} else {
// Otherwise report an error to the callback
versionCallback(response.statusText, null);
}
};
// Register another callback that will be invoked for network errors
request.onerror = request.ontimeout = function(e) {
versionCallback(e.type, null);
};
}
客户端 JavaScript 代码可以使用 XMLHttpRequest 类加上回调函数来进行 HTTP 请求,并在服务器响应到达时异步处理。¹ 这里定义的getCurrentVersionNumber()函数(我们可以想象它被假设的checkForUpdates()函数使用,我们在§13.1.1 中讨论过)发出 HTTP 请求,并定义在接收到服务器响应或超时或其他错误导致请求失败时将被调用的事件处理程序。
请注意,上面的代码示例不像我们之前的示例那样调用addEventListener()。对于大多数 Web API(包括此示例),可以通过在生成事件的对象上调用addEventListener()并传递感兴趣的事件名称以及回调函数来定义事件处理程序。通常,您也可以通过将其直接分配给对象的属性来注册单个事件监听器。这就是我们在这个示例代码中所做的,将函数分配给onload、onerror和ontimeout属性。按照惯例,像这样的事件监听器属性总是以on开头的名称。addEventListener()是更灵活的技术,因为它允许注册多个事件处理程序。但在确保没有其他代码需要为相同的对象和事件类型注册监听器的情况下,直接将适当的属性设置为您的回调可能更简单。
在这个示例代码中关于getCurrentVersionNumber()函数的另一点需要注意的是,由于它发出了一个异步请求,它无法同步返回调用者感兴趣的值(当前版本号)。相反,调用者传递一个回调函数,当结果准备就绪或发生错误时调用。在这种情况下,调用者提供了一个期望两个参数的回调函数。如果 XMLHttpRequest 正常工作,那么getCurrentVersionNumber()会用null作为第一个参数,版本号作为第二个参数调用回调。或者,如果发生错误,那么getCurrentVersionNumber()会用错误详细信息作为第一个参数,null作为第二个参数调用回调。
13.1.4 Node 中的回调和事件
Node.js 服务器端 JavaScript 环境是深度异步的,并定义了许多使用回调和事件的 API。例如,读取文件内容的默认 API 是异步的,并在文件内容被读取后调用回调函数:
const fs = require("fs"); // The "fs" module has filesystem-related APIs
let options = { // An object to hold options for our program
// default options would go here
};
// Read a configuration file, then call the callback function
fs.readFile("config.json", "utf-8", (err, text) => {
if (err) {
// If there was an error, display a warning, but continue
console.warn("Could not read config file:", err);
} else {
// Otherwise, parse the file contents and assign to the options object
Object.assign(options, JSON.parse(text));
}
// In either case, we can now start running the program
startProgram(options);
});
Node 的fs.readFile()函数将一个两参数回调作为其最后一个参数。它异步读取指定的文件,然后调用回调。如果文件成功读取,它将文件内容作为第二个回调参数传递。如果出现错误,它将错误作为第一个回调参数传递。在这个例子中,我们将回调表达为箭头函数,这是一种简洁和自然的语法,适用于这种简单操作。
Node 还定义了许多基于事件的 API。以下函数展示了如何在 Node 中请求 URL 的内容。它有两层通过事件监听器处理的异步代码。请注意,Node 使用on()方法来注册事件监听器,而不是addEventListener():
const https = require("https");
// Read the text content of the URL and asynchronously pass it to the callback.
function getText(url, callback) {
// Start an HTTP GET request for the URL
request = https.get(url);
// Register a function to handle the "response" event.
request.on("response", response => {
// The response event means that response headers have been received
let httpStatus = response.statusCode;
// The body of the HTTP response has not been received yet.
// So we register more event handlers to to be called when it arrives.
response.setEncoding("utf-8"); // We're expecting Unicode text
let body = ""; // which we will accumulate here.
// This event handler is called when a chunk of the body is ready
response.on("data", chunk => { body += chunk; });
// This event handler is called when the response is complete
response.on("end", () => {
if (httpStatus === 200) { // If the HTTP response was good
callback(null, body); // Pass response body to the callback
} else { // Otherwise pass an error
callback(httpStatus, null);
}
});
});
// We also register an event handler for lower-level network errors
request.on("error", (err) => {
callback(err, null);
});
}
13.2 承诺
现在我们已经在客户端和服务器端 JavaScript 环境中看到了回调和基于事件的异步编程的示例,我们可以介绍承诺,这是一个旨在简化异步编程的核心语言特性。
承诺是表示异步计算结果的对象。该结果可能已经准备好,也可能尚未准备好,承诺 API 故意对此保持模糊:没有同步获取承诺值的方法;您只能要求承诺在值准备好时调用回调函数。如果您正在定义一个类似前一节中的getText()函数的异步 API,但希望将其基于承诺,省略回调参数,而是返回一个承诺对象。调用者可以在这个承诺对象上注册一个或多个回调,当异步计算完成时,它们将被调用。
因此,在最简单的层面上,承诺只是一种与回调一起工作的不同方式。然而,使用它们有实际的好处。基于回调的异步编程的一个真正问题是,通常会出现回调内嵌在回调内嵌在回调中的情况,代码行缩进如此之深,以至于难以阅读。承诺允许将这种嵌套回调重新表达为更线性的承诺链,这样更容易阅读和推理。
回调函数的另一个问题是,它们可能会使处理错误变得困难。如果异步函数(或异步调用的回调)抛出异常,那么这个异常就无法传播回异步操作的发起者。这是关于异步编程的一个基本事实:它破坏了异常处理。另一种方法是通过回调参数和返回值来细致地跟踪和传播错误,但这样做很繁琐,很难做到正确。承诺在这里有所帮助,通过标准化处理错误的方式,并提供一种让错误正确传播通过一系列承诺的方法。
请注意,承诺代表单个异步计算的未来结果。然而,它们不能用于表示重复的异步计算。在本章的后面,我们将编写一个基于承诺的setTimeout()函数的替代方案。但我们不能使用承诺来替代setInterval(),因为该函数会重复调用回调函数,而这是承诺设计上不支持的。同样地,我们可以使用承诺来替代 XMLHttpRequest 对象的“load”事件处理程序,因为该回调只会被调用一次。但通常情况下,我们不会使用承诺来替代 HTML 按钮对象的“click”事件处理程序,因为我们通常希望允许用户多次点击按钮。
接下来的小节将:
-
解释承诺术语并展示基本承诺用法
-
展示 Promises 如何被链式调用
-
展示如何创建自己的基于 Promise 的 API
重要
起初,Promise 似乎很简单,事实上,Promise 的基本用例确实简单明了。但是,对于超出最简单用例的任何情况,它们可能变得令人惊讶地令人困惑。Promise 是异步编程的强大习语,但你需要深入理解才能正确自信地使用它们。然而,花时间深入了解是值得的,我敦促你仔细研究这一长章节。
13.2.1 使用 Promises
随着 Promises 在核心 JavaScript 语言中的出现,Web 浏览器已经开始实现基于 Promise 的 API。在前一节中,我们实现了一个getText()函数,该函数发起了一个异步的 HTTP 请求,并将 HTTP 响应的主体作为字符串传递给指定的回调函数。想象一个这个函数的变体,getJSON(),它将 HTTP 响应的主体解析为 JSON,并返回一个 Promise,而不是接受一个回调参数。我们将在本章后面实现一个getJSON()函数,但现在,让我们看看如何使用这个返回 Promise 的实用函数:
getJSON(url).then(jsonData => {
// This is a callback function that will be asynchronously
// invoked with the parsed JSON value when it becomes available.
});
getJSON()启动一个异步的 HTTP 请求,请求指定的 URL,然后,在该请求挂起期间,它返回一个 Promise 对象。Promise 对象定义了一个then()实例方法。我们不直接将回调函数传递给getJSON(),而是将其传递给then()方法。当 HTTP 响应到达时,该响应的主体被解析为 JSON,并将解析后的值传递给我们传递给then()的函数。
你可以将then()方法看作是一个回调注册方法,类似于用于在客户端 JavaScript 中注册事件处理程序的addEventListener()方法。如果多次调用 Promise 对象的then()方法,每个指定的函数都将在承诺的计算完成时被调用。
与许多事件侦听器不同,Promise 代表一个单一的计算,每个注册到then()的函数只会被调用一次。值得注意的是,无论何时调用then(),你传递给then()的函数都会异步调用,即使异步计算在调用then()时已经完成。
在简单的语法层面上,then()方法是 Promise 的独特特征,习惯上直接将.then()附加到返回 Promise 的函数调用上,而不是将 Promise 对象分配给变量的中间步骤。
习惯上,将返回 Promises 的函数和使用 Promises 结果的函数命名为动词,这些习惯导致的代码特别易于阅读:
// Suppose you have a function like this to display a user profile
function displayUserProfile(profile) { /* implementation omitted */ }
// Here's how you might use that function with a Promise.
// Notice how this line of code reads almost like an English sentence:
getJSON("/api/user/profile").then(displayUserProfile);
使用 Promises 处理错误
异步操作,特别是涉及网络的操作,通常会以多种方式失败,必须编写健壮的代码来处理不可避免发生的错误。
对于 Promises,我们可以通过将第二个函数传递给then()方法来实现:
getJSON("/api/user/profile").then(displayUserProfile, handleProfileError);
Promise 代表在 Promise 对象创建后发生的异步计算的未来结果。因为计算是在 Promise 对象返回给我们后执行的,所以传统上计算无法返回一个值或抛出我们可以捕获的异常。我们传递给then()的函数提供了替代方案。当同步计算正常完成时,它只是将其结果返回给调用者。当基于 Promise 的异步计算正常完成时,它将其结果传递给作为then()的第一个参数的函数。
当同步计算出现问题时,它会抛出一个异常,该异常会向上传播到调用堆栈,直到有一个catch子句来处理它。当异步计算运行时,其调用者不再在堆栈上,因此如果出现问题,就不可能将异常抛回给调用者。
相反,基于 Promise 的异步计算将异常(通常作为某种类型的 Error 对象,尽管这不是必需的)传递给then()的第二个函数。因此,在上面的代码中,如果getJSON()正常运行,它会将结果传递给displayUserProfile()。如果出现错误(用户未登录、服务器宕机、用户的互联网连接中断、请求超时等),那么getJSON()会将一个 Error 对象传递给handleProfileError()。
在实践中,很少看到两个函数传递给then()。在处理 Promise 时,有一种更好的、更符合习惯的处理错误的方式。要理解这一点,首先考虑一下如果getJSON()正常完成,但displayUserProfile()中出现错误会发生什么。当getJSON()返回时,回调函数会异步调用,因此它也是异步的,不能有意义地抛出异常(因为没有代码在调用堆栈上处理它)。
在这段代码中处理错误的更符合习惯的方式如下:
getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileError);
使用这段代码,getJSON()的正常结果仍然会传递给displayUserProfile(),但是getJSON()或displayUserProfile()中的任何错误(包括displayUserProfile抛出的任何异常)都会传递给handleProfileError()。catch()方法只是调用then()的一种简写形式,第一个参数为null,第二个参数为指定的错误处理函数。
当我们讨论下一节的 Promise 链时,我们将会更多地谈到catch()和这种错误处理习惯。
13.2.2 链式 Promise
Promise 最重要的好处之一是它们提供了一种自然的方式来将一系列异步操作表达为then()方法调用的线性链,而无需将每个操作嵌套在前一个操作的回调中。例如,这里是一个假设的 Promise 链:
fetch(documentURL) // Make an HTTP request
.then(response => response.json()) // Ask for the JSON body of the response
.then(document => { // When we get the parsed JSON
return render(document); // display the document to the user
})
.then(rendered => { // When we get the rendered document
cacheInDatabase(rendered); // cache it in the local database.
})
.catch(error => handle(error)); // Handle any errors that occur
这段代码说明了一系列 Promise 如何简单地表达一系列异步操作的过程。然而,我们不会讨论这个特定的 Promise 链。不过,我们将继续探讨使用 Promise 链进行 HTTP 请求的想法。
在本章的前面,我们看到了在 JavaScript 中使用 XMLHttpRequest 对象进行 HTTP 请求。这个奇怪命名的对象具有一个古老且笨拙的 API,它已经大部分被新的、基于 Promise 的 Fetch API(§15.11.1)所取代。在其最简单的形式中,这个新的 HTTP API 就是函数fetch()。你传递一个 URL 给它,它会返回一个 Promise。当 HTTP 响应开始到达并且 HTTP 状态和头部可用时,这个 Promise 就会被实现:
fetch("/api/user/profile").then(response => {
// When the promise resolves, we have status and headers
if (response.ok &&
response.headers.get("Content-Type") === "application/json") {
// What can we do here? We don't actually have the response body yet.
}
});
当fetch()返回的 Promise 被实现时,它会将一个 Response 对象传递给您传递给其then()方法的函数。这个响应对象让您可以访问请求状态和头部,并且还定义了像text()和json()这样的方法,分别以文本和 JSON 解析形式访问响应主体。但是尽管初始 Promise 被实现,响应主体可能尚未到达。因此,用于访问响应主体的这些text()和json()方法本身返回 Promise。以下是使用fetch()和response.json()方法获取 HTTP 响应主体的一种天真的方法:
fetch("/api/user/profile").then(response => {
response.json().then(profile => { // Ask for the JSON-parsed body
// When the body of the response arrives, it will be automatically
// parsed as JSON and passed to this function.
displayUserProfile(profile);
});
});
这是一种天真地使用 Promise 的方式,因为我们像回调一样嵌套它们,这违背了初衷。更好的习惯是使用 Promise 在一个顺序链中编写代码,就像这样:
fetch("/api/user/profile")
.then(response => {
return response.json();
})
.then(profile => {
displayUserProfile(profile);
});
让我们看一下这段代码中的方法调用,忽略传递给方法的参数:
fetch().then().then()
当在一个表达式中调用多个方法时,我们称之为方法链。我们知道fetch()函数返回一个 Promise 对象,我们可以看到这个链中的第一个.then()调用在返回的 Promise 对象上调用一个方法。但是链中还有第二个.then(),这意味着then()方法的第一次调用本身必须返回一个 Promise。
有时,当设计 API 以使用这种方法链时,只有一个对象,并且该对象的每个方法都返回对象本身以便于链接。然而,这并不是 Promise 的工作方式。当我们编写一系列.then()调用时,我们并不是在单个 Promise 对象上注册多个回调。相反,then()方法的每次调用都会返回一个新的 Promise 对象。直到传递给then()的函数完成,新的 Promise 对象才会被实现。
让我们回到上面原始fetch()链的简化形式。如果我们在其他地方定义传递给then()调用的函数,我们可以重构代码如下:
fetch(theURL) // task 1; returns promise 1
.then(callback1) // task 2; returns promise 2
.then(callback2); // task 3; returns promise 3
让我们详细讨论一下这段代码:
-
在第一行,使用一个 URL 调用
fetch()。它为该 URL 发起一个 HTTP GET 请求并返回一个 Promise。我们将这个 HTTP 请求称为“任务 1”,将 Promise 称为“promise 1”。 -
在第二行,我们调用 promise 1 的
then()方法,传递我们希望在 promise 1 实现时调用的callback1函数。then()方法将我们的回调存储在某个地方,然后返回一个新的 Promise。我们将在这一步返回的新 Promise 称为“promise 2”,并且我们将说“任务 2”在调用callback1时开始。 -
在第三行,我们调用 promise 2 的
then()方法,传递我们希望在 promise 2 实现时调用的callback2函数。这个then()方法记住我们的回调并返回另一个 Promise。我们将说“任务 3”在调用callback2时开始。我们可以称这个最新的 Promise 为“promise 3”,但实际上我们不需要为它命名,因为我们根本不会使用它。 -
前三个步骤都是在表达式首次执行时同步发生的。现在,在 HTTP 请求在步骤 1 中发出并通过互联网发送时,我们有一个异步暂停。
-
最终,HTTP 响应开始到达。
fetch()调用的异步部分将 HTTP 状态和标头包装在一个 Response 对象中,并使用该 Response 对象作为值来实现 promise 1。 -
当 promise 1 被实现时,它的值(Response 对象)被传递给我们的
callback1()函数,任务 2 开始。这个任务的工作是,给定一个 Response 对象作为输入,获取响应主体作为 JSON 对象。 -
让我们假设任务 2 正常完成,并且能够解析 HTTP 响应的主体以生成一个 JSON 对象。这个 JSON 对象用于实现 promise 2。
-
实现 promise 2 的值成为传递给
callback2()函数时任务 3 的输入。当任务 3 完成(假设它正常完成)时,promise 3 将被实现。但因为我们从未对 promise 3 做任何操作,当该 Promise 完成时什么也不会发生,异步计算链在这一点结束。
13.2.3 解决 Promise
在上一节中解释了与列表中的 URL 获取 Promise 链相关的内容时,我们谈到了 promise 1、2 和 3。但实际上还涉及第四个 Promise 对象,这将引出我们对 Promise“解决”意味着什么的重要讨论。
请记住,fetch()返回一个 Promise 对象,当实现时,将传递一个 Response 对象给我们注册的回调函数。这个 Response 对象有.text()、.json()和其他方法以各种形式请求 HTTP 响应的主体。但是由于主体可能尚未到达,这些方法必须返回 Promise 对象。在我们一直在研究的示例中,“任务 2”调用.json()方法并返回其值。这是第四个 Promise 对象,也是callback1()函数的返回值。
让我们再次以冗长和非成语化的方式重写 URL 获取代码,使回调和 Promises 明确:
function c1(response) { // callback 1
let p4 = response.json();
return p4; // returns promise 4
}
function c2(profile) { // callback 2
displayUserProfile(profile);
}
let p1 = fetch("/api/user/profile"); // promise 1, task 1
let p2 = p1.then(c1); // promise 2, task 2
let p3 = p2.then(c2); // promise 3, task 3
为了使 Promise 链有用地工作,任务 2 的输出必须成为任务 3 的输入。在我们正在考虑的示例中,任务 3 的输入是获取的 URL 主体,解析为 JSON 对象。但是,正如我们刚才讨论的,回调c1的返回值不是 JSON 对象,而是该 JSON 对象的 Promisep4。这似乎是一个矛盾,但实际上不是:当p1被实现时,c1被调用,任务 2 开始。当p2被实现时,c2被调用,任务 3 开始。但是仅仅因为在调用c1时任务 2 开始,并不意味着任务 2 在c1返回时必须结束。毕竟,Promises 是关于管理异步任务的,如果任务 2 是异步的(在这种情况下是),那么在回调返回时该任务将尚未完成。
现在我们准备讨论您需要真正掌握 Promises 的最后一个细节。当您将回调c传递给then()方法时,then()返回一个 Promisep并安排在稍后的某个时间异步调用c。回调执行一些计算并返回一个值v。当回调返回时,p被解析为值v。当一个 Promise 被解析为一个不是 Promise 的值时,它会立即被实现为该值。因此,如果c返回一个非 Promise,那么返回值就成为p的值,p被实现,我们完成了。但是如果返回值v本身是一个 Promise,那么p被解析但尚未实现。在这个阶段,p不能解决,直到 Promisev解决。如果v被实现,那么p将被实现为相同的值。如果v被拒绝,那么p将因同样的原因被拒绝。这就是 Promise“解析”状态的含义:Promise 已经与另一个 Promise 关联或“锁定”。我们还不知道p是否会被实现或被拒绝,但是我们的回调c不再控制这一点。p“解析”意味着它的命运现在完全取决于 Promisev的发生。
让我们回到我们的 URL 获取示例。当c1返回p4时,p2被解析。但被解析并不意味着被实现,所以任务 3 还没有开始。当完整的 HTTP 响应主体可用时,.json()方法可以解析它并使用解析后的值来实现p4。当p4被实现时,p2也会自动被实现,具有相同的解析 JSON 值。此时,解析后的 JSON 对象被传递给c2,任务 3 开始。
这可能是 JavaScript 中最难理解的部分之一,您可能需要阅读本节不止一次。图 13-1 以可视化形式呈现了这个过程,可能有助于为您澄清。
图 13-1. 使用 Promises 获取 URL
13.2.4 更多关于 Promises 和错误
在本章的前面,我们看到您可以将第二个回调函数传递给.then()方法,并且如果 Promise 被拒绝,则将调用此第二个函数。当发生这种情况时,传递给此第二个回调函数的参数是一个值—通常是代表拒绝原因的 Error 对象。我们还了解到,通过向 Promise 链中添加.catch()方法调用来处理 Promise 相关的错误是不常见的(甚至是不成文的)。现在我们已经检查了 Promise 链,我们可以回到错误处理并更详细地讨论它。在讨论之前,我想强调的是,在进行异步编程时,仔细处理错误非常重要。对于同步代码,如果您省略了错误处理代码,您至少会得到一个异常和堆栈跟踪,以便您可以找出出了什么问题。对于异步代码,未处理的异常通常不会被报告,错误可能会悄无声息地发生,使得调试变得更加困难。好消息是,.catch()方法使得在处理 Promise 时处理错误变得容易。
catch 和 finally 方法
Promise 的.catch()方法只是一种使用null作为第一个参数并将错误处理回调作为第二个参数调用.then()的简写方式。给定任何 Promisep和回调c,以下两行是等效的:
p.then(null, c);
p.catch(c);
.catch()简写更受欢迎,因为它更简单,并且名称与try/catch异常处理语句中的catch子句匹配。正如我们讨论过的,普通异常在异步代码中不起作用。Promise 的.catch()方法是一种适用于异步代码的替代方法。当同步代码出现问题时,我们可以说异常“沿着调用堆栈上升”直到找到catch块。对于 Promise 链的异步链,类似的隐喻可能是错误“沿着链路下滑”,直到找到.catch()调用。
在 ES2018 中,Promise 对象还定义了一个.finally()方法,其目的类似于try/catch/finally语句中的finally子句。如果您在 Promise 链中添加一个.finally()调用,那么您传递给.finally()的回调将在您调用它的 Promise 完成时被调用。如果 Promise 完成或拒绝,都会调用您的回调,并且不会传递任何参数,因此您无法找出它是完成还是拒绝。但是,如果您需要在任一情况下运行某种清理代码(例如关闭打开的文件或网络连接),则.finally()回调是执行此操作的理想方式。与.then()和.catch()一样,.finally()返回一个新的 Promise 对象。.finally()回调的返回值通常被忽略,而由.finally()返回的 Promise 通常将使用与调用.finally()的 Promise 解析或拒绝的相同值解析或拒绝。但是,如果.finally()回调引发异常,则由.finally()返回的 Promise 将以该值拒绝。
我们在前几节中学习的 URL 获取代码没有进行任何错误处理。现在让我们通过代码的更实际版本来纠正这一点:
fetch("/api/user/profile") // Start the HTTP request
.then(response => { // Call this when status and headers are ready
if (!response.ok) { // If we got a 404 Not Found or similar error
return null; // Maybe user is logged out; return null profile
}
// Now check the headers to ensure that the server sent us JSON.
// If not, our server is broken, and this is a serious error!
let type = response.headers.get("content-type");
if (type !== "application/json") {
throw new TypeError(`Expected JSON, got ${type}`);
}
// If we get here, then we got a 2xx status and a JSON content-type
// so we can confidently return a Promise for the response
// body as a JSON object.
return response.json();
})
.then(profile => { // Called with the parsed response body or null
if (profile) {
displayUserProfile(profile);
}
else { // If we got a 404 error above and returned null we end up here
displayLoggedOutProfilePage();
}
})
.catch(e => {
if (e instanceof NetworkError) {
// fetch() can fail this way if the internet connection is down
displayErrorMessage("Check your internet connection.");
}
else if (e instanceof TypeError) {
// This happens if we throw TypeError above
displayErrorMessage("Something is wrong with our server!");
}
else {
// This must be some kind of unanticipated error
console.error(e);
}
});
让我们通过分析当事情出错时会发生什么来分析这段代码。我们将使用之前使用的命名方案:p1是fetch()调用返回的 Promise。p2是第一个.then()调用返回的 Promise,c1是我们传递给该.then()调用的回调。p3是第二个.then()调用返回的 Promise,c2是我们传递给该调用的回调。最后,c3是我们传递给.catch()调用的回调。(该调用返回一个 Promise,但我们不需要通过名称引用它。)
可能失败的第一件事是 fetch() 请求本身。如果网络连接断开(或由于某种原因无法进行 HTTP 请求),那么 Promise p1 将被拒绝,并带有一个 NetworkError 对象。我们没有将错误处理回调函数作为第二个参数传递给 .then() 调用,因此 p2 也将以相同的 NetworkError 对象被拒绝。(如果我们向第一个 .then() 调用传递了错误处理程序,错误处理程序将被调用,如果它正常返回,p2 将被解析和/或完成,并带有该处理程序的返回值。)然而,没有处理程序,p2 被拒绝,然后 p3 由于相同原因被拒绝。此时,c3 错误处理回调被调用,并其中的 NetworkError 特定代码运行。
我们的代码可能失败的另一种方式是,如果我们的 HTTP 请求返回 404 Not Found 或其他 HTTP 错误。这些是有效的 HTTP 响应,因此 fetch() 调用不认为它们是错误。fetch() 将 404 Not Found 封装在一个 Response 对象中,并用该对象完成 p1,导致调用 c1。我们在 c1 中的代码检查 Response 对象的 ok 属性,以检测是否收到了正常的 HTTP 响应,并通过简单返回 null 处理这种情况。因为这个返回值不是一个 Promise,它立即完成 p2,并用这个值调用 c2。我们在 c2 中明确检查和处理 falsy 值,通过向用户显示不同的结果来处理这种情况。这是一个我们将异常条件视为非错误并在不使用错误处理程序的情况下处理它的案例。
如果我们得到一个正常的 HTTP 响应代码,但 Content-Type 头部未正确设置,c1 中会发生一个更严重的错误。我们的代码期望一个 JSON 格式的响应,所以如果服务器发送给我们 HTML、XML 或纯文本,我们将会遇到问题。c1 包含了检查 Content-Type 头部的代码。如果头部错误,它将把这视为一个不可恢复的问题并抛出一个 TypeError。当传递给 .then()(或 .catch())的回调抛出一个值时,作为 .then() 调用的返回值的 Promise 将被拒绝,并带有该抛出的值。在这种情况下,引发 TypeError 的 c1 中的代码导致 p2 被拒绝,并带有该 TypeError 对象。由于我们没有为 p2 指定错误处理程序,p3 也将被拒绝。c2 将不会被调用,并且 TypeError 将传递给 c3,它具有明确检查和处理这种类型错误的代码。
关于这段代码有几点值得注意。首先,请注意,使用常规的同步 throw 语句抛出的错误对象最终会在 Promise 链中的 .catch() 方法调用中异步处理。这应该清楚地说明为什么这种简写方法优先于向 .then() 传递第二个参数,并且为什么在 Promise 链末尾使用 .catch() 调用是如此习惯化的。
在我们离开错误处理的话题之前,我想指出,虽然习惯于在每个 Promise 链的末尾使用 .catch() 来清理(或至少记录)链中发生的任何错误,但在 Promise 链的其他地方使用 .catch() 也是完全有效的。如果你的 Promise 链中的某个阶段可能会因错误而失败,并且如果错误是某种可恢复的错误,不应该阻止链的其余部分运行,那么你可以在链中插入一个 .catch() 调用,代码可能看起来像这样:
startAsyncOperation()
.then(doStageTwo)
.catch(recoverFromStageTwoError)
.then(doStageThree)
.then(doStageFour)
.catch(logStageThreeAndFourErrors);
请记住,您传递给 .catch() 的回调只有在前一个阶段的回调抛出错误时才会被调用。如果回调正常返回,那么 .catch() 回调将被跳过,并且前一个回调的返回值将成为下一个 .then() 回调的输入。还要记住,.catch() 回调不仅用于报告错误,还用于处理和恢复错误。一旦错误传递给 .catch() 回调,它就会停止在 Promise 链中传播。.catch() 回调可以抛出新错误,但如果它正常返回,那么返回值将用于解析和/或实现相关的 Promise,并且错误将停止传播。
让我们具体说明一下:在前面的代码示例中,如果 startAsyncOperation() 或 doStageTwo() 抛出错误,则将调用 recoverFromStageTwoError() 函数。如果 recoverFromStageTwoError() 正常返回,则其返回值将传递给 doStageThree(),异步操作将继续正常进行。另一方面,如果 recoverFromStageTwoError() 无法恢复,则它将抛出错误(或重新抛出传递给它的错误)。在这种情况下,doStageThree() 和 doStageFour() 都不会被调用,并且由 recoverFromStageTwoError() 抛出的错误将传递给 logStageThreeAndFourErrors()。
有时,在复杂的网络环境中,错误可能更多或更少地随机发生,通过简单地重试异步请求来处理这些错误可能是合适的。想象一下,您已经编写了一个基于 Promise 的操作来查询数据库:
queryDatabase()
.then(displayTable)
.catch(displayDatabaseError);
现在假设瞬时网络负载问题导致失败率约为 1%。一个简单的解决方案可能是使用 .catch() 调用重试查询:
queryDatabase()
.catch(e => wait(500).then(queryDatabase)) // On failure, wait and retry
.then(displayTable)
.catch(displayDatabaseError);
如果假设的故障确实是随机的,那么添加这一行代码应该将您的错误率从 1% 降低到 0.01%。
13.2.5 并行的 Promises
我们花了很多时间讨论 Promise 链,用于顺序运行更大异步操作的异步步骤。但有时,我们希望并行执行多个异步操作。函数 Promise.all() 可以做到这一点。Promise.all() 接受一个 Promise 对象数组作为输入,并返回一个 Promise。如果任何输入 Promise 被拒绝,则返回的 Promise 将被拒绝。否则,它将以每个输入 Promise 的实现值数组实现。因此,例如,如果您想获取多个 URL 的文本内容,您可以使用以下代码:
// We start with an array of URLs
const urls = [ /* zero or more URLs here */ ];
// And convert it to an array of Promise objects
promises = urls.map(url => fetch(url).then(r => r.text()));
// Now get a Promise to run all those Promises in parallel
Promise.all(promises)
.then(bodies => { /* do something with the array of strings */ })
.catch(e => console.error(e));
Promise.all() 稍微比之前描述的更灵活。输入数组可以包含 Promise 对象和非 Promise 值。如果数组的元素不是 Promise,则会被视为已实现 Promise 的值,并且会被简单地复制到输出数组中。
Promise.all() 返回的 Promise 在任何输入 Promise 被拒绝时也会被拒绝。这会立即发生在第一个拒绝时,而其他输入 Promise 仍在等待的情况下也可能发生。在 ES2020 中,Promise.allSettled() 接受一个输入 Promise 数组并返回一个 Promise,就像 Promise.all() 一样。但是 Promise.allSettled() 永远不会拒绝返回的 Promise,并且在所有输入 Promise 都已完成之前不会实现该 Promise。该 Promise 解析为一个对象数组,每个输入 Promise 都有一个对象。每个返回的对象都有一个 status 属性,设置为“fulfilled”或“rejected”。如果状态是“fulfilled”,那么对象还将有一个 value 属性,给出实现值。如果状态是“rejected”,那么对象还将有一个 reason 属性,给出相应 Promise 的错误或拒绝值:
Promise.allSettled([Promise.resolve(1), Promise.reject(2), 3]).then(results => {
results[0] // => { status: "fulfilled", value: 1 }
results[1] // => { status: "rejected", reason: 2 }
results[2] // => { status: "fulfilled", value: 3 }
});
有时,您可能希望同时运行多个 Promise,但可能只关心第一个实现的值。在这种情况下,您可以使用Promise.race()而不是Promise.all()。它返回一个 Promise,当输入数组中的 Promise 中的第一个实现或拒绝时,该 Promise 将实现或拒绝。(或者,如果输入数组中有任何非 Promise 值,则简单地返回其中的第一个。)
13.2.6 创建 Promises
在许多先前的示例中,我们使用了返回 Promise 的函数fetch(),因为它是内置到 Web 浏览器中的最简单的返回 Promise 的函数之一。我们对 Promises 的讨论还依赖于假设的返回 Promise 的函数getJSON()和wait()。编写返回 Promises 的函数确实非常有用,本节展示了如何创建基于 Promise 的 API。特别是,我们将展示getJSON()和wait()的实现。
基于其他 Promises 的 Promises
如果您有其他返回 Promise 的函数作为起点,编写返回 Promise 的函数就很容易。给定一个 Promise,您可以通过调用.then()来创建(并返回)一个新的 Promise。因此,如果我们使用现有的fetch()函数作为起点,我们可以这样编写getJSON():
function getJSON(url) {
return fetch(url).then(response => response.json());
}
代码很简单,因为fetch()API 的 Response 对象具有预定义的json()方法。json()方法返回一个 Promise,我们从回调中返回该 Promise(回调是一个带有单表达式主体的箭头函数,因此返回是隐式的),因此getJSON()返回的 Promise 解析为response.json()返回的 Promise。当该 Promise 实现时,由getJSON()返回的 Promise 也实现为相同的值。请注意,此getJSON()实现中没有错误处理。我们不检查response.ok和 Content-Type 头,而是允许json()方法拒绝返回的 Promise,如果响应主体无法解析为 JSON,则会引发 SyntaxError。
让我们再写一个返回 Promise 的函数,这次使用getJSON()作为初始 Promise 的来源。
function getHighScore() {
return getJSON("/api/user/profile").then(profile => profile.highScore);
}
我们假设这个函数是某种基于 Web 的游戏的一部分,并且 URL“/api/user/profile”返回一个包含highScore属性的 JSON 格式数据结构。
基于同步值的 Promises
有时,您可能需要实现现有的基于 Promise 的 API,并从函数返回一个 Promise,即使要执行的计算实际上不需要任何异步操作。在这种情况下,静态方法Promise.resolve()和Promise.reject()将实现您想要的效果。Promise.resolve()以其单个参数作为值,并返回一个将立即(但异步地)实现为该值的 Promise。类似地,Promise.reject()接受一个参数,并返回一个将以该值为原因拒绝的 Promise。(要明确:这些静态方法返回的 Promises 在返回时并未已实现或已拒绝,但它们将在当前同步代码块运行完毕后立即实现或拒绝。通常,除非有许多待处理的异步任务等待运行,否则这将在几毫秒内发生。)
请回顾§13.2.3 中的内容,已解决的 Promise 与已实现的 Promise 不是同一回事。当我们调用Promise.resolve()时,通常会传递实现值以创建一个 Promise 对象,该对象将很快实现为该值。但是该方法的名称不是Promise.fulfill()。如果将 Promisep1传递给Promise.resolve(),它将返回一个新的 Promisep2,该 Promise 立即解决,但直到p1实现或拒绝之前,它才会实现或拒绝。
可以编写一个基于 Promise 的函数,其中值是同步计算的,并使用Promise.resolve()异步返回,尽管这种情况可能不太常见。然而,在异步函数中有同步特殊情况是相当常见的,你可以使用Promise.resolve()和Promise.reject()来处理这些特殊情况。特别是,如果在开始异步操作之前检测到错误条件(例如错误的参数值),你可以通过返回使用Promise.reject()创建的 Promise 来报告该错误。(在这种情况下,你也可以同步抛出错误,但这被认为是不好的做法,因为调用者需要同时编写同步的catch子句和使用异步的.catch()方法来处理错误。)最后,Promise.resolve()有时用于在 Promise 链中创建初始 Promise。我们将看到一些以这种方式使用它的示例。
从头开始的 Promises
对于getJSON()和getHighScore(),我们首先调用现有函数以获取初始 Promise,并通过调用该初始 Promise 的.then()方法创建并返回一个新 Promise。但是,当你无法使用另一个返回 Promise 的函数作为起点时,如何编写返回 Promise 的函数呢?在这种情况下,你可以使用Promise()构造函数创建一个全新的 Promise 对象,你可以完全控制它。操作如下:你调用Promise()构造函数并将一个函数作为其唯一参数传递。你传递的函数应该预期两个参数,按照惯例,应该命名为resolve和reject。构造函数会同步调用带有resolve和reject参数的函数。在调用你的函数后,Promise()构造函数会返回新创建的 Promise。返回的 Promise 受你传递给构造函数的函数控制。该函数应执行一些异步操作,然后调用resolve函数以解析或实现返回的 Promise,或调用reject函数以拒绝返回的 Promise。你的函数不必是异步的:如果这样做,即使你同步调用resolve或reject,Promise 仍将异步解析、实现或拒绝。
通过阅读关于将函数传递给构造函数的函数的功能可能很难理解,但希望一些示例能够澄清这一点。以下是如何编写基于 Promise 的wait()函数的方法,我们在本章的早期示例中使用过:
function wait(duration) {
// Create and return a new Promise
return new Promise((resolve, reject) => { // These control the Promise
// If the argument is invalid, reject the Promise
if (duration < 0) {
reject(new Error("Time travel not yet implemented"));
}
// Otherwise, wait asynchronously and then resolve the Promise.
// setTimeout will invoke resolve() with no arguments, which means
// that the Promise will fulfill with the undefined value.
setTimeout(resolve, duration);
});
}
请注意,用于控制使用Promise()构造函数创建的 Promise 的命运的一对函数的名称分别为resolve()和reject(),而不是fulfill()和reject()。如果将一个 Promise 传递给resolve(),则返回的 Promise 将解析为该新 Promise。然而,通常情况下,你会传递一个非 Promise 值,这将用该值实现返回的 Promise。
示例 13-1 是另一个使用Promise()构造函数的示例。这个示例实现了我们的getJSON()函数,用于在 Node 中使用,因为fetch()API 没有内置。请记住,我们在本章一开始讨论了异步回调和事件。这个示例同时使用了回调和事件处理程序,因此很好地演示了我们如何在其他类型的异步编程风格之上实现基于 Promise 的 API。
示例 13-1. 一个异步的 getJSON() 函数
const http = require("http");
function getJSON(url) {
// Create and return a new Promise
return new Promise((resolve, reject) => {
// Start an HTTP GET request for the specified URL
request = http.get(url, response => { // called when response starts
// Reject the Promise if the HTTP status is wrong
if (response.statusCode !== 200) {
reject(new Error(`HTTP status ${response.statusCode}`));
response.resume(); // so we don't leak memory
}
// And reject if the response headers are wrong
else if (response.headers["content-type"] !== "application/json") {
reject(new Error("Invalid content-type"));
response.resume(); // don't leak memory
}
else {
// Otherwise, register events to read the body of the response
let body = "";
response.setEncoding("utf-8");
response.on("data", chunk => { body += chunk; });
response.on("end", () => {
// When the response body is complete, try to parse it
try {
let parsed = JSON.parse(body);
// If it parsed successfully, fulfill the Promise
resolve(parsed);
} catch(e) {
// If parsing failed, reject the Promise
reject(e);
}
});
}
});
// We also reject the Promise if the request fails before we
// even get a response (such as when the network is down)
request.on("error", error => {
reject(error);
});
});
}
13.2.7 顺序执行的 Promises
Promise.all() 让并行运行任意数量的 Promises 变得容易。Promise 链使得表达一系列固定数量的 Promises 变得容易。然而,按顺序运行任意数量的 Promises 就比较棘手了。例如,假设你有一个要获取的 URL 数组,但为了避免过载网络,你希望一次只获取一个。如果数组长度和内容未知,你无法提前编写 Promise 链,因此需要动态构建一个,代码如下:
function fetchSequentially(urls) {
// We'll store the URL bodies here as we fetch them
const bodies = [];
// Here's a Promise-returning function that fetches one body
function fetchOne(url) {
return fetch(url)
.then(response => response.text())
.then(body => {
// We save the body to the array, and we're purposely
// omitting a return value here (returning undefined)
bodies.push(body);
});
}
// Start with a Promise that will fulfill right away (with value undefined)
let p = Promise.resolve(undefined);
// Now loop through the desired URLs, building a Promise chain
// of arbitrary length, fetching one URL at each stage of the chain
for(url of urls) {
p = p.then(() => fetchOne(url));
}
// When the last Promise in that chain is fulfilled, then the
// bodies array is ready. So let's return a Promise for that
// bodies array. Note that we don't include any error handlers:
// we want to allow errors to propagate to the caller.
return p.then(() => bodies);
}
有了定义的 fetchSequentially() 函数,我们可以一次获取一个 URL,代码与我们之前用来演示 Promise.all() 的并行获取代码类似:
fetchSequentially(urls)
.then(bodies => { /* do something with the array of strings */ })
.catch(e => console.error(e));
fetchSequentially() 函数首先创建一个 Promise,在返回后立即实现。然后,它基于该初始 Promise 构建一个长的线性 Promise 链,并返回链中的最后一个 Promise。这就像设置一排多米诺骨牌,然后推倒第一个。
我们可以采取另一种(可能更优雅)的方法。与其提前创建 Promises,我们可以让每个 Promise 的回调创建并返回下一个 Promise。也就是说,我们不是创建和链接一堆 Promises,而是创建解析为其他 Promises 的 Promises。我们不是创建一条多米诺般的 Promise 链,而是创建一个嵌套在另一个内部的 Promise 序列,就像一组套娃一样。采用这种方法,我们的代码可以返回第一个(最外层)Promise,知道它最终会实现(或拒绝!)与序列中最后一个(最内层)Promise 相同的值。接下来的 promiseSequence() 函数编写为通用的,不特定于 URL 获取。它在我们讨论 Promises 的最后,因为它很复杂。然而,如果你仔细阅读了本章,希望你能理解它是如何工作的。特别要注意的是,promiseSequence() 中的嵌套函数似乎递归调用自身,但因为“递归”调用是通过 then() 方法进行的,实际上并没有传统的递归发生:
// This function takes an array of input values and a "promiseMaker" function.
// For any input value x in the array, promiseMaker(x) should return a Promise
// that will fulfill to an output value. This function returns a Promise
// that fulfills to an array of the computed output values.
//
// Rather than creating the Promises all at once and letting them run in
// parallel, however, promiseSequence() only runs one Promise at a time
// and does not call promiseMaker() for a value until the previous Promise
// has fulfilled.
function promiseSequence(inputs, promiseMaker) {
// Make a private copy of the array that we can modify
inputs = [...inputs];
// Here's the function that we'll use as a Promise callback
// This is the pseudorecursive magic that makes this all work.
function handleNextInput(outputs) {
if (inputs.length === 0) {
// If there are no more inputs left, then return the array
// of outputs, finally fulfilling this Promise and all the
// previous resolved-but-not-fulfilled Promises.
return outputs;
} else {
// If there are still input values to process, then we'll
// return a Promise object, resolving the current Promise
// with the future value from a new Promise.
let nextInput = inputs.shift(); // Get the next input value,
return promiseMaker(nextInput) // compute the next output value,
// Then create a new outputs array with the new output value
.then(output => outputs.concat(output))
// Then "recurse", passing the new, longer, outputs array
.then(handleNextInput);
}
}
// Start with a Promise that fulfills to an empty array and use
// the function above as its callback.
return Promise.resolve([]).then(handleNextInput);
}
这个 promiseSequence() 函数是故意通用的。我们可以用它来获取 URL,代码如下:
// Given a URL, return a Promise that fulfills to the URL body text
function fetchBody(url) { return fetch(url).then(r => r.text()); }
// Use it to sequentially fetch a bunch of URL bodies
promiseSequence(urls, fetchBody)
.then(bodies => { /* do something with the array of strings */ })
.catch(console.error);