JavaScript 迭代器助手实战:惰性管道、无限序列与异步分页流

有一类代码,写的时候觉得挺自然,回头看一眼总觉得不对劲。比如从接口拉一批用户,筛出活跃的,取前 50 个名字:

async function topActiveNames(baseUrl) {
  const all = [];
  let cursor = null;

  do {
    const url = new URL(baseUrl);
    if (cursor) url.searchParams.set('cursor', cursor);
    const page = await (await fetch(url)).json();
    all.push(...page.items);
    cursor = page.nextCursor;
  } while (cursor);

  return all.filter(u => u.active).map(u => u.name).slice(0, 50);
}

能跑,但有两个可以较真的地方。第一,只要 50 个名字,却把所有分页全拉完了——如果这个接口有一万条数据,那 99.5% 的网络请求和内存都是白花的。第二,「先收集、再过滤、再切片」这三步各自都产生了一整个中间数组。

要修的话,传统写法是手动维护一个计数器,在循环里判断、提前 return。逻辑能对,但循环体里塞进了三件不相干的事:分页、筛选、截断。揉在一起之后,想改「不要名字要邮箱」都得重新读一遍循环。

迭代器助手(Iterator Helpers)给了另一条路。它把数组那套 map / filter / slice 搬到了迭代器上,而且是惰性的。迭代器在语言里存在很久了,但一直只能靠 for...of 或者展开语法来消费,中间加工全靠手写循环。现在这条链子终于补齐了。

一、先把三个词分清楚:可迭代对象、迭代器、生成器

这三个词经常被混着说,但要讲清楚迭代器助手,边界必须先划出来。

可迭代对象(iterable)的特征是有一个 Symbol.iterator 方法。数组、字符串、Map、Set、NodeList 都是。

迭代器(iterator)是 Symbol.iterator 返回的那个东西,它有一个 next() 方法,每次调用返回 { value, done }。迭代器本身也应该带 Symbol.iterator 并且返回自己,这样它才能被 for...of 直接吃。

生成器(generator)是用 function* 写的函数,调用它得到一个生成器对象,这个对象既是迭代器也是可迭代对象。

迭代器助手挂在的是 Iterator.prototype 上。也就是说,只有迭代器能用这些方法,数组不能用。数组有自己那套 Array.prototype.map,两者是不同的东西,行为也不一样。

const arr = [1, 2, 3];
arr.map(x => x * 2);        // 数组的 map,立刻求值,返回新数组

const it = [1, 2, 3].values();
it.map(x => x * 2);         // 迭代器的 map,惰性,返回迭代器

而生成器对象天然带这些方法,因为它就在 Iterator.prototype 的原型链上。这是后面所有例子的基础。

二、惰性到底值不值钱

先从最直观的对比开始。假设有一个百万长度的数组,要取前 5 个偶数的平方:

const bigArray = Array.from({ length: 1_000_000 }, (_, i) => i);

// 数组写法:三次完整遍历,两个中间数组
const viaArray = bigArray
  .filter(n => n % 2 === 0)   // 走一百万次,产出 50 万个
  .map(n => n * n)            // 走五十万次,产出 50 万个
  .slice(0, 5);               // 从 50 万里切 5 个

// 迭代器写法:一次遍历,零中间数组
const viaIterator = Iterator.from(bigArray)
  .filter(n => n % 2 === 0)
  .map(n => n * n)
  .take(5)
  .toArray();

结果是同一个 [0, 4, 16, 36, 64]。但工作量差得远。

数组那版:filter 老老实实走完一百万个元素,map 老实走完五十万个,slice 再从五十万的结果里取五个。中间两个数组都是完整的、真实分配的。

迭代器那版:take(5) 拿到第 5 个结果之后就告诉上游「够了」,整个链条停止。filter 和 map 加起来只跑了十来个元素。而 filter 和 map 本身没有分配任何数组——它们只是把你的函数包起来,等着被拉取。

这就是「惰性求值」和「及早求值」的区别。数组的方法必须返回一个完整的数组,所以它必须把活干完。迭代器的助手返回的是一个「还什么都没做」的迭代器,真正的计算在你消费它的时候,一个元素一个元素地被拉出来。

这里有个非常容易搞混的点,也是我第一次用的时候踩到的。

function* source() {
  for (let i = 0; i < 5; i++) {
    console.log('produce', i);
    yield i;
  }
}

const pipeline = source()
  .map(n => n * 10)
  .filter(n => n > 10);

console.log('pipeline built');
// 此时只打印了 'pipeline built',一行 'produce' 都没有

const result = pipeline.toArray();
// 到这里才一次性打印 produce 0 .. produce 4

构造管道的语句不执行任何东西。所有副作用——包括生成器函数体的执行、日志打印、网络请求——都推迟到消费的那一刻。这意味着你没法靠「打断点看哪一行没执行」来判断管道对不对,得把它消费掉。

三、案例一:无限序列

惰性的直接好处是,你可以写出「长度无限」的迭代器而不会炸。数组永远做不到这一点,因为数组必须被完整创建出来。

function* naturals() {
  let n = 0;
  while (true) yield n++;
}

const firstTenSquares = naturals()
  .filter(n => n % 3 !== 0)
  .map(n => n * n)
  .take(10)
  .toArray();

// [1, 4, 16, 25, 49, 64, 100, 121, 169, 196]

naturals() 里是个 while (true),看起来挺吓人,但 take(10) 拿到 10 个之后就不再往下拉了,生成器停在 yield 那一行,从此不再前进。它不会被回收,但也绝不会继续跑。

实际项目里更有用的例子是一个不重复的 ID 生成器:

function* sessionIdGenerator(prefix) {
  let seq = 0;
  while (true) {
    yield `${prefix}-${(++seq).toString(36).padStart(4, '0')}`;
  }
}

const gen = sessionIdGenerator('s');
gen.next().value;   // 's-0001'
gen.next().value;   // 's-0002'

这样 ID 是按需生成的,不需要预先算好一批放在数组里。如果程序提前结束,剩下的 ID 从来没被「生产」过——虽然它们本来也不占资源,但思路上的区别是重要的:你写的是规则,而不是一组数据。

四、案例二:一条链子跑完的异步分页

回到开头那个问题。迭代器助手在异步这一侧也有一整套对应的方法,挂在 AsyncIterator.prototype 上:map、filter、take、drop、flatMap、reduce、forEach、toArray、some、every、find。

先把分页逻辑单独写成一个异步生成器:

async function* fetchUsers(baseUrl) {
  let cursor = null;

  while (true) {
    const url = new URL(baseUrl);
    if (cursor) url.searchParams.set('cursor', cursor);

    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);

    const page = await res.json();
    yield* page.items;              // 展开这一页的元素

    if (!page.nextCursor) return;
    cursor = page.nextCursor;
  }
}

几个写法上的注意点。第一,yield* 在异步生成器里可以直接展开数组或者同步可迭代对象,你不需要写 for (const item of page.items) yield item。第二,那个 return 结束的是整个生成器,不是当前这一页。第三,异常直接 throw 出去,会传播到消费这一侧的 try/catch,不用在生成器里处理。

然后,整条业务逻辑变成一行链子:

const names = await fetchUsers('/api/users')
  .filter(u => u.active)
  .map(u => u.name)
  .take(50)
  .toArray();

和开头那段手写的循环放在一起看,改动其实就两点。

语义变清楚了。「按页拉用户」「只要活跃的」「要名字」「五十个」。四件事各占一行,谁也不用读循环体去猜。

网络请求变少了。这条链子只会拉到凑够 50 个名字为止的分页数。take(50) 一满足,整个异步管道就不再向上游索取,fetchUsers 生成器里的 while 循环不会再进入下一次迭代,fetch 也就不会发出。这跟数组那版「全拉完再筛」相比,可能省掉几十次 HTTP 往返。

还有一点经常被忽略:提前终止会触发清理。当 take 提前结束,或者你在 for await...of 里 break 出来,迭代器的 return() 会被调用,生成器函数里 finally 块会执行:

async function* watchQueue(client) {
  const sub = await client.subscribe('jobs');
  try {
    for await (const msg of sub) yield msg;
  } finally {
    await sub.unsubscribe();   // 提前 break 也会走到这里
  }
}

// 用的时候不必手动清理
for await (const job of watchQueue(client)) {
  if (job.priority === 'high') break;
  // break 之后 unsubscribe 已经被调用了
}

这个「结构化清理」的保证,是手动维护游标或者用回调风格的 API 很难拿到的。写自定义迭代器的时候,把资源释放放进 finally,可以省掉调用方一大块样板代码。

五、案例三:把 ReadableStream 和迭代器接起来

Web 平台上还有一个接口能让这两套东西自然对接:ReadableStream.prototype.values()。它返回一个异步迭代器,于是 fetch 的响应体、文件读取流、SSE 通道,全都可以直接用异步迭代器助手处理。

下面是从一个流式接口里读 NDJSON(每行一个 JSON 对象),筛出耗时超过 1 秒的请求:

async function* readNdjson(stream) {
  const decoder = new TextDecoder();
  let buffer = '';

  for await (const chunk of stream) {
    buffer += decoder.decode(chunk, { stream: true });

    let idx;
    while ((idx = buffer.indexOf('n')) !== -1) {
      const line = buffer.slice(0, idx).trim();
      buffer = buffer.slice(idx + 1);
      if (line) yield JSON.parse(line);
    }
  }

  const tail = (buffer + decoder.decode()).trim();
  if (tail) yield JSON.parse(tail);
}

const slow = await fetch('/api/logs/stream')
  .then(res => res.body)
  .then(body => readNdjson(body))
  .then(records => records
    .filter(r => r.durationMs > 1000)
    .map(r => ({ path: r.path, ms: r.durationMs }))
    .take(20)
    .toArray());

这段代码里有几个值得停下来看的地方。

那个 buffer 是必须的。流给你的 chunk 是任意切分的,一个 JSON 对象可能被劈成两半,也可能一个 chunk 里装了三个完整的对象加半个。所以要在缓冲区里找换行符,找到一行就发一行,剩下的留着。用 decoder.decode(chunk, { stream: true }) 而不是直接 decode(chunk),是为了让编码器把跨 chunk 的多字节字符(比如中文)拼回来,否则会出现「一个汉字被劈开变成两个乱码」。这个坑我在处理中文日志的时候连着踩过两次。

循环内部的 while 不是多余的。一个 chunk 里可能有多行,所以找到一行发一行之后,还得继续在这段缓冲区里找。如果只 if 一次,就会丢掉同 chunk 里的其他行,表现为「日志少了三分之二」。

take(20) 之后流会被取消。这一点很妙。凑够 20 条之后,toArray 停止拉取,异步迭代器被终止,readNdjson 里的 for await 被 return() 打断,而它迭代的是 ReadableStream 的迭代器——这时底层响应体会被 cancel,TCP 连接释放。不需要 AbortController,不需要手动 reader.cancel()。惰性在这里不只是省 CPU,还省了网络和连接。

六、几个一定会撞上的坑

1. 迭代器只能消费一次

这是最要命的一条。数组可以随便遍历,迭代器不行。

const it = [1, 2, 3].values();

it.map(x => x * 2).toArray();   // [2, 4, 6]
it.map(x => x * 2).toArray();   // []  —— 空的!

第二次是空的,因为迭代器已经走到头了。done 一旦为 true,就永远为 true,没有 rewind,也没有 reset。数组那种「同一个数据源反复遍历」的直觉在这里不成立。

要复用,就在源头重新创建:

const makeSource = () => [1, 2, 3].values();

makeSource().map(x => x * 2).toArray();
makeSource().map(x => x * 2).toArray();

或者干脆一开始就用数组存着,需要遍历的时候各取各的迭代器。如果一段代码需要「先遍历一遍统计,再遍历一遍处理」,那就别用迭代器,老老实实 Array.from 存下来。

2. 惰性意味着不执行,也意味着异常延后

const pipeline = Iterator.from(data)
  .map(parseItem)     // parseItem 可能抛异常
  .filter(isValid);

// 这里不会抛,因为什么都还没跑
console.log('built ok');

// 异常会在这里抛出来
const result = pipeline.toArray();

用 try/catch 的时候要把 消费语句 包进去,包在构造管道那一行是没用的。反过来,如果你写的是「先 try 构造、再 try 消费」,那就白包一层了。

3. 同步和异步不能混着链

async function* asyncSource() { /* ... */ }

const result = asyncSource()
  .map(x => x * 2)      // 这是 AsyncIterator.prototype.map,返回 Promise 链
  .filter(x => x > 10)  // OK,也是异步版
  .toArray();            // 返回 Promise

// 必须 await
const items = await result;

异步迭代器上的 map 返回的也是异步迭代器,回调可以返回 Promise(会被自动 await),也可以返回普通值。但如果反过来——把同步迭代器塞进异步链里——是不行的。要转换,得包一层:

async function* toAsync(syncIterable) {
  yield* syncIterable;
}

反过来把异步迭代器转成同步的,没有任何办法。异步就是异步的,不可能在不阻塞线程的前提下变成同步。

4. 别在 toArray 之前偷看结果

经常看到这种写法:

const pipeline = Iterator.from(data)
  .filter(isValid)
  .map(transform);

console.log(pipeline.length);   // undefined,而且不会有任何输出

迭代器没有 length,也不知道自己还剩多少个元素。它是「一个一个拉」的模型,没有「总数」这个概念。naturals().take(10) 里的 10 是截断条件,不是长度——真正的元素个数只有拉完才知道。想要数组,就只能拉完,也就是 toArray()。

5. 大数组的场景下不一定划算

如果数据本来就是个百万长度的数组,那么「先 Iterator.from 再用助手」和「直接数组方法」的区别只在中间数组上。数组那版多分配两个数组,但如果你的最终结果也是完整数组(没有 take),那总量其实差不多。

真正拉开差距的是两种情况:一是有提前终止(take、find、some),二是数据源本身就是「按需产生」的(生成器、流、分页接口)。在这两种之外,为了用而用,收益很有限。

6. Iterator.from 是做什么的

简单说,它把「任何可迭代的东西」变成一个带助手方法的迭代器。因为字符串、Map、Set 的 Symbol.iterator 返回的迭代器本来也带这些方法,所以它最常见的用途是处理那些自定义的可迭代对象,以及——一个挺实用的场景——临时把一个可迭代对象包装起来。

const m = new Map([['a', 1], ['b', 2]]);

const pairs = Iterator.from(m)
  .map(([k, v]) => `${k}=${v}`)
  .toArray();

// ['a=1', 'b=2']

不用 Iterator.from 也可以写 m.entries().map(...),效果一样。它的价值在于统一:如果一段代码接受的输入可能是数组、可能是 Set、可能是生成器,用 Iterator.from(input) 包一层,后面就只认一种东西了,不用再写分支判断。

七、什么时候别用它

需要随机访问的时候。迭代器只能顺序拉,it[5] 这种是不存在的。要索引、要排序、要反复回看,直接用数组。

需要多次遍历的时候。前面说过了。二次遍历的成本比一开始就存数组高。

数据量很小的时候。十个元素,两种写法的性能差异完全在噪声范围内,那就选可读性更好的那个。通常数组方法的写法更眼熟,团队里所有人都能一眼看懂。

需要和其他库互操作的时候。很多库的 API 标注是 Array 而不是 Iterable。虽然大部分时候传迭代器进去也能用(因为很多库内部会展开它),但不保证。遇到接口不匹配,还得 Array.from 转一下,那偷懒的意义就不大了。

至于环境支持,同步的迭代器助手在 ES2025 已经定稿,Node 22 及以上、以及主流浏览器的新版本都已经可以直接用,不需要 polyfill。异步迭代器助手进入实现的时间稍早一些。稳妥起见,动手之前在你的目标环境里跑一句 typeof [].values().map 确认一下,比翻文档快。

八、写在最后

迭代器这套东西在 JS 里存在很久了,但一直处于「语言有、工具没有」的尴尬状态。for...of 让你能遍历它,展开语法让你能把它变成数组,但中间那一步——筛选、变换、截断——只能手写循环。所以现实里大家绕一圈还是回到数组上:Array.from(something) 然后再用数组方法。绕了一圈,惰性丢了,提前终止也丢了。

助手补上之后,写法上最大的变化是:你可以先描述「要什么」,再决定「要多少」。分页那一段尤其明显。过去要把「拉多少页」和「要多少条」揉在同一个循环里,现在它们各自独立地写在自己的位置上,中间靠 take(50) 连起来。多出来的不只是网络请求,还有改需求时的从容——把 50 改成 200,改一个数字就行。

如果手上正好有一段「边循环边筛边计数」的胶水代码,可以拿它当第一个试点。先剥出数据源那一层,把它改成一个生成器;再在上面接助手方法,把循环体里揉着的那几件事一行行摊开。改完对着看一遍,通常会比原来短,而且更清楚哪一行在做什么。

JavaScript 迭代器助手实战:惰性管道、无限序列与异步分页流
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript JavaScript 迭代器助手实战:惰性管道、无限序列与异步分页流 https://www.taomawang.com/web/javascript/2810.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务