首页 / 文章 / JavaScript生成器详解:可暂停的函数与延迟迭代

JavaScript生成器详解:可暂停的函数与延迟迭代

了解 JavaScript 生成器函数如何暂停和恢复执行、实现迭代器协议,以及如何实现延迟处理、节省内存的数据流传输。

1084 词

到目前为止你编写的每一个函数都遵循一条规则:调用它后,它会从头执行到尾,只返回一次值,然后就结束了。生成器却悄悄打破了这一规则,而这种打破在实践中其实非常重要:生成器函数可以在执行过程中暂停,将值传回调用它的地方,之后又能从确切的断点继续执行,就好像中间没有经过任何时间一样。

基本机制

你使用function*来声明生成器,它不使用return,而是通过yield一次传递一个值:

function* countUp() {
  console.log("starting");
  yield 1;
  console.log("resumed after first yield");
  yield 2;
  console.log("resumed after second yield");
  yield 3;
  console.log("done");
}
const counter = countUp();
console.log(counter.next()); // "starting" logs, then { value: 1, done: false }
console.log(counter.next()); // "resumed after first yield" logs, then { value: 2, done: false }
console.log(counter.next()); // "resumed after second yield" logs, then { value: 3, done: false }
console.log(counter.next()); // "done" logs, then { value: undefined, done: true }

调用 countUp() 并不会执行该函数的任何代码体。它返回的是一个生成器对象,这是一种尚未开始运行的休眠结构。后续每次调用 .next() 都会从上次停止的位置继续执行,一直运行到下一个 yield 语句,然后再暂停,并返回刚刚生成的值。函数的所有内部状态、变量、循环中的位置以及其它所有信息都会在这些暂停期间保持完整,就好像函数从未被中断过一样。

为何这与直接返回数组有本质区别

一个自然的疑问是:为何不直接构建数组再返回呢?这正是生成器发挥作用的地方,因为它们以延迟的方式逐项生成输出,仅在需要时才处理,而无需提前计算出全部结果。

function* readLargeFileLineByLine(filePath) {
  const fileHandle = fs.openSync(filePath, "r");
  let position = 0;
  let leftover = "";
  while (true) {
    const buffer = Buffer.alloc(1024);
    const bytesRead = fs.readSync(fileHandle, buffer, 0, 1024, position);
    if (bytesRead === 0) break;    position += bytesRead;
    const lines = (leftover + buffer.toString("utf8", 0, bytesRead)).split("\n");
    leftover = lines.pop();
    for (const line of lines) yield line;
  }
  fs.closeSync(fileHandle);
}for (const line of readLargeFileLineByLine("access.log")) {
  if (line.includes("500")) console.log(line);
  // only reads and holds one small chunk of the file in memory at a time
}

如果采用预先构建所有行数组的方式,那么对于数GB大小的日志文件,必须将其全部读入内存后才能查看其中任意一行,这正是流式处理方法旨在避免的问题。生成器则通过不同的机制解决这一问题:只有当有代码通过.next()请求某一行时才会对其进行计算;如果周围的for...of循环提前终止,比如在找到所需内容后便停止,生成器也不会继续计算后续的内容。

生成器实现了迭代器协议,这就是为什么 for...of 能正常使用的原因

for...of、数组解构以及展开运算符等构造方式都能作用于任何满足迭代器协议的对象,也就是说,只要是暴露了能返回 { value, done }next() 方法的对象即可。生成器对象会自动符合这一要求,无需用户做任何额外处理,这也解释了为何可以直接对生成器进行迭代而无需额外的代码配置:

function* pageThroughResults(fetchPage) {
  let page = 1;
  while (true) {
    const results = fetchPageSync(fetchPage, page);
    if (results.length === 0) return;
    yield* results; // delegates to another iterable, yielding each of its values
    page++;
  }
}

yield* 语法会将控制权交给另一个可迭代对象,并按顺序传递其每个值。正是通过这种方式,生成器能够将一系列分页的结果转换为一条连续的独立元素流,而使用该流的代码无需知晓背后存在分页处理。

双向通信:.next()不仅可以获取值,还可以发送值

生成器还有一个鲜为人知的特性:yield不仅用于输出值,它本身也是一种表达式,任何传递给后续.next()调用的值都会成为该表达式的结果。这意味着数据可以传回已暂停的函数中,而不仅仅是从其中传出。

function* priceNegotiation() {
  const offer1 = yield "What's your offer?";
  const offer2 = yield `I can't do ${offer1}, how about a counter?`;
  return `Final: ${offer2}`;
}
const negotiation = priceNegotiation();
console.log(negotiation.next().value);        // "What's your offer?"
console.log(negotiation.next(50).value);       // "I can't do 50, how about a counter?"
console.log(negotiation.next(80).value);       // "Final: 80"

每次调用 .next(value) 时,生成器都会被唤醒,此时处于暂停状态的 yield 行会使用你提供的 value 值进行计算。在普通函数设计中,能够暂停执行、等待调用者提供新输入后再继续处理该输入的功能实属罕见。早在 JavaScript 语言原生支持 async/await 之前,早期的异步库正是依靠这种机制来模拟相关功能:调度器会先解析一个承诺,然后通过 .next() 将其结果传递给生成器,并在每次出现 yield 时重复此过程,直到所有异步操作都完成。

为何这是值得了解的真正基础

生成器绝非可以安全忽视的句法异常现象。它们正是async/await最终所依赖的机制,因为“在此暂停执行,稍后带着某个值继续执行”正是等待承诺时所需的行为。它们也解释了为何for...of、展开语法以及解构操作能在数组、字符串、Map实例和自定义对象中保持一致的行为,因为生成器实现的迭代器协议与这些内置类型早已使用的协议完全相同。一旦你理解了其真正的思维模型——即一种能够在每个暂停点交换值的同时实现暂停与继续的功能、惰性求值、自定义迭代逻辑,以及async/await的内部运作机制——这些原本看似互不相关的特性就不再如此。实际上它们是一个整体。

在三种不同场景中均出现的底层模式。

相关阅读