三十五、集合(Set)
在 ES6 之前,JavaScript 没有集合的数据结构。而是使用了两种解决方法:
- 对象的键被用作字符串的集合。
- 数组被用作任意值的集合。缺点是检查成员资格(数组是否包含一个值)较慢。
自 ES6 以来,JavaScript 有了数据结构Set
,它可以包含任意值并快速执行成员资格检查。
35.1 使用集合
35.1.1 创建集合
有三种常见的创建集合的方法。
首先,您可以使用没有任何参数的构造函数创建一个空的 Set:
const emptySet = new Set(); assert.equal(emptySet.size, 0);
其次,您可以将可迭代对象(例如数组)传递给构造函数。迭代的值成为新 Set 的元素:
const set = new Set(['red', 'green', 'blue']);
第三,.add()
方法向 Set 中添加元素,并且可以链式调用:
const set = new Set() .add('red') .add('green') .add('blue');
35.1.2 添加、删除、检查成员资格
.add()
向 Set 中添加一个元素。
const set = new Set(); set.add('red');
.has()
检查一个元素是否是 Set 的成员。
assert.equal(set.has('red'), true);
.delete()
从 Set 中移除一个元素。
assert.equal(set.delete('red'), true); // there was a deletion assert.equal(set.has('red'), false);
35.1.3 确定集合的大小并清除它
.size
包含集合中元素的数量。
const set = new Set() .add('foo') .add('bar'); assert.equal(set.size, 2)
.clear()
移除 Set 的所有元素。
set.clear(); assert.equal(set.size, 0)
35.1.4 遍历集合
集合是可迭代的,for-of
循环的工作方式与您期望的一样:
const set = new Set(['red', 'green', 'blue']); for (const x of set) { console.log(x); } // Output: // 'red' // 'green' // 'blue'
正如您所看到的,集合保留了插入顺序。也就是说,元素总是按照它们被添加的顺序进行迭代。
鉴于集合是可迭代的,您可以使用Array.from()
将其转换为数组:
const set = new Set(['red', 'green', 'blue']); const arr = Array.from(set); // ['red', 'green', 'blue']
35.2 使用集合的示例
35.2.1 从数组中移除重复项
将数组转换为 Set,然后再转换回来,可以从数组中移除重复项:
assert.deepEqual( Array.from(new Set([1, 2, 1, 2, 3, 3, 3])), [1, 2, 3]);
35.2.2 创建一个 Unicode 字符(代码点)的集合
字符串是可迭代的,因此可以作为new Set()
的参数使用:
assert.deepEqual( new Set('abc'), new Set(['a', 'b', 'c']));
35.3 哪些集合元素被视为相等?
与 Map 键一样,Set 元素的比较方式类似于===
,唯一的例外是NaN
等于它自己。
> const set = new Set([NaN, NaN, NaN]); > set.size 1 > set.has(NaN) true
与===
一样,两个不同的对象永远不会被视为相等(目前没有办法改变这一点):
> const set = new Set(); > set.add({}); > set.size 1 > set.add({}); > set.size 2
35.4 缺失的集合操作
集合缺少一些常见的操作。这样的操作通常可以通过实现:
- 通过扩展为数组文字将输入的 Set 转换为数组。
- 在数组上执行操作。
- 将结果转换为 Set 并返回。
35.4.1 并集(a
∪ b
)
计算两个 Set a
和 b
的并集意味着创建一个包含a
和b
元素的 Set。
const a = new Set([1,2,3]); const b = new Set([4,3,2]); // Use spreading to concatenate two iterables const union = new Set([...a, ...b]); assert.deepEqual(Array.from(union), [1, 2, 3, 4]);
35.4.2 交集(a
∩ b
)
计算两个 Set a
和 b
的交集意味着创建一个包含a
和b
中都有的元素的 Set。
const a = new Set([1,2,3]); const b = new Set([4,3,2]); const intersection = new Set( Array.from(a).filter(x => b.has(x)) ); assert.deepEqual( Array.from(intersection), [2, 3] );
35.4.3 差集(a
\ b
)
计算两个 Set a
和 b
之间的差异意味着创建一个包含a
中不在b
中的元素的 Set。这个操作有时也被称为减(−)。
const a = new Set([1,2,3]); const b = new Set([4,3,2]); const difference = new Set( Array.from(a).filter(x => !b.has(x)) ); assert.deepEqual( Array.from(difference), [1] );
35.4.4 对 Set 进行映射
集合没有一个名为.map()
的方法。但是我们可以借用数组的方法:
const set = new Set([1, 2, 3]); const mappedSet = new Set( Array.from(set).map(x => x * 2) ); // Convert mappedSet to an Array to check what’s inside it assert.deepEqual( Array.from(mappedSet), [2, 4, 6] );
35.4.5 对 Set 进行过滤
我们不能直接使用.filter()
来过滤 Set,所以我们需要使用相应的数组方法:
const set = new Set([1, 2, 3, 4, 5]); const filteredSet = new Set( Array.from(set).filter(x => (x % 2) === 0) ); assert.deepEqual( Array.from(filteredSet), [2, 4] );
35.5 快速参考:Set<T>
35.5.1 构造函数
new Set<T>(values?: Iterable<T>)
^([ES6])
如果你不提供参数values
,那么将创建一个空的 Set。如果你提供了参数,那么迭代的值将被添加为 Set 的元素。例如:
const set = new Set(['red', 'green', 'blue']);
35.5.2 Set<T>.prototype
: 单个 Set 元素
.add(value: T): this
^([ES6])
将value
添加到这个 Set 中。这个方法返回this
,这意味着它可以被链接。
const set = new Set(['red']); set.add('green').add('blue'); assert.deepEqual( Array.from(set), ['red', 'green', 'blue'] );
.delete(value: T): boolean
^([ES6])
从这个 Set 中移除value
。如果有东西被删除,则返回true
,否则返回false
。
const set = new Set(['red', 'green', 'blue']); assert.equal(set.delete('red'), true); // there was a deletion assert.deepEqual( Array.from(set), ['green', 'blue'] );
.has(value: T): boolean
^([ES6])
检查value
是否在这个 Set 中。
const set = new Set(['red', 'green']); assert.equal(set.has('red'), true); assert.equal(set.has('blue'), false);
35.5.3 Set<T>.prototype
: 所有 Set 元素
get .size: number
^([ES6])
返回这个 Set 中有多少个元素。
const set = new Set(['red', 'green', 'blue']); assert.equal(set.size, 3);
.clear(): void
^([ES6])
从这个 Set 中移除所有元素。
const set = new Set(['red', 'green', 'blue']); assert.equal(set.size, 3); set.clear(); assert.equal(set.size, 0);
35.5.4 Set<T>.prototype
: 迭代和循环
.values(): Iterable<T>
^([ES6])
返回这个 Set 的所有元素的可迭代对象。
const set = new Set(['red', 'green']); for (const x of set.values()) { console.log(x); } // Output: // 'red' // 'green'
[Symbol.iterator](): Iterable<T>
^([ES6])
迭代 Set 的默认方式。与.values()
相同。
const set = new Set(['red', 'green']); for (const x of set) { console.log(x); } // Output: // 'red' // 'green'
.forEach(callback: (value: T, key: T, theSet: Set<T>) => void, thisArg?: any): void
^([ES6])
将这个 Set 的每个元素传递给callback()
。value
和key
都包含当前元素。这种冗余是为了使这个callback
的类型签名与Map.prototype.forEach()
的callback
相同。
您可以通过thisArg
指定callback
的this
。如果省略,this
为undefined
。
const set = new Set(['red', 'green']); set.forEach(x => console.log(x)); // Output: // 'red' // 'green'
35.5.5 与Map
的对称性
以下两种方法主要是为了使 Set 和 Map 具有类似的接口。每个 Set 元素都被处理,就好像它是一个键和值都是元素的 Map 条目一样。
Set.prototype.entries(): Iterable<[T,T]>
^([ES6])Set.prototype.keys(): Iterable<T>
^([ES6])
.entries()
使您可以将 Set 转换为 Map:
const set = new Set(['a', 'b', 'c']); const map = new Map(set.entries()); assert.deepEqual( Array.from(map.entries()), [['a','a'], ['b','b'], ['c','c']] );
35.6 常见问题:Set
35.6.1 为什么 Set 有.size
,而数组有.length
?
这个问题的答案在§33.6.4 “为什么 Map 有.size
,而数组有.length
?”中给出。
测验
查看测验应用。
三十六、WeakSets (WeakSet) (advanced)
- 36.1 示例:将对象标记为可与方法一起使用
- 36.2 WeakSet API
WeakSets 类似于 Sets,具有以下区别:
- 它们可以在不阻止这些对象被垃圾回收的情况下持有对象。
- 它们是黑匣子:我们只有在拥有 WeakSet 和一个值的情况下才能从 WeakSet 中获取任何数据。支持的唯一方法是
.add()
、.delete()
、.has()
。请参阅 WeakMaps 作为黑匣子一节,了解为什么 WeakSets 不允许迭代、循环和清除。
鉴于我们无法遍历它们的元素,WeakSets 的用例并不那么多。它们确实使我们能够标记对象。
36.1 示例:将对象标记为可与方法一起使用
以下代码演示了一个类如何确保其方法仅应用于由它创建的实例(基于Domenic Denicola 的代码):
const instancesOfSafeClass = new WeakSet(); class SafeClass { constructor() { instancesOfSafeClass.add(this); } method() { if (!instancesOfSafeClass.has(this)) { throw new TypeError('Incompatible object!'); } } } const safeInstance = new SafeClass(); safeInstance.method(); // works assert.throws( () => { const obj = {}; SafeClass.prototype.method.call(obj); // throws an exception }, TypeError );
36.2 WeakSet API
WeakSet
的构造函数和三个方法与它们的Set
等效方法的工作方式相同(参见 ch_sets.html#quickref-sets):
new WeakSet<T>(values?: Iterable<T>)
^([ES6]).add(value: T): this
^([ES6]).delete(value: T): boolean
^([ES6]).has(value: T): boolean
^([ES6])
三十七、解构
37.1 解构的第一印象
通过普通赋值,您一次提取一个数据片段,例如:
const arr = ['a', 'b', 'c']; const x = arr[0]; // extract const y = arr[1]; // extract
通过解构,您可以通过接收数据的位置中的模式同时提取多个数据片段。在前面的代码中,=
的左侧就是这样一个位置。在下面的代码中,第 A 行的方括号是一个解构模式:
const arr = ['a', 'b', 'c']; const [x, y] = arr; // (A) assert.equal(x, 'a'); assert.equal(y, 'b');
这段代码与前面的代码相同。
请注意,模式比数据“小”:我们只提取我们需要的部分。
37.2 构造 vs. 提取
为了理解解构是什么,考虑 JavaScript 有两种相反的操作:
- 您可以通过设置属性和对象字面量来构造复合数据。
- 您可以通过获取属性来提取复合数据的数据片段。
构造数据如下所示:
// Constructing: one property at a time const jane1 = {}; jane1.first = 'Jane'; jane1.last = 'Doe'; // Constructing: multiple properties const jane2 = { first: 'Jane', last: 'Doe', }; assert.deepEqual(jane1, jane2);
提取数据如下所示:
const jane = { first: 'Jane', last: 'Doe', }; // Extracting: one property at a time const f1 = jane.first; const l1 = jane.last; assert.equal(f1, 'Jane'); assert.equal(l1, 'Doe'); // Extracting: multiple properties (NEW!) const {first: f2, last: l2} = jane; // (A) assert.equal(f2, 'Jane'); assert.equal(l2, 'Doe');
第 A 行的操作是新的:我们声明了两个变量f2
和l2
,并通过解构(多值提取)对它们进行初始化。
第 A 行的以下部分是一个解构模式:
{first: f2, last: l2}
解构模式在语法上类似于用于多值构造的字面量。但它们出现在数据接收的地方(例如,在赋值的左侧),而不是数据创建的地方(例如,在赋值的右侧)。
37.3 我们可以在哪里进行解构?
解构模式可以在“数据接收位置”使用,例如:
- 变量声明:
const [a] = ['x']; assert.equal(a, 'x'); let [b] = ['y']; assert.equal(b, 'y');
- 赋值:
let b; [b] = ['z']; assert.equal(b, 'z');
- 参数定义:
const f = ([x]) => x; assert.equal(f(['a']), 'a');
请注意,变量声明包括 for-of
循环中的 const
和 let
声明:
const arr = ['a', 'b']; for (const [index, element] of arr.entries()) { console.log(index, element); } // Output: // 0, 'a' // 1, 'b'
在接下来的两节中,我们将深入探讨两种解构:对象解构和数组解构。
37.4 对象解构
对象解构 允许你通过看起来像对象字面量的模式批量提取属性的值:
const address = { street: 'Evergreen Terrace', number: '742', city: 'Springfield', state: 'NT', zip: '49007', }; const { street: s, city: c } = address; assert.equal(s, 'Evergreen Terrace'); assert.equal(c, 'Springfield');
你可以将模式看作是一个透明的图层,你将其放在数据上:模式键'street'
在数据中有匹配。因此,数据值'Evergreen Terrace'
被赋给模式变量s
。
你也可以对原始值进行对象解构:
const {length: len} = 'abc'; assert.equal(len, 3);
你也可以对数组进行对象解构:
const {0:x, 2:y} = ['a', 'b', 'c']; assert.equal(x, 'a'); assert.equal(y, 'c');
为什么会这样?数组索引也是属性。
37.4.1 属性值简写
对象字面量支持属性值简写,对象模式也是如此:
const { street, city } = address; assert.equal(street, 'Evergreen Terrace'); assert.equal(city, 'Springfield');
练习:对象解构
exercises/destructuring/object_destructuring_exrc.mjs
37.4.2 剩余属性
在对象字面量中,你可以有展开属性。在对象模式中,你可以有剩余属性(必须放在最后):
const obj = { a: 1, b: 2, c: 3 }; const { a: propValue, ...remaining } = obj; // (A) assert.equal(propValue, 1); assert.deepEqual(remaining, {b:2, c:3});
一个剩余属性变量,比如 remaining
(第 A 行),被赋予一个包含未在模式中提及的所有数据属性的对象。
remaining
也可以被视为从 obj
中非破坏性地移除属性 a
的结果。
37.4.3 语法陷阱:通过对象解构进行赋值
如果我们在赋值中使用对象解构,就会面临由语法歧义引起的陷阱 - 你不能以大括号开始一个语句,因为这样 JavaScript 会认为你正在开始一个代码块:
let prop; assert.throws( () => eval("{prop} = { prop: 'hello' };"), { name: 'SyntaxError', message: "Unexpected token '='", });
为什么使用 eval()
?
eval()
延迟解析(因此延迟了 SyntaxError
)直到 assert.throws()
的回调被执行。如果我们不使用它,当这段代码被解析时就会出现错误,assert.throws()
甚至都不会被执行。
解决方法是将整个赋值放在括号中:
let prop; ({prop} = { prop: 'hello' }); assert.equal(prop, 'hello');
37.5 数组解构
数组解构 允许你通过看起来像数组字面量的模式批量提取数组元素的值:
const [x, y] = ['a', 'b']; assert.equal(x, 'a'); assert.equal(y, 'b');
你可以通过在数组模式内部提及空位来跳过元素:
const [, x, y] = ['a', 'b', 'c']; // (A) assert.equal(x, 'b'); assert.equal(y, 'c');
在第 A 行的数组模式的第一个元素是一个空位,这就是为什么索引为 0 的数组元素被忽略了。
37.5.1 数组解构适用于任何可迭代对象
数组解构可以应用于任何可迭代的值,而不仅仅是数组:
// Sets are iterable const mySet = new Set().add('a').add('b').add('c'); const [first, second] = mySet; assert.equal(first, 'a'); assert.equal(second, 'b'); // Strings are iterable const [a, b] = 'xyz'; assert.equal(a, 'x'); assert.equal(b, 'y');
37.5.2 剩余元素
在数组字面量中,你可以有展开元素。在数组模式中,你可以有剩余元素(必须放在最后):
const [x, y, ...remaining] = ['a', 'b', 'c', 'd']; // (A) assert.equal(x, 'a'); assert.equal(y, 'b'); assert.deepEqual(remaining, ['c', 'd']);
一个 rest 元素变量,比如 remaining
(第 A 行),被赋予一个包含尚未提及的解构值的所有元素的数组。
37.6 解构的例子
37.6.1 数组解构:交换变量值
你可以使用数组解构来交换两个变量的值,而不需要临时变量:
let x = 'a'; let y = 'b'; [x,y] = [y,x]; // swap assert.equal(x, 'b'); assert.equal(y, 'a');
37.6.2 数组解构:返回数组的操作
当操作返回数组时,数组解构非常有用,例如正则表达式方法.exec()
就返回数组:
// Skip the element at index 0 (the whole match): const [, year, month, day] = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/ .exec('2999-12-31'); assert.equal(year, '2999'); assert.equal(month, '12'); assert.equal(day, '31');
37.6.3 对象解构:多个返回值
如果一个函数返回多个值 - 无论是作为数组打包还是作为对象打包 - 解构非常有用。
考虑一个名为findElement()
的函数,它在数组中查找元素:
findElement(array, (value, index) => «boolean expression»)
它的第二个参数是一个接收元素的值和索引并返回一个布尔值指示这是否是调用者正在寻找的元素的函数。
现在我们面临一个两难选择:findElement()
应该返回它找到的元素的值还是索引?一个解决方案是创建两个单独的函数,但这将导致重复的代码,因为这两个函数将非常相似。
以下实现通过返回一个包含找到的元素的索引和值的对象来避免重复:
function findElement(arr, predicate) { for (let index=0; index < arr.length; index++) { const value = arr[index]; if (predicate(value)) { // We found something: return { value, index }; } } // We didn’t find anything: return { value: undefined, index: -1 }; }
解构帮助我们处理findElement()
的结果:
const arr = [7, 8, 6]; const {value, index} = findElement(arr, x => x % 2 === 0); assert.equal(value, 8); assert.equal(index, 1);
由于我们正在处理属性键,因此我们提到value
和index
的顺序并不重要:
const {index, value} = findElement(arr, x => x % 2 === 0);
关键是,解构还可以很好地为我们服务,即使我们只对两个结果中的一个感兴趣:
const arr = [7, 8, 6]; const {value} = findElement(arr, x => x % 2 === 0); assert.equal(value, 8); const {index} = findElement(arr, x => x % 2 === 0); assert.equal(index, 1);
所有这些便利结合在一起,使得处理多个返回值的方式非常灵活。
37.7 如果模式部分没有匹配到任何内容会发生什么?
如果模式的一部分没有匹配,会发生什么?与使用非批处理运算符一样:您会得到undefined
。
37.7.1 对象解构和缺少属性
如果对象模式中的属性在右侧没有匹配项,则会得到undefined
:
const {prop: p} = {}; assert.equal(p, undefined);
37.7.2 数组解构和缺少元素
如果数组模式中的元素在右侧没有匹配项,则会得到undefined
:
const [x] = []; assert.equal(x, undefined);
37.8 哪些值不能被解构?
37.8.1 您不能对undefined
和null
进行对象解构
只有当要解构的值是undefined
或null
时,对象解构才会失败。也就是说,每当通过点运算符访问属性失败时,解构也会失败。
> const {prop} = undefined TypeError: Cannot destructure property 'prop' of 'undefined' as it is undefined. > const {prop} = null TypeError: Cannot destructure property 'prop' of 'null' as it is null.
37.8.2 您不能对非可迭代值进行数组解构
数组解构要求被解构的值是可迭代的。因此,您不能对undefined
和null
进行数组解构。但您也不能对非可迭代对象进行数组解构:
> const [x] = {} TypeError: {} is not iterable
Quiz: basic
请参阅 quiz app。
37.9 (高级)
所有剩余的部分都是高级的。
37.10 默认值
通常,如果模式没有匹配,相应的变量将被设置为undefined
:
const {prop: p} = {}; assert.equal(p, undefined);
如果要使用不同的值,需要指定默认值(通过=
):
const {prop: p = 123} = {}; // (A) assert.equal(p, 123);
在 A 行,我们指定了p
的默认值为123
。由于我们正在解构的数据中没有名为prop
的属性,因此使用了默认值。
37.10.1 数组解构中的默认值
在这里,我们有两个默认值分配给变量x
和y
,因为被解构的数组中对应的元素不存在。
const [x=1, y=2] = []; assert.equal(x, 1); assert.equal(y, 2);
数组模式的第一个元素的默认值是1
;第二个元素的默认值是2
。
37.10.2 对象解构中的默认值
您还可以为对象解构指定默认值:
const {first: f='', last: l=''} = {}; assert.equal(f, ''); assert.equal(l, '');
在被解构的对象中,既没有属性键first
也没有属性键last
。因此,使用了默认值。
使用属性值简写,这段代码变得更简单:
const {first='', last=''} = {}; assert.equal(first, ''); assert.equal(last, '');
37.11 参数定义类似于解构
考虑到我们在本章中学到的内容,参数定义与数组模式(剩余元素,默认值等)有很多共同之处。实际上,以下两个函数声明是等价的:
function f1(«pattern1», «pattern2») { // ··· } function f2(...args) { const [«pattern1», «pattern2»] = args; // ··· }
37.12 嵌套解构
到目前为止,我们只在解构模式中将变量用作赋值目标(数据接收器)。但是您也可以使用模式作为赋值目标,这使您可以将模式嵌套到任意深度:
const arr = [ { first: 'Jane', last: 'Bond' }, { first: 'Lars', last: 'Croft' }, ]; const [, {first}] = arr; // (A) assert.equal(first, 'Lars');
在 A 行的数组模式中,索引 1 处有一个嵌套的对象模式。
嵌套模式可能会变得难以理解,因此最好适度使用。
Quiz: advanced
请参阅 quiz app。
三十八、同步生成器(高级)
38.1 什么是同步生成器?
同步生成器是函数定义和方法定义的特殊版本,它们总是返回同步可迭代对象:
// Generator function declaration function* genFunc1() { /*···*/ } // Generator function expression const genFunc2 = function* () { /*···*/ }; // Generator method definition in an object literal const obj = { * generatorMethod() { // ··· } }; // Generator method definition in a class definition // (class declaration or class expression) class MyClass { * generatorMethod() { // ··· } }
星号(*
)标记函数和方法为生成器:
- 函数:伪关键字
function*
是关键字function
和星号的组合。 - 方法:
*
是一个修饰符(类似于static
和get
)。
38.1.1 生成器函数返回可迭代对象并通过yield
填充它们
如果我们调用一个生成器函数,它会返回一个可迭代对象(实际上是一个同时也是可迭代的迭代器)。生成器通过yield
操作符填充该可迭代对象:
function* genFunc1() { yield 'a'; yield 'b'; } const iterable = genFunc1(); // Convert the iterable to an Array, to check what’s inside: assert.deepEqual( Array.from(iterable), ['a', 'b'] ); // We can also use a for-of loop for (const x of genFunc1()) { console.log(x); } // Output: // 'a' // 'b'
38.1.2 yield
暂停生成器函数
使用生成器函数涉及以下步骤:
- 调用它会返回一个迭代器
iter
(也是可迭代的)。 - 重复迭代
iter
会调用iter.next()
。每次,我们都会进入生成器函数的主体,直到出现返回值的yield
。
因此,yield
不仅仅是向可迭代对象添加值,它还会暂停并退出生成器函数:
- 与
return
一样,yield
退出函数体并返回一个值(通过.next()
)。 - 与
return
不同,如果我们重复调用(.next()
),执行将直接在yield
之后恢复。
让我们通过以下生成器函数来检查这意味着什么。
let location = 0; function* genFunc2() { location = 1; yield 'a'; location = 2; yield 'b'; location = 3; }
为了使用genFunc2()
,我们必须首先创建迭代器/可迭代对象iter
。genFunc2()
现在被暂停在其主体“之前”。
const iter = genFunc2(); // genFunc2() is now paused “before” its body: assert.equal(location, 0);
iter
实现了迭代协议。因此,我们通过iter.next()
来控制genFunc2()
的执行。调用该方法会恢复暂停的genFunc2()
并执行,直到出现yield
。然后执行暂停,.next()
返回yield
的操作数:
assert.deepEqual( iter.next(), {value: 'a', done: false}); // genFunc2() is now paused directly after the first `yield`: assert.equal(location, 1);
请注意,yield
的值'a'
被包装在一个对象中,这就是迭代器始终传递其值的方式。
我们再次调用iter.next()
,执行将在之前暂停的地方继续。一旦遇到第二个yield
,genFunc2()
被暂停,.next()
返回'b'
。
assert.deepEqual( iter.next(), {value: 'b', done: false}); // genFunc2() is now paused directly after the second `yield`: assert.equal(location, 2);
我们再次调用iter.next()
,执行将继续,直到离开genFunc2()
的主体:
assert.deepEqual( iter.next(), {value: undefined, done: true}); // We have reached the end of genFunc2(): assert.equal(location, 3);
这一次,.next()
的结果的.done
属性为true
,这意味着迭代器已经完成。
38.1.3 yield
为什么会暂停执行?
yield
暂停执行的好处是什么?为什么它不像数组方法.push()
那样简单地填充可迭代对象而不暂停?
由于暂停,生成器提供了许多协程的功能(考虑协作式多任务处理的进程)。例如,当我们请求可迭代对象的下一个值时,该值是惰性计算的(按需计算)。以下两个生成器函数演示了这意味着什么。
/** * Returns an iterable over lines */ function* genLines() { yield 'A line'; yield 'Another line'; yield 'Last line'; } /** * Input: iterable over lines * Output: iterable over numbered lines */ function* numberLines(lineIterable) { let lineNumber = 1; for (const line of lineIterable) { // input yield lineNumber + ': ' + line; // output lineNumber++; } }
请注意,numberLines()
中的yield
出现在for-of
循环内。yield
可以在循环内使用,但不能在回调内使用(稍后会详细介绍)。
让我们将两个生成器组合起来产生可迭代对象numberedLines
:
const numberedLines = numberLines(genLines()); assert.deepEqual( numberedLines.next(), {value: '1: A line', done: false}); assert.deepEqual( numberedLines.next(), {value: '2: Another line', done: false});
在这里使用生成器的主要好处是一切都是逐步进行的:通过numberedLines.next()
,我们只要求numberLines()
提供一个编号行。反过来,它只要求genLines()
提供一个未编号行。
如果,例如,genLines()
从大型文本文件中读取其行,则此增量主义将继续工作:如果我们要求numberLines()
提供编号行,则只要genLines()
从文本文件中读取了第一行,我们就会得到一个编号行。
如果没有生成器,genLines()
将首先读取所有行并返回它们。然后numberLines()
将对所有行进行编号并返回它们。因此,我们必须等待更长的时间才能获得第一行编号。
练习:将普通函数转换为生成器
exercises/sync-generators/fib_seq_test.mjs
38.1.4 示例:在可迭代对象上进行映射
以下函数mapIter()
类似于数组方法.map()
,但它返回一个可迭代对象,而不是一个数组,并且按需生成其结果。
function* mapIter(iterable, func) { let index = 0; for (const x of iterable) { yield func(x, index); index++; } } const iterable = mapIter(['a', 'b'], x => x + x); assert.deepEqual( Array.from(iterable), ['aa', 'bb'] );
练习:过滤可迭代对象
exercises/sync-generators/filter_iter_gen_test.mjs
38.2 从生成器调用生成器(高级)
38.2.1 通过yield*
调用生成器
yield
只能直接在生成器内部使用 - 到目前为止,我们还没有看到将产出委托给另一个函数或方法的方法。
让我们首先检查什么不起作用:在以下示例中,我们希望foo()
调用bar()
,以便后者为前者产生两个值。遗憾的是,朴素的方法失败了:
function* bar() { yield 'a'; yield 'b'; } function* foo() { // Nothing happens if we call `bar()`: bar(); } assert.deepEqual( Array.from(foo()), [] );
为什么这不起作用?函数调用bar()
返回一个可迭代对象,我们忽略了它。
我们希望foo()
产生bar()
产生的所有内容。这就是yield*
运算符的作用:
function* bar() { yield 'a'; yield 'b'; } function* foo() { yield* bar(); } assert.deepEqual( Array.from(foo()), ['a', 'b'] );
换句话说,前面的foo()
大致相当于:
function* foo() { for (const x of bar()) { yield x; } }
请注意,yield*
适用于任何可迭代对象:
function* gen() { yield* [1, 2]; } assert.deepEqual( Array.from(gen()), [1, 2] );
38.2.2 示例:遍历树
yield*
让我们在生成器中进行递归调用,这在迭代递归数据结构(如树)时非常有用。例如,以下是用于二叉树的数据结构。
class BinaryTree { constructor(value, left=null, right=null) { this.value = value; this.left = left; this.right = right; } /** Prefix iteration: parent before children */ * [Symbol.iterator]() { yield this.value; if (this.left) { // Same as yield* this.left[Symbol.iterator]() yield* this.left; } if (this.right) { yield* this.right; } } }
方法[Symbol.iterator]()
添加了对迭代协议的支持,这意味着我们可以使用for-of
循环来迭代BinaryTree
的实例:
const tree = new BinaryTree('a', new BinaryTree('b', new BinaryTree('c'), new BinaryTree('d')), new BinaryTree('e')); for (const x of tree) { console.log(x); } // Output: // 'a' // 'b' // 'c' // 'd' // 'e'
练习:遍历嵌套数组
exercises/sync-generators/iter_nested_arrays_test.mjs
38.3 背景:外部迭代 vs. 内部迭代
为了准备下一节,我们需要了解两种不同的迭代对象“内部”值的风格:
- 外部迭代(拉取):您的代码通过迭代协议向对象请求值。例如,
for-of
循环基于 JavaScript 的迭代协议:
for (const x of ['a', 'b']) { console.log(x); } // Output: // 'a' // 'b'
- 内部迭代(推送):我们将回调函数传递给对象的方法,该方法将值提供给回调。例如,数组具有方法
.forEach()
:
['a', 'b'].forEach((x) => { console.log(x); }); // Output: // 'a' // 'b'
下一节中有两种迭代风格的示例。
38.4 生成器的用例:重用遍历
生成器的一个重要用例是提取和重用遍历。
38.4.1 重用的遍历
例如,考虑以下遍历文件树并记录它们路径的函数(它使用Node.js API来实现):
function logPaths(dir) { for (const fileName of fs.readdirSync(dir)) { const filePath = path.resolve(dir, fileName); console.log(filePath); const stats = fs.statSync(filePath); if (stats.isDirectory()) { logPaths(filePath); // recursive call } } }
考虑以下目录:
mydir/ a.txt b.txt subdir/ c.txt
让我们记录mydir/
内部的路径:
logPaths('mydir'); // Output: // 'mydir/a.txt' // 'mydir/b.txt' // 'mydir/subdir' // 'mydir/subdir/c.txt'
我们如何重用这个遍历并做一些除了记录路径之外的事情呢?
38.4.2 内部迭代(推)
重用遍历代码的一种方式是通过内部迭代:每个遍历的值都传递给一个回调函数(A 行)。
function visitPaths(dir, callback) { for (const fileName of fs.readdirSync(dir)) { const filePath = path.resolve(dir, fileName); callback(filePath); // (A) const stats = fs.statSync(filePath); if (stats.isDirectory()) { visitPaths(filePath, callback); } } } const paths = []; visitPaths('mydir', p => paths.push(p)); assert.deepEqual( paths, [ 'mydir/a.txt', 'mydir/b.txt', 'mydir/subdir', 'mydir/subdir/c.txt', ]);
38.4.3 外部迭代(拉)
重用遍历代码的另一种方式是通过外部迭代:我们可以编写一个生成器,它会产生所有遍历的值(A 行)。
function* iterPaths(dir) { for (const fileName of fs.readdirSync(dir)) { const filePath = path.resolve(dir, fileName); yield filePath; // (A) const stats = fs.statSync(filePath); if (stats.isDirectory()) { yield* iterPaths(filePath); } } } const paths = Array.from(iterPaths('mydir'));
38.5 生成器的高级特性
Exploring ES6中的生成器章节涵盖了本书范围之外的两个特性:
yield
也可以通过.next()
的参数接收数据。- 生成器也可以
return
值(不仅仅是yield
它们)。这样的值不会成为迭代值,但可以通过yield*
来检索。