Iterator Helpers 实战:ES2025 迭代器助手,让分页拉取和流式处理不再写胶水代码

去年底 V8 13.4 一上线,Iterator Helpers 就成了默认特性之一——Chrome 122、Edge 122、Firefox 131 跟上,Safari 26 也补齐了。翻译成人话:迭代器原型上终于有了 mapfiltertakereduce 这些方法,不用再借助手写生成器或者引入第三方库。

听起来挺小的事,但如果你写过这类代码就会知道,这个特性解决的是日常中很别扭的一类问题。

先说清楚它跟数组方法的区别

给迭代器加 map,第一反应可能是”这不就是数组那套吗,有啥新鲜的”。但它们在求值时机上完全是两码事。

数组方法都是立即求值:你写 [1,2,3].map(x => x * 2),那么整个数组会被立即遍历一遍,返回一个新数组。哪怕后面只用到前两个元素,第三个也已经算完了。

迭代器方法却是惰性的。下面这段代码:

function* naturals() {
  let i = 1;
  while (true) yield i++;
}

const result = naturals()
  .map(x => x * x)
  .filter(x => x % 2 === 1)
  .take(5)
  .toArray();

console.log(result); // [1, 9, 25, 49, 81]

naturals() 是一个无限的生成器,无休止地往外吐自然数。如果用数组方法去处理它,第一句 map 就得把自己跑死。但用迭代器方法,整条链从头到尾只被拉取了足够产生 5 个结果的数据。上面那段实际拿到过的数,大概是 1 到 9 之间的几个奇数,后面的连碰都没碰。

这就是”惰性求值“四个字背后真正的意思:直到你在链尾写 toArrayforEach 或者 reduce——这些叫”消耗型方法”——才真正开始迭代,而且是按需迭代。链中间的 mapfiltertake 都只是往管道上装了一个环节,并没有真的跑。

为什么要关心惰性:一次分页拉取的对比

看一个具体的场景。某接口分页返回用户列表,每页 20 条。需求是:找到前 3 个”过去一个月登录过”并且名字以 A 开头的用户。

用传统写法大致是这样:

async function findTargets() {
  const found = [];
  let page = 1;

  while (true) {
    const batch = await fetchPage(page);
    for (const user of batch) {
      if (user.lastLoginAt > oneMonthAgo && user.name.startsWith('A')) {
        found.push(user);
        if (found.length === 3) return found;
      }
    }
    if (batch.length < 20) return found;
    page++;
  }
}

这段代码本身逻辑是对的,但它把”翻页””过滤””凑够 3 个就停”三件事揉在一起了。业务逻辑一改,比如过滤条件变成两个、或者要凑够 10 个、或者要按时间排序再取,整段都得重写。而且注意一个细节:就算第 2 页里已经找到了 3 个,fetchPage 也把这一整页 20 条都拉回来了。虽然只用到其中 3 条,剩下 17 条白白浪费在网络和解析上。

用迭代器助手,同样的需求可以写成:

async function* fetchAllPages() {
  let page = 1;
  while (true) {
    const batch = await fetchPage(page);
    if (batch.length === 0) return;
    yield* batch;
    page++;
  }
}

async function findTargets() {
  const result = [];
  for await (const user of fetchAllPages().filter(
    u => u.lastLoginAt > oneMonthAgo && u.name.startsWith('A')
  )) {
    result.push(user);
    if (result.length === 3) break;
  }
  return result;
}

逻辑分成了两层:fetchAllPages 只管翻页,返回一个无限的异步迭代器;筛选和提前终止写在调用处。如果哪天要改成”只取名字包含 B 的”或者”页大小改成 50″,改动量极小。

这里用到了异步迭代器,这一点很重要——后面会专门讲。

实战案例:大日志文件的流式管道

第二个场景更贴近后端或者工具脚本。假设有一份 5GB 的日志文件,需要提取所有返回 5xx 的请求行,只关心前 1000 条,用于排查最近的问题。

Node.js 里读取这种文件,通常会配合 readline

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

function* readLines(path) {
  const stream = createReadStream(path, { encoding: 'utf8' });
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
  yield* rl;
}

const errors = readLines('/var/log/nginx/access.log')
  .map(line => line.trim())
  .filter(line => {
    const m = line.match(/" (d{3}) /);
    return m && Number(m[1]) >= 500;
  })
  .take(1000)
  .toArray();

console.log(`前 1000 条 5xx 记录共 ${errors.length} 条`);

这里 yield* 是一个关键语法。它能把一个可迭代对象(这里是 readline 的异步迭代器接口对应的对象)整体委托出去。<readline> 返回的 rl 本身是可异步迭代的,包一层生成器就可以把它变成一个简单的”行迭代器”,后面接哪个迭代器方法都行。

这套写法的好处在哪?take(1000) 之后,一旦凑够 1000 条,链条就停了。createReadStream 会被自动销毁(生成器返回时触发的清理逻辑),文件不会读到底。如果直接用 readFileSync 加正则匹配,5GB 的文件光读进内存就得爆炸,更别说后面还要遍历。

有人可能会问:这个用 readline 的事件监听也能做,为什么要用迭代器?原因在于组合性。readlineline 事件是一个推模型,你只能在回调里判断各种条件,要组合”过滤 + 取前 N + 分组统计”这种需求,就得自己维护一堆状态变量。而迭代器是一个拉模型,每条数据都是被下游”要”上去的,中间加什么环节就是加一行方法的问题。

方法速查与几个容易踩的点

目前迭代器原型上提供的方法有这么一批(都是标准的 ECMAScript 名字,跟数组方法保持一致):

  • 变换类:mapflatMap
  • 过滤类:filtertakedrop
  • 消耗类:reduceforEachtoArraysomeeveryfind
  • 其他:Iterator.fromIterator.concat(把多个可迭代对象拼成一个迭代器)

它们之间的分工可以记成一句话:除了消耗类,其余都是惰性的,返回的还是一个迭代器。所以下面这段代码虽然看着像”过滤后再遍历两次”,实际却什么都不做:

const pipeline = data.filter(x => x.active).map(x => x.id);
// 到这里为止,data 一次都没被访问过

但正因为整个链是共享的、惰性的,有个坑就特别难发现:迭代器是一次性的

const it = [1, 2, 3].values().map(x => x * 2);

console.log(it.toArray()); // [2, 4, 6]
console.log(it.toArray()); // []  —— 空的!

toArray 会把迭代器跑到 done: true,之后这个迭代器就彻底枯竭了。再调 toArray 拿到的就是空数组,而且不报错、不警告,静默返回空值。这是刚接触迭代器助手时最容易犯的错误。

应对办法也简单:别把链中间的结果存成变量复用,直接用链式调用一次写完;或者每次新建一个迭代器。如果确实需要多轮访问,那就 toArray() 落成数组,反正数组方法也是现成的。

还有一个不那么明显的坑:reducefindsome 这些消耗方法也是”能停就停”的。find 找到第一个符合条件的元素就返回,迭代器也停在那个位置,后面的元素没被消费。这在处理无限序列时是好事,但如果你对同一个迭代器接着做别的操作,拿到的就是剩余部分,而不是全部。这种”部分消费”的语义跟数组方法完全不一样,写的时候心里得有个数。

异步迭代器:Node.js 里的重头戏

前面分页拉取的例子其实用到了异步迭代器,但没展开说。这块值得单独拿出来讲,因为在服务端场景里它的价值更高。

异步迭代器(AsyncIterator)的用法和同步版本几乎一模一样,只是需要在 for await 里消费,而且消耗方法要加 await

const ids = await asyncIds()
  .filter(hasPermission)
  .take(50)
  .toArray();

写法上和同步的差别在于 asyncIds 本身返回异步迭代器,其余组合方式完全一样。mapfiltertake 都同时提供了同步和异步两个版本——注意这不是自动的,是规范里明确写了两套。也就是说一个异步迭代器上不光是 map 能等,filter 的回调也可以是 async 函数,返回一个 Promise。

这就解决了一类以前很别扭的问题——只要环境支持 async/await,你没法在同步的 filter 回调里 await 一个异步检查函数。之前的写法只能先把数据拉成数组,用 Promise.all 一个个检查,再过滤。中间一堆 await Promise.all
现在可以直接链式写下来,逐条异步处理,而且惰性求值意味着不会提前并发打爆下游。

不过有一个必须留意的限制:异步迭代器上的方法不是并发执行的。每次拉取一条,处理完再拉下一条,串行的。如果你的处理是 IO 密集型的(比如每条都发一个 HTTP 请求),串行会导致整体耗时等于单条耗时之和,非常慢。

想要并发就得自己在链尾做批处理。常见的做法是用 take 分块,每一块并行处理:

async function processInBatches(source, batchSize = 20) {
  const iter = source[Symbol.asyncIterator]();
  while (true) {
    const batch = [];
    while (batch.length < batchSize) {
      const { value, done } = await iter.next();
      if (done) break;
      batch.push(value);
    }
    if (batch.length === 0) return;
    await Promise.all(batch.map(handleOne));
    if (batch.length < batchSize) return;
  }
}

这段代码稍微超出了迭代器助手的范畴,但它是实际项目里绕不过去的一环。惰性管道适合”逐条逻辑复杂”的场景,批量并发适合”每条独立且快”的场景,两者结合起来才能覆盖大部分需求。

和数组方法的取舍

已经用了这么多年数组方法,看到迭代器助手的第一反应可能是”那我是不是该把所有 .map 都换过去”。

并不需要。判断的依据其实只有两条:数据源有多大,需不需要提前终止

数据是个几十条的小数组,或者你本来就要把所有结果收集起来做后续处理(比如给 UI 渲染一个列表),那数组方法用着更顺手。toArray 这一步的开销不白给,数组方法一步到位。

但遇到下面这些情况,迭代器就明显更有优势:

  • 数据源很大或者无边界(日志流、无限生成器、分页接口)
  • 只要满足条件的前几条结果,后面用不到
  • 数据源已经很自然地是迭代器形态(MapSetreadlinefetch 的流)
  • 每一步处理的成本都很高(比如每条都涉及网络请求或者复杂计算),不想做无谓计算

另外有一个容易被忽略的事实:MapSet 本身就是可迭代对象,写着写着你就会想给它们用上这些方法。

const counts = new Map()
  .set('a', 3)
  .set('b', 5);

const labels = counts.entries()
  .map(([k, v]) => `${k}(${v})`)
  .toArray();

放在以前,counts.entries() 拿到的是一个 Map 迭代器,你只能手动写 for...of 或者 [...counts.entries()].map()。后者在数据大的时候会把所有条目复制成一个临时数组,纯属浪费。现在有了 .map 直接链式写下来更简洁,也没有那个中间数组。

兼容性与渐进增强

浏览器支持这块,前面已经说过了,主流引擎该有的都有了。但如果你的项目还需要支持稍老的环境(比如 Safari 17 或者某些嵌入式 WebView),那就得考虑降级方案。

写法上不需要动,只要确保不支持的浏览器里调用不到这些方法就行。最省事的是做成一个函数:

const supportsIteratorHelpers =
  typeof Iterator !== 'undefined' &&
  typeof Iterator.prototype.map === 'function';

用这个标志决定后面走迭代器链路还是走数组链路。不过说实话,一旦用上了迭代器链,退回数组链的逻辑基本不可能是顺手改改——整个结构都不一样。真的还需要支持旧环境的情况下,我的建议是先用 polyfill 顶上,等项目到了合适的版本再摘掉。

有一个官方的 polyfill 叫 es-iterator-helpers,基于 core-js 实现。npm install es-iterator-helpers 之后在入口处 import 'es-iterator-helpers/auto' 就把 Iterator.prototype 补全了。缺点是体积不小,而且它不是原生的,性能稍差一点。

收个尾

Iterator Helpers 是个看起来平平无奇、用起来却能明显改变写法的特性。真正让我觉得它有意思的地方,是它把”惰性”这件事从生成器、RxJS 这些相对陡峭的工具里拿出来,变成了日常随手可用的基础设施。

以前遇到分页拉取、流式处理这类场景,脑子里自动会切换成”事件回调 + 状态变量”的模式,写出来就是一坨循环加 flag。现在可以用同一套链式管道来表达,读起来像在描述数据怎么流动,而不是怎么控制它。

如果你手上正有几个手动维护的生成器工具函数,或者某个函数式库里专为惰性处理写的工具,不妨挑一个换成原生实现试试。改完会发现,代码更短、意图更直接,运行时也更省资源。

Iterator Helpers 实战:ES2025 迭代器助手,让分页拉取和流式处理不再写胶水代码
收藏 (0) 打赏

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

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

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

淘吗网 javascript Iterator Helpers 实战:ES2025 迭代器助手,让分页拉取和流式处理不再写胶水代码 https://www.taomawang.com/web/javascript/2755.html

常见问题

相关文章

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

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