许多编程语言都将迭代数据的方式从使用 for
循环转变到使用迭代器对象,for
循环需要初始化变量以便追踪集合内的位置,而迭代器则以编程方式返回集合中的下一个项。迭代器使操作集合变得更简单,JS 语言的很多新成分中都有迭代器的身影,如 for-of
和扩展运算符。
何为迭代器?
迭代器是被设计专用于迭代的对象,带有特定接口。所有迭代器对象都拥有 next()
方法,会返回一个结果对象。该结果对象有两个属性:对应下一个值的 value
,以及一个布尔类型的 done
,其值为 true
时表示没有更多值可供使用。迭代器持有一个指向集合位置的内部指针,每当调用了 next()
方法,迭代器就会返回相应的下一个值。
若你在最后一个值返回后再调用 next()
,所返回的 done
属性值会是 true
,并且 value
属性值会是迭代器自身的返回值(return value,即使用 return 语句明确返回的值)。该“返回值”不是原数据集的一部分,却会成为相关数据的最后一个片段,或在迭代器未提供返回值的时候使用 undefined
。迭代器自身的返回值类似于函数的返回值,是向调用者返回信息的最后手段。
记住这些后,在 ES5 中创建一个迭代器就相当简单了:
1 | function createIterator (items) { |
正如此例演示,根据迭代器的规则来书写一个迭代器,是有一点复杂的。为此,ES6 提供了生成器,让创建迭代器对象变得更简单。
何为生成器?
生成器是能返回一个迭代器的函数。生成器由放在 function
关键字之后的一个星号(*
)来表示,并能使用新的 yield
关键字。将星号紧跟在 function
关键字之后,或是在中间留出空格,都是没问题的,正如下例:
1 | function *createIterator () { |
生成器函数最有意思的方面可能就是它们会在每个 yield
语句后停止执行。例如,此代码中 yield 1
执行后,该函数将不会再执行任何操作,直到迭代器的 next()
方法被调用,此时才继续执行 yield 2
。
yield
关键字可以和值或是表达式一起使用,因此你可以通过生成器给迭代器添加项目,而不是机械化地将项目一个个列出。
1 | function *createIterator (items) { |
yield
关键字只能用在生成器内部,用于其他任意位置都是语法错误,即使在生成器内部的函数中也不行!
例如下面的写法就是错误的:
1 | function *createIterator (items) { |
尽管 yield
严格位于 createIterator()
内部,此代码仍然有语法错误,因为 yield
无法穿越函数边界。从这点上来说,yield
与 return
非常相似,在一个被嵌套的函数中无法将值返回给包含它的函数。
生成器函数表达式
你可以使用函数表达式来创建一个生成器,只要在 function
关键字与圆括号之间使用一个星号(*
)即可。例如:
1 | let createIterator = function * (items) { |
不能将箭头函数创建为生成器。
生成器对象方法
由于生成器就是函数,因此也可以被添加到对象中。
1 | var o = { |
可迭代对象和Symbol.iterator
- 首先要记住迭代器是一个对象,是一个带有特定接口、专门用来迭代的对象
- 生成器是一个用于生成迭代器的函数
- 只要具有
Symbol.iterator
方法的对象,就是可迭代对象 Symbol.iterator
知名符号定义了为指定对象返回迭代器的函数,换句话说,Symbol.iterator
方法是可迭代对象的生成器方法- 生成器创建的所有迭代器都是可迭代对象,因为生成器默认就会为
Symbol.iterator
属性赋值
在 ES6 中,所有的集合对象(数组、Set和Map)以及字符串都是可迭代对象,因此它们都被指定了默认的迭代器。
for-of循环
for-of
首先会调用可迭代对象的 Symbol.iterator
来生成迭代器,然后在循环中调用迭代器的 next()
方法,并将迭代器的 value
值存储在一个变量上,循环过程会持续到迭代器的 done
属性变为 true
为止。
访问默认迭代器
你可以使用 Symbol.iterator
来访问对象上的默认迭代器,就像这样:
1 | let values = [1, 2, 3] |
既然 Symbol.iterator
指定了默认迭代器,你就可以使用它来检测一个对象是否能进行迭代,正如下例:
1 | function isIterable (object) { |
这个 isIterable()
函数仅仅查看对象是否存在一个类型为函数的默认迭代器。for-of
循环在执行之前会做类似的检查。
创建可迭代对象
开发者自定义对象默认情况下不是可迭代对象,但你可以创建一个包含生成器的 Symbol.iterator
属性,让它们成为可迭代对象。例如:
1 | let collection = { |
内置的迭代器
集合的迭代器
ES6 具有三种集合对象类型:数组、Map 和 Set。这三种类型都拥有如下迭代器,有助于探索它们的内容:
entries()
:返回一个包含键值对的迭代器。values()
:返回一个包含集合中的值的迭代器。keys()
:返回一个包含集合中的键的迭代器。
entries
entries()
迭代器会在每次 next()
被调用时返回一个双项数组,此数组代表了集合中每个元素的键与值:对于数组来说,第一项是数值索引。对于 Set,第一项也是值。对于 Map,第一项就是键。
记住,第一项是键,第二项才是值,别用混了……
1 | let colors = [ 'red', 'green', 'blue' ] |
values
values()
迭代器仅仅能返回存储在集合内的值。
1 | let colors = [ 'red', 'green', 'blue' ] |
keys
keys()
迭代器能返回集合中的每一个键。对于数组来说,它只返回了数值类型的键,永不返回数组的其他自有属性。Set 的键与值时相同的,因此它的 keys()
与 values()
返回了相同的迭代器。对于 Map,keys()
迭代器返回了每个不重复的键。
1 | let colors = [ 'red', 'green', 'blue' ] |
集合类型的默认迭代器
当 for-of
循环没有显示指定迭代器时,每种集合类型都有一个默认的迭代器供循环使用。values()
方法是数组与 Set 的默认迭代器,而 entries()
方法则是 Map 的默认迭代器。
Map 默认迭代器的行为有助于在 for-of
循环中使用解构,正如此例:
1 | let data = new Map() |
字符串和 NodeList
需要记住,可以对字符串和 NodeList
使用 for-of
循环!
扩展运算符与非数组的可迭代对象
扩展运算符能作用于所有可迭代对象,并且会使用默认迭代器来判断需要使用哪些值。所有的值都从迭代器中被读取出来并插入数组,遵循迭代器返回值的顺序。
1 | let mySet = new Set([1, 2, 3, 3, 3, 4, 5]) |
迭代器高级功能
传递参数给迭代器
你可以通过 next()
方法向迭代器传递参数。当一个参数被传递给 next()
方法时,该参数就会成为生成器内部 yield
语句的值。此处有个基本范例:
1 | function *createIterator () { |
对于 next()
的首次调用是一个特殊情况,传给它的任意参数都会被忽略。由于传递给 next()
的参数会成为 yield
语句的值,该 yield
语句指的是上次生成器中断执行处的语句。而 next()
方法第一次被调用时,生成器函数才刚刚开始执行,没有所谓的“上一次中断处的 yield
语句”可供赋值。因此在第一次调用 next()
时,不存在任何向其传递参数的理由。
在第二次调用 next()
时,4
作为参数被传递进去,这个 4
最终被赋值给了生成器函数内部的 first
变量。在包含赋值操作的第一个 yield
语句中,表达式右侧在第一次调用 next()
时被计算,而表达式左侧则在第二次调用 next()
方法、并在生成器函数继续执行前被计算。由于第二次调用 next()
传入了 4
,这个值就被赋给了 first
变量,之后生成器继续执行。
第二个 yield
使用了第一个 yield
的结果并加上了 2
,也就是返回了一个 6
。当 next()
被第三次调用时,传入了参数 5
。这个值被赋给了 second
变量,并随后用在了第三个 yield
语句中,返回了 8
。
在迭代器中抛出错误
能传递给迭代器的不仅是数据,还可以是错误条件。迭代器可以选择实现一个 throw()
方法,用于指示迭代器应在恢复执行时抛出一个错误。这是对异步编程来说很重要的一个能力,同时也会增加生成器内部的灵活度,能够既模仿返回一个值,又模仿抛出错误。你可以传递一个错误对象给 throw()
方法,当迭代器继续进行处理时应当抛出此错误。例如:
如果你不使用 throw()
传递一个错误给迭代器,而是通过 next()
传递一个错误给迭代器,那么迭代器内部不会抛出错误!
1 | function *createIterator () { |
了解这些之后,你就可以在生成器内部使用一个 try-catch
块来捕捉这种错误:
1 | function *createIterator () { |
本例使用一个 try-catch
块包裹了第二个 yield
语句。尽管这个 yield
自身的执行不会出错,但在对 second
变量赋值之前,错误就在此时被抛出,于是 catch
部分捕捉错误并将这个变量赋值为 6
,然后再继续执行到下一个 yield
处并返回了 9
。
要注意一件有趣的事情发生了:throw()
方法就像 next()
方法一样返回了一个结果对象。由于错误在生成器内部被捕捉,代码继续执行到下一个 yield
处并返回了下一个值,也就是 9
。
将 next()
与 throw()
都当作迭代器的指令,会有助于思考。next()
方法指示迭代器继续执行,而 throw()
方法则指示迭代器通过抛出一个错误继续执行。
生成器的 return
由于生成器是函数,你可以在它内部使用 return
语句,既可以让生成器早一点退出执行,也可以指定在 next()
方法最后一次调用时的返回值。在生成器内,return
表明所有的处理已完成,因此 done
属性会被设为 true
,而如果提供了返回值,就会被用于 value
字段。此处有个例子,单纯使用 return
让生成器更早返回:
1 | function *createIterator () { |
你也可以指定一个返回值,会被用于最终返回的结果对象中的 value
字段。例如:
1 | function *createIterator () { |
扩展运算符与 for-of
循环会忽略 return
语句所指定的任意值。一旦它们看到 done
的值为 true
,它们就会停止操作而不会读取对应的 value
值。
生成器委托
在某些情况下,将两个迭代器的值合并一起会更有用。生成器可以用星号(*
)配合 yield
这一特殊形式来委托其他的迭代器。正如生成器的定义,星号出现在何处是不重要的,只要落在 yield
关键字与生成器函数名之间即可。此处有个范例:
1 | function *createNumberIterator () { |
此例中的 createCombinedIterator()
生成器依次委托了 createNumberIterator()
与 createColorIterator()
。返回的迭代器从外部看来就是一个单一的迭代器,用于产生所有的值。每次对 next()
的调用都会委托给合适的生成器,直到使用 createNumberIterator()
与 createColorIterator()
创建的迭代器全部清空为止。然后最终的 yield
会被执行以返回true
。
生成器委托也能让你进一步使用生成器的返回值。这是访问这些返回值的最简单方式,并且在执行复杂任务时会非常有用。例如:
1 | function *createNumberIterator () { |
注意观察,第三次调用 next()
方法时,代码在 createNumberIterator()
内的 return
语句处并没有停止执行,而是直接运行到 createCombinedIterator()
内的下一个 yield
处才停止执行的。
此处 createCombinedIterator()
生成器委托了 createNumberIterator()
并将它的返回值赋值给了 result
变量。由于 createNumberIterator()
包含 return 3
语句,该返回值就是 3
。result
变量接下来会作为参数传递给 createRepeatingIterator()
生成器,指示同一个字符串需要被重复几次。
你也可以直接在字符串上使用 yield *
,字符串的默认迭代器会被使用。
1 | function *createCombinedIterator () { |
异步任务运行
执行异步操作的传统方式是调用一个包含回调的函数。例如,在 Node 中从磁盘读取一个文件:
1 | let fs = require('fs') |
当你拥有数量少而有限的任务需要完成时,这么做很有效。然而当你需要嵌套回调函数,或者要按顺序处理一系列的异步任务时,传统方式就会非常麻烦。在这种场合下,生成器与 yield
会很有用。
一个简单的任务运行器
由于 yield
能停止运行,并在重新运行前等待 next()
方法被调用,你就可以在没有回调函数的情况下实现异步调用。首先,你需要一个能够调用生成器并启动迭代器的函数,就像这样:
1 | function run (taskDef) { |
配合这个已实现的 run()
函数,你就可以运行一个包含多条 yield
语句的生成器,就像这样:
1 | run (function * () { |
此例只是将三个数值输出到控制台,单纯用于表明对 next()
的所有调用都已被执行。然而,仅仅使用几次 yield
并不太有意义,下一步是要把值传进迭代器并获取返回数据。
带数据的任务运行
传递数据给任务运行器最简单的方式,就是把 yield
返回的值传入下一次的 next()
调用。为此,你仅需传递 result.value
,正如以下代码:
1 | function run (taskDef) { |
异步任务运行器
下面的代码是一个使用生成器来运行异步任务的例子。
ps. 为了保持纯粹,这个例子中并没有使用 Promise
1 | let fs = require('fs') |