Skip to content

迭代器与生成器:把一串值变成按需取用 ​

数组先把所有元素放好,迭代协议则允许消费者一次请求一个值。数据可以已经存在,也可以在请求时才计算。生成器进一步保存局部变量和暂停位置,让我们不必手写一整套状态机。

可迭代对象与迭代器分工不同 ​

可迭代对象提供 [Symbol.iterator](),调用后获得迭代器。迭代器提供 next(),返回 { value, done }。done: false 表示有一个可消费的值,done: true 表示结束。

js
const steps = {
  [Symbol.iterator]() {
    let n = 0;
    return {
      next() {
        return n < 3 ? { value: n++ * 2, done: false } : { done: true };
      }
    };
  }
};
console.log([...steps].join(',')); // 0,2,4
console.log([...steps].join(',')); // 0,2,4

每次调用迭代方法都会创建新的 n,所以可以重新遍历。同一个迭代器则通常是有状态的一次性游标;用完以后不会自动回到开头。内建迭代器往往还让自己的 Symbol.iterator 返回自身,因此同时符合两种协议;手写的上例 next 对象没有这个方法,能被上层 steps 使用,但不能直接展开自身。

for...of 消费值,for...in 枚举属性键。类数组对象只有 length 和索引不代表可迭代;普通对象也不会因为有若干属性就自动可迭代。解构和展开会消费迭代器,其中无限序列不能直接展开为数组。

接下来用生成器简写迭代器。function* 声明生成器函数,调用后先得到游标而不执行函数体;每次 next 才继续运行到下一个 yield。yield value 交出一项并暂停,下一次 next 从暂停处继续。对象方法前加 * 是同一种语法,所以 *[Symbol.iterator]() 表示“用生成器实现迭代入口”。先凭这个对应关系阅读双游标例子,下一节再展开如何把值送回暂停的表达式。

两个游标应该各自记住读到了哪里 ​

js
const collection = {
  *[Symbol.iterator]() { yield '甲'; yield '乙'; }
};
const left = collection[Symbol.iterator]();
const right = collection[Symbol.iterator]();
console.log(left.next().value, left.next().value); // 甲 乙
console.log(right.next().value); // 甲
console.log(left.next().done); // true

集合负责产生游标,游标负责保存进度。如果把进度变量放在 collection 外部并让所有游标共享,同一个集合的嵌套循环就可能互相抢数据。相反,如果对象代表不可重放的实时消息队列,那么“多个消费者共享一次性来源”可能正是设计意图。可重复性是对象的语义选择,不是有了 Symbol.iterator 就自动获得。

迭代结果必须是对象;next() 直接返回一个数字会使 for...of 抛 TypeError。done 通过真假值判断,而 value 可以是 undefined,所以不能写“value 为 undefined 就结束”。有些 API 省略最后结果的 value,这是结束状态允许省略值,不是协议将 undefined 当结束标记。

生成器把暂停位置交给语言保存 ​

js
function* promptNumber() {
  const first = yield '给我一个数字';
  yield first * 3;
  return '结束';
}
const it = promptNumber();
console.log(it.next()); // { value: '给我一个数字', done: false }
console.log(it.next(4)); // { value: 12, done: false }
console.log(it.next()); // { value: '结束', done: true }

调用生成器函数只创建迭代器,不立即执行正文。第一次 next 才开始运行。后续 next(4) 把 4 作为暂停的 yield 表达式结果送回函数。第一次 next 的参数没有对应的暂停表达式,所以被忽略。

return 的值位于 done 为 true 的结果里;普通 for...of 不把这个终止值作为循环项。yield 只能直接写在生成器正文,不能随意放进其内部普通回调。

throw 把暂停的表达式变成一次异常 ​

js
function* readWithFallback() {
  try {
    const answer = yield '等待读取';
    yield `读到:${answer}`;
  } catch (error) {
    yield `恢复:${error.message}`;
  }
}
const reader = readWithFallback();
console.log(reader.next().value); // 等待读取
console.log(reader.throw(new Error('来源断开')).value); // 恢复:来源断开
console.log(reader.next().done); // true

暂停的是赋值右侧 yield 表达式。调用 throw 后,这个位置以抛错方式继续,所以不会把一个错误对象当普通值赋给 answer,也不会执行下一行“读到”;控制流进入 catch,再暂停在新的 yield。生成器在异常后能否继续,取决于它是否捕获异常及后续代码,而不由调用方的 throw 方法名一锤定音。

提前退出时给资源一个收尾机会 ​

js
function* samples() {
  try {
    yield '第一份';
    yield '第二份';
  } finally {
    console.log('释放资源');
  }
}
for (const value of samples()) {
  console.log(value); // 第一份
  break;
}
// 接着输出 释放资源

循环体 break、return 或抛错导致提前退出时,会按协议尝试调用迭代器的 return()。生成器的 return 会推进收尾逻辑,因此 finally 能执行。直接手动调用几次 next 后把迭代器丢掉,没有这种自动关闭保证。迭代器自身 next 抛错也不能简单套用“所有失败都会调用 return”;资源拥有者应在自身实现里安排异常清理。

it.throw(error) 把异常送入当前暂停点,生成器可以用 try/catch 接住并继续 yield;没接住则结束并向调用者抛错。对尚未开始的生成器调用 return/throw,不会先执行正文再进入 finally。finally 中再次 yield 还可能让 return 得到 done: false,所以清理块通常不要设计成还要消费者继续驱动才能释放资源。

finally 仍可暂停,因此 return 并非强制立刻关机 ​

js
function* finishing() {
  try { yield '正文'; }
  finally { yield '清理进度'; }
}
const task = finishing();
task.next();
console.log(task.return('结束')); // { value: '清理进度', done: false }
console.log(task.next());         // { value: '结束', done: true }

return 相当于在暂停点请求以返回方式退出,途中仍执行 finally;finally 又 yield,就把退出过程暂停了。下一次 next 才继续完成原来的返回。for...of 提前退出时会调用 return,但不会无条件继续 next 到结束,因此不能把关键资源释放放在这个额外 yield 之后,等一个已经离开的消费者回来善后。

清理也抛错时,原来怎样退出很重要 ​

js
function makeSource() {
  return {
    [Symbol.iterator]() { return this; },
    next() { return { value: 1, done: false }; },
    return() { throw new Error('清理失败'); }
  };
}
try { for (const value of makeSource()) { break; } }
catch (error) { console.log(error.message); } // 清理失败
try { for (const value of makeSource()) { throw new Error('处理失败'); } }
catch (error) { console.log(error.message); } // 处理失败

break 本身不是异常,关闭时抛错会取代这个正常的提前退出。循环体原本已经抛错时,IteratorClose 优先保留那个原异常,不让 return 的新异常覆盖它。这与普通 finally 里 throw 覆盖原错误的规则不同:这里调用的是具有自己异常优先规则的迭代关闭算法。理解两者区别,才能在资源适配器里正确记录清理失败;不要假定最终抛出的那个错误包含了所有失败信息。

yield* 委托的是协议 ​

js
function* inner() { yield 2; return 8; }
function* outer() {
  const result = yield* inner();
  yield result + 1;
}
console.log([...outer()].join(',')); // 2,9

yield* 把 next 等交互委托给内部迭代器,并把内部终止值作为表达式结果。它不是仅把数组拼接起来,委托对象可以是任何符合迭代协议的对象。外层提前关闭时也会涉及内层 return;throw 的转发取决于内层是否提供 throw,缺少时有关闭与错误规则,因此不要把它简化成没有异常语义的 for 循环替换。

下面让被委托的迭代器只提供 next 与 return,观察缺少 throw 的情况:

js
const inner = {
  [Symbol.iterator]() { return this; },
  next() { return { value: 1, done: false }; },
  return() { console.log('关闭内层'); return { done: true }; }
};
function* relay() {
  try { yield* inner; }
  catch (error) { yield error.name; }
}
const outer = relay();
console.log(outer.next().value); // 1
console.log(outer.throw(new Error('注入错误')).value);
// 先输出 关闭内层,再输出 TypeError

外层想转发 throw,内层却没有这个入口,于是尝试 return 关闭内层,然后产生 TypeError。外层 catch 捕获的是协议无法继续委托导致的 TypeError,已经不是最初注入的 Error。若内层提供了 throw,它的返回结果会继续参与委托;这就是 yield* 超出简单“把每个值yield出去”的地方。

本节给出协议基础;现代 Iterator helpers 等补充见全书后续现代特性章节。协议本身与这些便利方法不是同一层,理解 next/return 后再读它们就不需要重新学习一套模型。

练习 ​

  1. 为什么展开 steps 两次都得到相同结果,而同一个生成器对象通常只能用一次?

  2. 生成器 return '结束' 的值为什么没有出现在 for...of 中?

  3. 手动 next 一次以后不再使用生成器,finally 一定执行吗?如果 finally 又有 yield,一次 return 能否保证它全部执行完成?

  4. 实现生成器 select(source, predicate),只交付满足条件的值;消费者提前 break 后,源生成器的 finally 也要运行。用带计数器的源验证:找到首个偶数便停止时不计算后面的值。再消费完整序列,确认没有命中的值不会交付。

查看答案

用心记录每一步成长