“The best programmers are not marginally better than merely good ones. They are an order-of-magnitude better, measured by whatever standard: conceptual creativity, speed, ingenuity of design, or problem-solving ability.” —Randall E. Stross
前言
大多数编程语言已经从使用for循环(需要初始化变量以便跟踪数据在集合中的位置)转而使用以编程方式返回集合中下一项的迭代器对象来迭代数据。
尽管迭代器使得处理数据集合更加容易,但是由于需要显式地维护其内部状态,因此它们的创建需要谨慎的编程。生成器函数提供了一个强大的选择:它们允许您通过编写一个执行不连续的函数来定义迭代算法。
循环的秘密
如果你使用JavaScript(ES5)来编写过程序,那你很可能写类似一下子的代码:
1 | var colors = ["red", "green", "blue"]; |
这种用最原始for循环来迭代数组(或类数组对象)是最简单也是最直观的,需要使用变量来追踪colors数组的索引位置,变量的值会随着每次迭代而递增直到它大于数组的长度值。
虽然上面的循环十分简单,但是当你嵌套循环并且需要跟踪多个变量时,循环会变得复杂。额外的复杂性可能导致错误,并且for循环的样板性质会导致更多错误,因为类似的代码写在多个地方。而迭代器旨在解决这些问题。
类数组对象的玄机
众所周知,在JavaScript中遍历数组中的每一个元素是一件很简单的事情,我们可以通过最原始的for、while循环,或ES6为我们提供的for-of之类的众多方法来实现;那么为什么这些方法就可以遍历数组或类数组对象(String、Map、Set、arguments)呢?而为什么这些方法不能遍历Object呢?
古人有云:要想战胜它,就先成为它;那么我们就先来来看看类数组对象究竟是什么货色。我分别打印了Array、Map、Set、还有String等类数组对象的信息,发现它们有一个共同的特征:其原型对象上都实现了[Symbol(Symbol.iterator)]方法。
可迭代协议
在实际编程工作中,我们除了会使用数组这样的数据结构之外,还会大量使用自定义数据结构,那么我们如何去遍历或者说使这些自定义数据结构能够被循环迭代呢?上一节的[Symbol(Symbol.iterator)]方法会不会对此有用呢?
可迭代协议允许JavaScript对象定义或自定义其迭代行为,例如在for..of构造中循环的值;为了实现可迭代性,对象(或其原型上)必须实现Symbol.iterator方法,该方法返回一个叫做迭代器的对象。
实现
1 | const iterable = { |
应用
JavaScript中一些内置数据类型是具有默认迭代行为的内置可迭代类型,例如String、Array、Mapy及Set,因为它们的原型上实现了[Symbol.iterator]方法,其实就是实现了可迭代协议。
当然还有很多地方使用了可迭代性,有的可能不太明显,比如:
- for-of循环
- 数组结构
- ES6扩展运算符
- Map和Set的构造函数要求是可迭代对象
Iterator(迭代器)
定义
迭代器仅仅是具有为迭代设计的特定接口的对象。所有的迭代器对象都有一个“next()”方法,这个方法返回一个含有value和done两个字段的对象,其中value字段的值就是当前从数据集合中迭代出的值;而done字段的是一个布尔值,用来表示迭代是否已经完成。迭代器一旦被创建, 我们就能通过重复调用“next()”方法来迭代它,在产生终止值后继续调用next()方法应该始终返回{done: true}
案例
1 | function makeRangeIterator<T>(dataCollection: Array<T>, start=0, step=1,end=Infinity,){ |
下面这张图可以帮组我们建立可迭代性、迭代器、next之间的关系:
Generator(生成器)
虽然自定义迭代器是一个十分有用的工具,但是从上面代码可以看出由于需要去显示维护其内部状态,所以创建迭代器需要谨慎的编程。生成器是一个强有力的替代工具,它使得我们可以更简单的创建迭代器对象。
定义
生成器是一个返回迭代器的函数,它允许通过编写一个非连续执行的函数来定义迭代算法。它的的语法为“function* FuncName(){ }”。首次调用时,生成器函数不执行任何代码,而是返回一种称为Generator的迭代器。通过调用生成器的“next”方法消耗值时,Generator函数将执行,直到遇到yield关键字。
简而言之,JavaScript中生成器是一个返回值为迭代器的函数,与普通函数不同的是,生成器函数在执行过程中可以中断并且能从中断的地方继续执行。下面的这张图对比了普通函数与生成器函数执行过程:
举例说明
1 | function* generatorFunction(){ // line 1 |
上面代码中,我们使用了function* 的语法创建了一个生成器函数(line 1),在函数内部我们使用了另一个关键字yield(line 2),它只能在生成器中使用。生成器函数在执行过程中一旦运行到yield关键字时,会立即“返回”定义在它后面的值并中断函数的执行,注意此处的返回跟普通函数的return不同,在生成器上下文中,我们叫(yield)产出某值。
1 | function * generatorFunc() { |
在(line 3)中我们通过创建了generatorObject对象,这看起来貌似生成器函数“generatorFunction”内部的语句应该已经执行,但是神奇的是第一句console.log为啥并没有打印输出呢?原来是,当生成器函数执行时总是简单的返回一个迭代器对象,然后通过调用这个迭代器对象的next方法,生成器函数内部语句才会开始执行;
所以(line 3 中)generatorObject就是一个迭代器对象,然后我们通过调用它的next方法(line 4),这时生成器“generatorFunction”才开始真正执行,打印出”This will be executed first.“;接着执行下一行代码(line 2),这时遇到了yield关键值,将其后定义的‘Hello’字符串作为值,产出“{value: ‘Hello’, done: false}”对象,同时会被暂时中断挂起,直到迭代器再次调用next方法;
在line 5中,我们再次调用了next,这时生成器函数被唤醒并从上一次挂起的地方继续开始执行,所以打印出”I will be printed after the pause“,接着又遇到了yield关键字,产出”{value: ‘World!’, done: false}”后再次挂起;
在line 5中,我们又再一次调用了next,这次生成器函数被唤醒后发现没有语句可以执行了,记住如果函数没有return语句,默认返回undefined,因此生成器函数会产出”{value: undefined, done: true}”,属性值done被设置为true,这就意味着生成器函数执行结束了。
应用场景
一 . 实现可迭代
前面有提到要实现可迭代性,对象(或其原型上)必须实现[Symbol.iterator]方法来返回一个我们自己编写的迭代器,这使得实现过于复杂,而生成器函数可以使我们更简单的创建迭代器对象。接下来我们分别使用自定义迭代器和生成器来实现可迭代行对象。
自定义迭代器:
1 | const iterableObj = { |
生成器
1 | function * generatorFunc() { |
你可以比较两个版本,它可以说明生成器带来的诸多好处:
- 我们不需要关心Symbol.iterator
- 我们不再必须实现next方法
- 我们不再关心必须像在next方法中返回{value: ‘This’, done: false}这样的对象结构
- 我们不用去维护迭代器内部的状态
二. 异步任务处理
生成器最让人兴奋的地方与异步编程相关。 JavaScript中的异步编程是一把双刃剑:简单的异步任务很容易完成,而复杂的异步任务成为代码组织的一个苦差事。然而生成器允许您在执行过程中有效地暂停代码,因此它们开辟了许多与异步处理相关的可能性。
解决异步任务的传统方法是回调函数,这种方法能够很好地解决简单的业务,但是一旦业务过于复杂,这种方法往往使得嵌套多层代码造成回调地狱。得益于yield关键字可以中断挂起函数直到next方法的调用,我们可以不需要管理回调函数就能实现异步编程。
高级迭代器功能
传递参数到迭代器
在上述迭代器的案例代码中可以发现,迭代器的next方法其实可以接受参数。在生成器函数中,将参数传递给next()方法时,该参数将成为生成器内yield语句的值。此功能对于更高级的功能(如异步编程)非常重要。这是一个基本的例子:
1 | function * createIterator(){ |
上面代码中,我们在第一次调用next方法时(line 4),生成器函数内部开始执行(line 1),执行过程中由于赋值语句是从右往左执行的,一开始就遇到yield关键值,产出”{value: 1, done: flase}”,然后中止挂起函数等待下一次next方法调用,注意此时变量first还没有被赋值;当我们再次调用(line 5)next方法(值得注意的是,此次调用next方法时还传入了参数4),函数从上一次中止的地方(line 1 表达式右边)被唤醒继续执行,此时参数4会被当作yield语句的执行结果赋值给变量first,接着执行下一行(line 2)直到遇到yield,产出”{value: 6(4+2), done: false}”然后中止;继续我们第三次调用next(line 6),这次yield接受参数5赋给变量second,产出”{value: 8(5+3), done: false}”后再次中断挂起;最后一次调用next,函数被唤醒后发现没有语句可以执行,且函数没有return语句(即默认返回undefined),所以默认返回”{value: undefined, done: true}”;
注意,首次调用next方法传入的参数是无效的,会被函数完全忽略掉。即上面(line 4)代码中的next函数中传入任何参数都是无效的。
在生成器中抛出和捕获错误
我们通过迭代器的next方法不仅能传递数据,而且还能传递Error信息。迭代器可以选择实现一个throw()方法,该方法指示迭代器在恢复时抛出错误。
function * createIterator(){
let first = yield 1; // line 1
let seccond;
try{
second = yield first + 2;
}catch(error){
second = 6;
}
yield second + 3; // line 3
}
let iterator = createIterator();
console.log(iterator.next());
// "{ value: 1, done: false }"
console.log(iterator.next(4));
// "{ value: 6, done: false }"
console.log(iterator.throw(new Error("Boom")));
// "{ value: 9, done: false }"
console.log(iterator.next());
// "{ value: undefined, done: true
在生成器函数中使用return语句
我们说过生成器就是一个函数,我们可以像普通函数那样在生成器中使用return语句,它默认会将返回值中的done属性设置为true,也就意味着你可以通过使用return语句提前退出函数或为定义最后一次调用next方法的产出值。
function * createIterator(){
let first = yield 1; // line 1
let seccond = yield first + 2; // line 2
return 1000;
yield second + 3; // line 3
}
上面代码中,第三次调用next方法后,产出值为”{value: 1000, done: true}”,并且函数会退出。
特殊地,ES6中的扩展运算符和for-of操作符会忽略掉任何return语句定义的值。因为它们首先检查对象中的done属性是否为true,一旦done为true便不会在读取value属性。