Node.js 18中异步迭代器AsyncIterator怎么用?

文章导读
异步迭代器在 Node.js 18 里是处理流和自定义遍历的一种通用机制,但很多报错其实出在协议判断上。如果你用 for await 时遇到 TypeError,先不要改业务代码,先看目标对象有没有实现 Symbol.asyncIterator。
📋 目录
  1. 先确认对象是否真的支持异步迭代协议
  2. for await 消费流时,注意数据块的边界
  3. 错误处理要提前设计,try/catch 与 finally 配合
  4. 自定义异步迭代器,next() 和 return() 都要按协议实现
  5. 流状态和并发,两个容易忽略的边界条件
  6. 性能上做取舍,先观察再优化
A A

异步迭代器在 Node.js 18 里是处理流和自定义遍历的一种通用机制,但很多报错其实出在协议判断上。如果你用 for await 时遇到 TypeError,先不要改业务代码,先看目标对象有没有实现 Symbol.asyncIterator。

先确认对象是否真的支持异步迭代协议

一个对象是否为异步迭代器,要看它是否实现了 Symbol.asyncIterator 方法。这个方法返回一个异步迭代器,其 next() 方法返回 Promise。在 Node.js 18 中,for await...of 循环会自动调用这个 Symbol.asyncIterator 方法,如果对象不具备该方法则抛出 TypeError。判断时可以直接用 typeof obj[Symbol.asyncIterator] === 'function' 确认,或者先尝试 for await 看是否报错。

检查时可以写一个工具函数,返回布尔值。如果确认不支持,就要考虑替代方案:内置的 Readable 流可以直接用,普通数组可以用 Readable.from() 包装。

async function isAsyncIterable(obj) {
  return typeof obj?.[Symbol.asyncIterator] === 'function';
}

for await 消费流时,注意数据块的边界

在 Node.js 18 中,异步迭代器最常见的消费方式是 for await...of。比如对 fs.createReadStream 返回的 Readable 流,可以用 for await (const chunk of stream) 读取数据块。需要注意 for await 要求目标对象必须实现异步迭代协议,普通数组或可迭代对象不能直接使用,需要先使用 Readable.from() 将数组包装成异步可迭代的流。

这里的“数据块”不是业务上的记录,而是流内部缓冲区的片段。读取文件时,一个 chunk 可能是几 KB 到几十 KB,不要假设一次读取正好是一行 JSON 或一段配置。如果需要按完整行处理,可以先累积到 Buffer 里,再按分隔符切分。下面是一个通用的累积写法:

let fullBuffer = Buffer.alloc(0);
for await (const chunk of stream) {
  fullBuffer = Buffer.concat([fullBuffer, chunk]);
}

错误处理要提前设计,try/catch 与 finally 配合

无论用内置流还是自定义异步迭代器,错误处理都要提前想清楚。for await...of 内部会等待每个 next() 返回的 Promise 落定。如果某个 Promise 被 reject,循环会立即退出并抛出异常。捕获方式与同步循环基本一致,用 try/catch 包裹整个循环即可。但要注意两点:第一,已经消费过的数据不会回滚,异常发生时无法回到上一次成功的位置;第二,如果迭代器实现了 return() 方法,异常或 break 会触发它,但如果你没有实现,清理逻辑就不会执行。

Node.js 18中异步迭代器AsyncIterator怎么用?
try {
  for await (const chunk of stream) {
    process(chunk);
  }
} catch (err) {
  console.error('读取出错', err);
} finally {
  await stream.destroy();
}

自定义异步迭代器,next() 和 return() 都要按协议实现

自定义异步迭代器时,最核心的是 next() 必须返回 Promise。虽然在某些环境下返回带 then 的对象也能被识别,但协议要求就是 Promise,不要依赖这种兼容行为。另一个经常被忽略的是 return() 方法。这个方法用于提前退出时的清理,比如 for await 中执行了 break 或抛出异常,如果迭代器没有实现 return(),底层资源可能不会被释放。对文件句柄或数据库连接来说,这就是泄漏。

可以这样实现一个简单的异步迭代器:

function createAsyncIterable() {
  let i = 0;
  return {
    [Symbol.asyncIterator]() {
      return this;
    },
    async next() {
      if (i < 3) return { value: i++, done: false };
      return { done: true };
    },
    async return() {
      console.log('清理被调用');
      return { done: true };
    }
  };
}

流状态和并发,两个容易忽略的边界条件

使用流作为异步迭代器时,需要先确认流没有进入 flowing 模式。如果之前挂过 data 事件或用过 pipe(),流可能已经不在异步迭代状态。检查 stream.readableFlowing 属性,如果为 true,先调用 stream.pause()。另外,不要在同一个流上启动多个 for await 循环,多个循环会互相争抢数据块,导致数据读取不完整。如果必须并发,用 pipeline 或 PassThrough 做分流。

if (stream.readableFlowing === true) {
  stream.pause();
}

性能上做取舍,先观察再优化

在 Node.js 18 中,异步迭代器由于每次 next() 都会产生 Promise 微任务,高吞吐场景下可能比事件回调慢。但优势在于代码可读性和暂停控制。如果希望提高性能,可以在循环内使用 Buffer.concat 积累块,而不是逐块处理。同时,注意 destroy 流时要捕获 'error' 事件,否则未处理的错误可能导致进程崩溃。这里没有具体 benchmark 数据,实际差异取决于场景。

所以不要一上来就追求性能,先确认业务瓶颈是否在于逐块处理。如果只是小文件或低并发,for await 的便利性远大于性能差异。如果要优化,优先考虑在循环内累积 Buffer,减少处理次数,同时为 stream 绑定 error 监听。销毁时用下面的模式可以避免未处理异常:

stream.on('error', (err) => {
  console.error('流错误', err);
});
await stream.destroy();