首页 阅读DAY9 JavaScript高级程序设计 7章下 迭代器与生成器
文章
取消

阅读DAY9 JavaScript高级程序设计 7章下 迭代器与生成器

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

迭代器主要提供了针对不同对象的统一的数据遍历方式,但生成器在此基础之上,还提供了对执行流程时机的精细控制,是异步编程的基石。生成器可以说是迭代器的语法糖,async/await语法又是建立在生成器概念之上的语法糖。

生成器:

生成器是一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力。这种能力具有深远的影响,比如使用生成器可以自定义迭代器和实现协程。

生成器是一个名不副实但功能强大的特性,名字说的是生成值,实际还能控制执行流程。在 ES6 之后的 JavaScript 开发中,后者可能比前者更重要。

生成器常见用途:

场景用法
异步流程控制async + yield 模拟 async/await(理解原理用)
懒求值 / 无限序列function* 按需产出,不一次性生成全部数据
迭代协议适配让自定义数据结构支持 for...of
状态机用生成器表达有限状态机,每个 yield 是一个状态

生成器基础:

生成器的形式是一个函数,函数名称前面加一个星号(*)表示它是一个生成器。只要是可以定义函数的地方,就可以定义生成器。

定义一个生成器对象时,已经自动实现了可迭代协议和迭代器协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 生成器函数声明
function* generatorFn() {}

// 生成器函数表达式
let generatorFn = function* () {}

// 作为对象字面量方法的生成器函数
let foo = {
  * generatorFn() {}
}

// 作为类实例方法的生成器函数
class Foo {
  * generatorFn() {}
}

// 作为类静态方法的生成器函数
class Bar {
  static * generatorFn() {}
}

箭头函数不能用来定义生成器函数。

标识生成器函数的星号不受两侧空格的影响:

1
2
3
4
5
6
7
8
9
10
// 等价的生成器函数
function* generatorFnA() {}
function *generatorFnB() {}
function * generatorFnC() {}

// 等价的生成器方法
class Foo {
  *generatorFnD() {}
  * generatorFnE() {}
}

调用生成器函数会产生一个生成器对象。生成器对象一开始处于暂停执行(suspended)的状态。与迭代器相似,生成器对象也实现了Iterator接口,因此具有next()方法。调用这个方法会让生成器开始或恢复执行。

1
2
3
4
5
6
function* generatorFn() {}

const g = generatorFn();

console.log(g);       // generatorFn {<suspended>}
console.log(g.next);  // f next() { [native code] }

next()方法的返回值与迭代器保持一致,有一个done属性和一个value属性。函数体为空的生成器函数中间不会停留,调用一次next()就会让生成器到达done: true状态。

1
2
3
4
5
6
function* generatorFn() {}

let generatorObject = generatorFn();

console.log(generatorObject);         // generatorFn {<suspended>}
console.log(generatorObject.next());  // { done: true, value: undefined }

value属性是生成器函数的返回值,默认值为undefined,可以通过生成器函数的返回值指定:

1
2
3
4
5
6
7
8
function* generatorFn() {
  return 'foo';
}

let generatorObject = generatorFn();

console.log(generatorObject);         // generatorFn {<suspended>}
console.log(generatorObject.next());  // { done: true, value: 'foo' }

生成器函数只会在初次调用next()方法后开始执行,如下所示:

1
2
3
4
5
6
7
8
function* generatorFn() {
  console.log('foobar');
}

// 初次调用生成器函数并不会打印日志
let generatorObject = generatorFn();

generatorObject.next();  // foobar

生成器对象实现了Iterable接口,它们默认的迭代器指向自身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* generatorFn() {}

console.log(generatorFn);
// f* generatorFn() {}
console.log(generatorFn()[Symbol.iterator]);
// f [Symbol.iterator]() {native code}

console.log(generatorFn());
// generatorFn {<suspended>}
console.log(generatorFn()[Symbol.iterator]());
// generatorFn {<suspended>}

const g = generatorFn();

console.log(g === g[Symbol.iterator]());
// true

通过yield中断执行:

虽然内容不老少,但是最重要的只是yield基本用法和next()传值,其他了解即可。

yield关键字可以让生成器停止和开始执行,也是生成器最有用的特性。生成器函数在遇到yield关键字之前会正常执行。遇到这个关键字后,执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用next()方法来恢复执行

1
2
3
4
5
6
7
8
function* generatorFn() {
  yield;
}

let generatorObject = generatorFn();

console.log(generatorObject.next());  // { done: false, value: undefined }
console.log(generatorObject.next());  // { done: true, value: undefined }

此时的yield关键字有点儿像函数的中间返回语句,它生成的值会出现在next()方法返回的对象里。通过yield关键字退出的生成器函数会处于done: false状态;通过return关键字退出的生成器函数会处于done: true状态

1
2
3
4
5
6
7
8
9
10
11
function* generatorFn() {
  yield 'foo';
  yield 'bar';
  return 'baz';
}

let generatorObject = generatorFn();

console.log(generatorObject.next());  // { done: false, value: 'foo' }
console.log(generatorObject.next());  // { done: false, value: 'bar' }
console.log(generatorObject.next());  // { done: true, value: 'baz' }

生成器函数内部的执行流程会针对每个生成器对象区分作用域。在一个生成器对象上调用next()不会影响其他生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* generatorFn() {
  yield 'foo';
  yield 'bar';
  return 'baz';
}

let generatorObject1 = generatorFn();
let generatorObject2 = generatorFn();


console.log(generatorObject1.next()); // { done: false, value: 'foo' }
console.log(generatorObject2.next()); // { done: false, value: 'foo' }
console.log(generatorObject2.next()); // { done: false, value: 'bar' }
console.log(generatorObject1.next()); // { done: false, value: 'bar' }

yield关键字只能在生成器函数内部使用,用在其他地方会抛出错误。类似函数的return关键字,yield关键字必须直接位于生成器函数定义中,出现在嵌套的非生成器函数中会抛出语法错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 有效
function* validGeneratorFn() {
  yield;
}

// 无效
function* invalidGeneratorFnA() {
  function a() {
    yield;
  }
}

// 无效
function* invalidGeneratorFnB() {
  const b = () => {
    yield;
  }
}

// 无效
function* invalidGeneratorFnC() {
  (() => {
    yield;
  })();
}

生成器对象作为可迭代对象:

在生成器对象上显式调用next()方法的用处并不大。其实,如果把生成器对象当成可迭代对象,那么使用起来会更方便(利用一些语法糖):

原先对象是不能够像数组或者映射那样很方便地进行迭代的,但通过迭代器协议,我们可以将对象也进行迭代;在引入生成器之后,我们更可以通过生成器函数非常便捷地自定义一个可迭代对象,并利用一些语法糖来很方便地迭代这个对象。

1
2
3
4
5
6
7
8
9
10
11
12
function* generatorFn() {
  yield 1;
  yield 2;
  yield 3;
}

for (const x of generatorFn()) {
  console.log(x);
}
// 1
// 2
// 3

在需要自定义迭代对象时,这样使用生成器对象会特别有用。比如,我们需要定义一个可迭代对象,而它会产生一个迭代器,这个迭代器会执行指定的次数。使用生成器,可以通过一个简单的循环来实现:

1
2
3
4
5
6
7
8
9
10
11
12
function* nTimes(n) {
  while(n--) {
    yield;
  }
}

for (let _ of nTimes(3)) {
  console.log('foo');
}
// foo
// foo
// foo

传给生成器的函数可以控制迭代循环的次数。在n为0时,while条件为假,循环退出,生成器函数返回。

使用yield实现输入和输出:

除了可以作为函数的中间返回语句使用,yield关键字还可以作为函数的中间参数使用。上一次让生成器函数暂停的yield关键字会接收到传给next()方法的第一个值。对于yield的利用是要给予next()方法的。这里有个地方不太好理解:第一次调用next()传入的值不会被使用,因为这一次调用是为了开始执行生成器函数:

1
2
3
4
5
6
7
8
9
10
11
function* generatorFn(initial) {
  console.log(initial);
  console.log(yield);
  console.log(yield);
}

let generatorObject = generatorFn('foo');

generatorObject.next('bar');  // foo
generatorObject.next('baz');  // baz
generatorObject.next('qux');  // qux

具体过程是这样的:

第一步:创建生成器对象

1
let generatorObject = generatorFn('foo');

此时函数体还没开始执行,'foo' 只是传给了生成器函数的参数 initial,还没用到。生成器处于 suspended 状态。

第二步:第一次 next()

1
generatorObject.next('bar'); // 输出: foo

调用 next('bar') 后,函数体开始执行:

  1. 执行 console.log(initial) → 输出 foo
  2. 遇到第一个 yield → 暂停,返回 { done: false, value: undefined }

'bar' 被丢弃了。 因为第一次 next() 的作用是”启动”生成器,此时还没有任何 yield 在”等待接收值”,所以传入的参数无处安放。

第三步:第二次 next()

1
generatorObject.next('baz'); // 输出: baz

这一次 next('baz') 让生成器从暂停处恢复,'baz' 成了第一个 yield 表达式的返回值

  1. yield 接收到 'baz',相当于 yield 的值变成了 'baz'
  2. 执行 console.log(yield) → 输出 baz
  3. 遇到第二个 yield → 再次暂停

第四步:第三次 next()

1
generatorObject.next('qux'); // 输出: qux

同理,'qux' 成为第二个 yield 的返回值:

  1. yield 接收到 'qux'
  2. 执行 console.log(yield) → 输出 qux
  3. 函数结束,返回 { done: true, value: undefined }

next() 传的值永远是给上一个 yield。 第一次 next() 时还没有上一个 yield,所以值被丢弃。从第二次开始,每次 next(值) 都是把值塞回上次暂停的 yield 处。

yield关键字可以同时用于输入和输出,如下例所示:

1
2
3
4
5
6
7
8
function* generatorFn() {
  return yield 'foo';
}

let generatorObject = generatorFn();

console.log(generatorObject.next());       // { done: false, value: 'foo' }
console.log(generatorObject.next('bar'));  // { done: true, value: 'bar' }

因为函数必须对整个表达式求值才能确定要返回的值,所以它在遇到yield关键字时暂停执行并计算出要产生的值:”foo”。下一次调用next()传入了 “bar”,作为交给同一个yield的值。然后这个值被确定为本次生成器函数要返回的值。

yield关键字并非只能使用一次。比如,以下代码就定义了一个无穷计数生成器函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* generatorFn() {
  for (let i = 0;;++i) {
    yield i;
  }
}

let generatorObject = generatorFn();

console.log(generatorObject.next().value);  // 0
console.log(generatorObject.next().value);  // 1
console.log(generatorObject.next().value);  // 2
console.log(generatorObject.next().value);  // 3
console.log(generatorObject.next().value);  // 4
console.log(generatorObject.next().value);  // 5
...

假设我们想定义一个生成器函数,它会根据配置的值迭代相应次数并产生迭代的索引。初始化一个新数组可以实现这个需求,但不用数组也可以实现同样的行为:

1
2
3
4
5
6
7
8
9
10
11
12
function* nTimes(n) {
  for (let i = 0; i < n; ++i) {
    yield i;
  }
}

for (let x of nTimes(3)) {
  console.log(x);
}
// 0
// 1
// 2

另外,使用while循环也可以,而且代码稍微简洁一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
function* nTimes(n) {
  let i = 0;
  while(n--) {
    yield i++;
  }
}

for (let x of nTimes(3)) {
  console.log(x);
}
// 0
// 1
// 2

像这样使用生成器也可以实现范围和填充数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function* range(start, end) {
  while(end > start) {
    yield start++;
  }
}

for (const x of range(4, 7)) {
  console.log(x);
}
// 4
// 5
// 6

function* zeroes(n) {
  while(n--) {
    yield 0;
  }
}

console.log(Array.from(zeroes(8))); // [0, 0, 0, 0, 0, 0, 0, 0]

产生可迭代对象:

yield*这块开头这部分理解了就行。

yield*:

使用星号增强 yield 的行为,可以让它迭代一个可迭代对象,一次产出一个值

1
2
3
4
5
6
7
8
function* generatorFn() {
 yield* [1, 2, 3];
}

for (const x of generatorFn()) {
 console.log(x);
}
// 1 2 3

yield* 本质上就是把可迭代对象”展平”为一连串单独产出的值,和用 for...of 循环逐个 yield 完全等价:

1
2
3
4
5
6
7
8
9
10
// 这两种写法行为一致
function* generatorFnA() {
 for (const x of [1, 2, 3]) {
 yield x;
 }
}

function* generatorFnB() {
 yield* [1, 2, 3];
}

星号两侧有无空格不影响行为:yield*yield *yield * 都是等价的。

yield* 的返回值:

yield* 表达式本身也有一个值,它等于关联的迭代器返回 done: true 时的 value 属性。

普通可迭代对象(如数组):迭代器耗尽时 valueundefined

1
2
3
4
5
6
7
8
9
10
11
function* generatorFn() {
 console.log('iter value:', yield* [1, 2, 3]);
}

for (const x of generatorFn()) {
 console.log('value:', x);
}
// value: 1
// value: 2
// value: 3
// iter value: undefined

生成器函数value 就是生成器函数的 return 值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function* innerGeneratorFn() {
 yield 'foo';
 return 'bar';
}

function* outerGeneratorFn() {
 console.log('iter value:', yield* innerGeneratorFn());
}

for (const x of outerGeneratorFn()) {
 console.log('value:', x);
}
// value: foo
// iter value: bar

yield* 把可迭代对象”展平”逐个产出,它本身的值取决于被委托的迭代器最终返回什么。普通迭代器返回 undefined,生成器返回 return 的值。

使用yield* 实现递归算法:

基本不用这玩意递归,了解即可。

yield* 非常有用的场景是实现递归操作,此时生成器可以产生自身。看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function* nTimes(n) {
  if (n > 0) {
    yield* nTimes(n - 1);
    yield n - 1;
  }
}

for (const x of nTimes(3)) {
  console.log(x);
}
// 0
// 1
// 2

在这个例子中,每个生成器首先都会从新创建的生成器对象产出每个值,然后再产出一个整数。结果就是生成器函数会递归地减少计数器值,并实例化另一个生成器对象。从最顶层来看,这就相当于创建一个可迭代对象并返回递增的整数。

使用递归生成器结构和yield* 可以优雅地表达递归算法。下面是一个图的实现,用于生成一个随机的双向图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class Node {
  constructor(id) {
    this.id = id;
    this.neighbors = new Set();
  }

  connect(node) {
    if (node !== this) {
      this.neighbors.add(node);
      node.neighbors.add(this);
    }
  }
}

class RandomGraph {
  constructor(size) {
    this.nodes = new Set();

    // 创建节点
    for (let i = 0; i < size; ++i) {
      this.nodes.add(new Node(i));
    }

    // 随机连接节点
    const threshold = 1 / size;
    for (const x of this.nodes) {
      for (const y of this.nodes) {
        if (Math.random() < threshold) {
          x.connect(y);
        }
      }
    }
  }

  // 这个方法仅用于调试
  print() {
    for (const node of this.nodes) {
      const ids = [...node.neighbors]
                      .map((n) => n.id)
                      .join(',');

      console.log(`${node.id}: ${ids}`);
    }
  }
}

const g = new RandomGraph(6);

g.print();
// 示例输出
// 0: 2,3,5
// 1: 2,3,4,5
// 2: 1,3
// 3: 0,1,2,4
// 4: 2,3
// 5: 0,4

图数据结构非常适合递归遍历,而递归生成器恰好非常合用。为此,生成器函数必须接收一个可迭代对象,产出该对象中的每一个值,并且对每个值进行递归。这个实现可以用来测试某个图是否连通,即是否没有不可到达的节点。只要从一个节点开始,然后尽力访问每个节点就可以了。结果就得到了一个非常简洁的深度优先遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Node {
  constructor(id) {
    ...
  }

  connect(node) {
    ...
  }
}

class RandomGraph {
  constructor(size) {
    ...
  }

  print() {
    ...
  }

  isConnected() {
    const visitedNodes = new Set();

    function* traverse(nodes) {
      for (const node of nodes) {
        if (!visitedNodes.has(node)) {
          yield node;
          yield* traverse(node.neighbors);
        }
      }
    }

    // 取得集合中的第一个节点
    const firstNode = this.nodes[Symbol.iterator]().next().value;

    // 使用递归生成器迭代每个节点
    for (const node of traverse([firstNode])) {
      visitedNodes.add(node);
    }

    return visitedNodes.size === this.nodes.size;
  }
}

生成器作为默认迭代器:

因为生成器对象实现了Iterable接口,而且生成器函数和默认迭代器被调用之后都产生迭代器,所以生成器格外适合作为默认迭代器。下面是一个简单的例子,这个类的默认迭代器可以用一行代码产出类的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Foo {
  constructor() {
    this.values = [1, 2, 3];
  }
  * [Symbol.iterator]() {
    yield* this.values;
  }
}

const f = new Foo();
for (const x of f) {
  console.log(x);
}
// 1
// 2
// 3

这里,for-of循环调用了默认迭代器(它恰好又是一个生成器函数)并产生了一个生成器对象。这个生成器对象是可迭代的,所以完全可以在迭代中使用。

提前终止生成器:

也是不太重要,不用记细节,知道生成器可以提前关闭就行。

与迭代器类似,生成器也支持”可关闭”的概念。生成器对象有三个方法:next()return()throw(),其中 return()throw() 都可以强制生成器进入关闭状态。

return():强制关闭

return() 会强制生成器进入关闭状态,提供的值成为最终 IteratorResult 的 value

1
2
3
4
5
6
7
8
function* generatorFn() {
 for (const x of [1, 2, 3]) { yield x; }
}

const g = generatorFn();
g.next(); // { done: false, value: 1 }
g.return(4); // { done: true, value: 4 }
g; // generatorFn {<closed>}

与迭代器不同,所有生成器对象都有 return() 方法,且一旦关闭就无法恢复,后续 next() 始终返回 { done: true, value: undefined }

1
2
g.next(); // { done: true, value: undefined }
g.next(); // { done: true, value: undefined }

for...of 等内置结构会忽略 done: true 的返回值,因此 return() 提供的值不会被消费。

throw():注入错误

throw() 会在生成器暂停时注入一个错误。如果错误未被处理,生成器就会关闭:

1
2
3
4
5
6
7
8
9
10
11
function* generatorFn() {
 for (const x of [1, 2, 3]) { yield x; }
}

const g = generatorFn();
try {
 g.throw('foo');
} catch (e) {
 console.log(e); // foo
}
g; // generatorFn {<closed>}

如果生成器函数内部处理了错误,生成器不会关闭,还可以恢复执行。但错误处理会跳过对应的 yield,因此会丢一个值:

1
2
3
4
5
6
7
8
9
10
11
12
function* generatorFn() {
 for (const x of [1, 2, 3]) {
 try {
 yield x;
 } catch(e) {}
 }
}

const g = generatorFn();
g.next(); // { done: false, value: 1 }
g.throw('foo'); // 错误在 yield 处抛出,被 catch 捕获,跳过值 2
g.next(); // { done: false, value: 3 } ← 直接跳到了 3

如果生成器还没开始执行就调用 throw(),错误不会在函数内部被捕获,因为相当于在函数块外部抛出了错误。

对比总结:

 return()throw()
作用强制关闭,返回指定值在暂停处注入错误
错误处理内部 catch 可恢复,未处理则关闭
关闭后能否恢复不可恢复取决于是否 catch
值的跳过不跳过,直接结束内部 catch 时会跳过当前 yield

异步迭代:

同步迭代器的 next() 每次调用都直接返回 { value, done },这意味着确定值所需的所有计算和资源获取必须在调用时同步完成。如果值依赖异步操作,主线程就会被阻塞等待。

异步迭代器解决了这个问题:next() 每次调用返回一个期约(Promise),该期约会解决为 { value, done } 对象。这样执行线程可以先释放,等异步操作完成后再恢复:

1
2
同步迭代器:next() → { value, done } ← 必须立即拿到值
异步迭代器:next() → Promise<{ value, done }> ← 值准备好了再解决

异步迭代 = 迭代器协议 + 异步控制。把同步的 next() 返回值包一层 Promise,就能在不阻塞主线程的情况下迭代异步数据源。

创建和使用异步迭代器:

以同步迭代器为参照,很容易理解异步迭代器。同步迭代器的一个类似如下,一个 Emitter 类,用 Symbol.iterator 定义同步生成器,for...of 消费:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Emitter {
    constructor(max) {
        this.max = max;
        this.syncIdx = 0;
    }
    
    *[Symbol.iterator]() {
        while (this.syncIdx < this.max) {
            yield this.syncIdx++;
        }
    }
}

const emitter = new Emitter(5);
for (const x of emitter) { 
    console.log(x); 
}
// 0 1 2 3 4

这个例子可行,是因为下一个值能立即产出。如果不想在确定下一个值时阻塞主线程,就需要异步生成器产出由期约封装的值。

ECMAScript 为此定义了 Symbol.asyncIterator,用于标识产出期约的生成器函数;配套的 for await...of 专门消费异步迭代器。将 Emitter 扩展为同时支持同步和异步迭代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Emitter {
    constructor(max) {
        this.max = max;
        this.syncIdx = 0;
        this.asyncIdx = 0;
    }

    *[Symbol.iterator]() {
        while (this.syncIdx < this.max) {
            yield this.syncIdx++;
        }
    }

    async *[Symbol.asyncIterator]() {
        while (this.asyncIdx < this.max) {
            yield new Promise((resolve) => resolve(this.asyncIdx++));
        }
    }
}

const emitter = new Emitter(5);

// 同步迭代
for (const x of emitter) { 
    console.log(x); 
}
// 0 1 2 3 4

// 异步迭代
async function asyncCount() {
    for await (const x of emitter) { 
        console.log(x); 
    }
}
asyncCount();
// 0 1 2 3 4

核心区别:同步生成器用 *[Symbol.iterator](),直接 yield 值;异步生成器用 async *[Symbol.asyncIterator]()yield 的是 Promise。

for await...of 可以消费同步可迭代对象,会自动将值包装为期约处理,这体现了它的向下兼容:

1
2
3
4
5
6
7
8
// 用 for-await-of 消费同步迭代器,正常工作
async function asyncIteratorSyncCount() {
    for await (const x of emitter[Symbol.iterator]()) {
        console.log(x);
    }
}
asyncIteratorSyncCount();
// 0 1 2 3 4

但反过来不行,for...of 不能消费异步迭代器,因为异步迭代器没有实现 Symbol.iterator

1
2
3
4
5
6
7
8
// 用 for...of 消费异步迭代器,报错
function syncIteratorAsyncCount() {
    for (const x of emitter[Symbol.asyncIterator]()) {
        console.log(x);
    }
}
syncIteratorAsyncCount();
// TypeError: asyncCounter is not iterable

Symbol.asyncIterator 不会改变生成器函数的行为。异步生成器的行为由 async * 定义,它产出期约。Symbol.asyncIterator 只是一个标识,向 for await...of 等外部结构表明”这个迭代器返回的是一系列期约对象”。

理解异步迭代器队列:

前面的例子中,期约都是立即解决的,看起来和同步迭代器没区别。但实际场景中,迭代器产出的期约可能各自需要长短不等的时间来解决,期约的解决顺序可能和产出顺序不一致。

异步迭代器应该尽可能模拟同步迭代的行为,每次迭代的代码按顺序执行。为此,异步迭代器内部维护了一个回调队列,保证早期值的处理程序始终在后期值之前完成执行,即使后期值先解决。

用随机延迟模拟网络请求来验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Emitter {
 constructor(max) {
 this.max = max;
 this.syncIdx = 0;
 this.asyncIdx = 0;
 }

 *[Symbol.iterator]() {
 while (this.syncIdx < this.max) { yield this.syncIdx++; }
 }

 async *[Symbol.asyncIterator]() {
 while (this.asyncIdx < this.max) {
 yield new Promise((resolve) => {
 setTimeout(() => {
 resolve(this.asyncIdx++);
 }, Math.floor(Math.random() * 1000)); // 随机0-1000ms延迟
 });
 }
 }
}

const emitter = new Emitter(5);

// 同步迭代——立即输出
for (const x of emitter) { console.log(x); }
// 0 1 2 3 4

// 异步迭代——每个期约解决时间随机,但输出依然按顺序
async function asyncCount() {
 for await (const x of emitter) { console.log(x); }
}
asyncCount();
// 0 1 2 3 4 (虽然每个值等待时间随机,但输出顺序不变)

尽管每个期约的解决时间随机(可能第3个比第1个先解决),输出依然是 0、1、2、3、4 按序排列。这就是回调队列的功劳——它强制按迭代顺序处理,不会让先解决的后期值”插队”。

异步迭代器内部的回调队列保证了产出顺序决定处理顺序,不受期约实际解决时间的影响。

异步迭代器如何处理拒绝:

把异步迭代器和期约组合到一起,就必须考虑迭代器返回的期约被拒绝时该怎么办。由于异步迭代的设计坚持按次序完成的原则,跳过循环中被拒绝的期约就不合理了。为此,被拒绝的期约将强制迭代器退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Emitter {
  constructor(max) {
    this.max = max;
    this.asyncIdx = 0;
  }

  async *[Symbol.asyncIterator]() {
    while (this.asyncIdx < this.max) {
        if (this.asyncIdx < 3) {
         yield this.asyncIdx++;
        } else {
          throw 'Exited loop';
        }
    }
  }
}

const emitter = new Emitter(5);

async function asyncCount() {
  const asyncCounter = emitter[Symbol.asyncIterator]();

  for await (const x of asyncCounter) {
    console.log(x);
  }
}

asyncCount();
// 0
// 1
// 2
// Uncaught (in promise) Exited loop

使用next()手动异步迭代:

for await...of 提供了两个有用的特性:异步迭代器队列保证顺序执行,以及隐藏期约结构。但这也隐藏了底层行为。

异步迭代器同样遵守迭代器协议,可以手动调用 next() 遍历。此时 next() 返回的是最终会解决为 { value, done } 的期约,需要自行用期约 API 获取值:

1
2
3
4
5
const emitter = new Emitter(5);
const asyncCounter = emitter[Symbol.asyncIterator]();

console.log(asyncCounter.next());
// { value: Promise, done: false }

手动调用的代价是需要自己处理期约,但好处是绕过了异步迭代器队列,你可以按任意顺序处理值,不再被强制按产出顺序等待。

顶级异步循环:

一般来说,异步行为(包括for-await-of循环)不能出现在异步函数外部。可是,我们偶尔也需要在这种情况下使用异步行为。此时可以借助异步IIFE达成目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Emitter {
  constructor(max) {
    this.max = max;
    this.asyncIdx = 0;
  }

  async *[Symbol.asyncIterator]() {
    while(this.asyncIdx < this.max) {
      yield new Promise((resolve) => resolve(this.asyncIdx++));
    }
  }
}

const emitter = new Emitter(5);

(async function() {
  const asyncCounter = emitter[Symbol.asyncIterator]();

  for await(const x of asyncCounter) {
    console.log(x);
  }
})();
// 0
// 1
// 2
// 3
// 4

实现可观测对象:

异步迭代器会在不导致计算成本的情况下耐心等待下一次迭代,这为实现可观测接口开辟了新的天地。从宏观来看,需要捕获事件、将其封装到期约中,然后将这些事件通过迭代器传递出去,从而让监听程序能够介入异步迭代器。当有事件发生时,异步迭代器中的下一个期约将借助该事件得以解决。

可观测对象已经超出本书的讨论范畴,因为这通常都是由第三方库来实现的。如果想了解更多,推荐大家看一看RxJS这个库。

一个简单的例子是捕获浏览器事件的可观测流。为此,需要一个期约队列,每个期约都对应一个事件。这个队列也会保持事件生成的顺序,而这也是该类问题的一个符合预期的特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Observable {
  constructor() {
    this.promiseQueue = [];

    // 保存队列中下一个期约的解决方法
    this.resolve = null;

    // 把最初的期约推入队列,该期约
    // 将通过第一个观测到的事件解决
    this.enqueue();
  }

  // 创建新期约,保存其解决方法
  // 然后将其推入队列
  enqueue() {
    this.promiseQueue.push(
      new Promise((resolve) => this.resolve = resolve));
  }

  // 取出位于队列前端的期约
  // 并返回这个期约
  dequeue() {
    return this.promiseQueue.shift();
  }
}

要使用这个期约队列,还要在这个类上定义一个异步生成器方法。这个生成器应该可以处理任何类型的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Observable {
  constructor() {
    this.promiseQueue = [];

    // 保存队列中下一个期约的解决方法
    this.resolve = null;

    // 把最初的期约推入队列,该期约
    // 将通过第一个观测到的事件解决
    this.enqueue();
  }

  // 创建新期约,保存其解决方法
  // 然后将其推入队列
  enqueue() {
    this.promiseQueue.push(
      new Promise((resolve) => this.resolve = resolve));
  }

  // 取出位于队列前端的期约
  // 并返回这个期约
  dequeue() {
    return this.promiseQueue.shift();
  }

  async *fromEvent (element, eventType) {
    // 当事件生成时,解决队列前端的期约
    // 传入事件对象,同时入队另一个期约
    element.addEventListener(eventType, (event) => {
      this.resolve(event);
      this.enqueue();
    });

    // 每个被解决的队列前端的期约
    // 都会向异步迭代器产出事件对象
    while (1) {
      yield await this.dequeue();
    }
  }
}

定义好这个类,接下来实现对DOM元素的观测就很容易了。假设网页中有一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Observable {
  constructor() {
    this.promiseQueue = [];

    // 保存队列中下一个期约的解决方法
    this.resolve = null;

    // 把最初的期约推入队列,该期约
    // 将通过第一个观测到的事件解决
    this.enqueue();
  }

  // 创建新期约,保存其解决方法
  // 然后将其推入队列
  enqueue() {
    this.promiseQueue.push(
      new Promise((resolve) => this.resolve = resolve));
  }

  // 取出位于队列前端的期约
  // 并返回这个期约
  dequeue() {
    return this.promiseQueue.shift();
  }

  async *fromEvent (element, eventType) {
    // 当事件生成时,解决队列前端的期约
    // 传入事件对象,同时入队另一个期约
    element.addEventListener(eventType, (event) => {
      this.resolve(event);
      this.enqueue();
    });

    // 每个被解决的队列前端的期约
    // 都会向异步迭代器产出事件对象
    while (1) {
      yield await this.dequeue();
    }
  }
}

(async function() {
  const observable = new Observable();

  const button = document.querySelector('button');
  const mouseClickIterator = observable.fromEvent(button, 'click');

  for await (const clickEvent of mouseClickIterator) {
    console.log(clickEvent);
  }
})();
本文由作者按照 CC BY 4.0 进行授权

重启DAY8 验证二叉搜索树

阅读DAY10 JavaScript高级程序设计 8章上 对象、类与面向对象编程