开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。
红宝书《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') 后,函数体开始执行:
- 执行
console.log(initial)→ 输出foo - 遇到第一个
yield→ 暂停,返回{ done: false, value: undefined }
'bar' 被丢弃了。 因为第一次 next() 的作用是”启动”生成器,此时还没有任何 yield 在”等待接收值”,所以传入的参数无处安放。
第三步:第二次 next()
1
generatorObject.next('baz'); // 输出: baz
这一次 next('baz') 让生成器从暂停处恢复,'baz' 成了第一个 yield 表达式的返回值:
yield接收到'baz',相当于yield的值变成了'baz'- 执行
console.log(yield)→ 输出baz - 遇到第二个
yield→ 再次暂停
第四步:第三次 next()
1
generatorObject.next('qux'); // 输出: qux
同理,'qux' 成为第二个 yield 的返回值:
yield接收到'qux'- 执行
console.log(yield)→ 输出qux - 函数结束,返回
{ 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 属性。
普通可迭代对象(如数组):迭代器耗尽时 value 为 undefined:
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);
}
})();