别再链式 map/filter 了:Iterator Helpers 处理百万行数据的正确姿势

上个月接了个内部工具需求,做一个前端日志分析器。需求本身不复杂:用户拖一个 log 文件进来,工具跑一遍,统计各种 HTTP 状态码、错误类型、慢请求分布,最后画几张图。

我想着这不就是 Array 那一套吗,一行行读出来扔进数组,然后 filter、map、reduce 三连,一小时内搞定。本地拿一个 12MB 的测试日志跑了一下,完美。

结果用户端一次真实场景,拖了一个 340MB 的日志文件进来。

Chrome 直接 white screen。打开 Performance 面板一看,堆内存瞬间飙到 2.8GB,页签直接 crash 掉。事后分析,光是那一句 lines.filter(l => l.includes('ERROR')),就在堆上分配了一个包含一百多万个字符串的新数组。后面还有 .map()、.filter() 又叠了两个数组。整个过程其实只需要一次遍历,却因为链式调用被迫做了五六次遍历和五六次内存分配。

那天晚上翻 TC39 提案的时候,想起了 Iterator Helpers。之前扫过一眼没当回事,这次是真派上用场了。

Iterator Helpers 到底是个啥

简单说,就是把数组上那些熟得不能再熟的 map、filter、take、drop、flatMap、reduce、some、every、find 这些方法,挂到了 Iterator 原型上。你在数组上会写的链式调用,现在也能在迭代器上写。

听起来好像只是”位置换了一下”,但背后是本质的区别。

数组的方法会立即求值。[1,2,3].map(f) 是”现在就把每个元素跑一遍 f,把结果装进一个新数组”。而迭代器的方法是惰性的。它不会立刻执行,只是记录一个待执行的操作序列,等你真正去 for...of、toArray() 或者调用终结方法的时候,才一个元素一个元素地”拉”出去。

这个区别在处理小数组时看不出来,但一旦数据量上到几十万、几百万,就不一样了。链式调用数组方法会产生多个中间数组,每个中间数组都占用完整的内存;而迭代器是流水线式的,同一时刻内存里最多只有当前正在处理的这一个元素。

浏览器支持情况

写之前先讲下支持面,免得有人踩坑。Chrome 122 开始默认开启,Firefox 131、Safari 18.4 也都跟上了。Node.js 从 22 开始可用,Express、Koa 那一套之后写数据处理会方便很多。

如果一定要兼容老的浏览器,可以考虑 core-js 的 polyfill,但性能收益会打折。要我说,这种新特性用在内部工具或者已经明确过基线的前端项目里最合适,别绑死在支持 IE 的祖传项目上。

第一个例子:跟数组写法正面对比

先看一段经典代码。假设有一个用户列表,要筛出成年用户,计算每个人的全名,然后取前 5 个。

数组写法:

const result = users
  .filter(u => u.age >= 18)
  .map(u => u.firstName + ' ' + u.lastName)
  .slice(0, 5);

迭代器写法:

const result = users.values()
  .filter(u => u.age >= 18)
  .map(u => u.firstName + ' ' + u.lastName)
  .take(5)
  .toArray();

结构几乎一样,但执行方式有本质差别。

数组写法里,filter 会遍历整个 users,不管你能不能立刻凑够 5 个成年人都会跑完,然后创建一个新的数组。map 同样遍历一遍完整的新数组。最后 slice 再生成第三个数组。三个数组,三次完整遍历。

迭代器写法里,链条挂上之后不会立刻执行。toArray() 开始拉数据,每拉一个元素,依次穿过 filter、map,如果通过了就放进结果,凑够 5 个就停下。整个过程只创建了一个长度不超过 5 的结果数组,而且如果 users 里前 10 个就有 5 个成年人,后面的元素一个都不会被处理。

take 这个方法是关键。它相当于一个”熔断器”,一旦下游已经拿够了,就通知上游别再送了。数组方法里没有对应的东西,你只能用 slice 事后裁剪,但上游已经跑完了。

案例:百万行日志流式分析

回到我那个崩溃的日志分析工具。日志格式大概是这样的:

2026-01-18T09:12:34.221Z [INFO] GET /api/products 200 45ms user=10023
2026-01-18T09:12:34.428Z [ERROR] POST /api/orders 500 1280ms user=10087 err=DB_TIMEOUT
2026-01-18T09:12:34.511Z [INFO] GET /api/cart 200 22ms user=10023
...

我要统计的是:每种状态码出现的次数、每种错误类型出现的次数、以及耗时超过 500ms 的请求列表(最多展示 100 条)。

用数组写,文件一大就爆内存。用迭代器写,可以做到边读边算,内存占用几乎是常数级别。

先写一个把字符串按行切开的生成器:

function* splitLines(text) {
  let start = 0;
  for (let i = 0; i < text.length; i++) {
    if (text.charCodeAt(i) === 10) { // 换行符
      yield text.slice(start, i);
      start = i + 1;
    }
  }
  if (start < text.length) {
    yield text.slice(start);
  }
}

这个生成器本身也是惰性的,for...of 拉一行它才切一行。这样就不会一次性把整个文件切开生成上百万个字符串。

然后定义解析函数,把一行日志变成一个对象:

const LINE_RE = /^(S+) [(w+)] (w+) (S+) (d{3}) (d+)ms user=(d+)(?: err=(w+))?/;

function parseLine(line) {
  const m = LINE_RE.exec(line);
  if (!m) return null;
  return {
    time: m[1],
    level: m[2],
    method: m[3],
    path: m[4],
    status: Number(m[5]),
    cost: Number(m[6]),
    userId: m[7],
    error: m[8] || null,
  };
}

接下来是分析主逻辑。我用了迭代器链:

function analyze(rawText) {
  const statusCount = new Map();
  const errorCount = new Map();
  const slowRequests = [];

  splitLines(rawText)
    .map(parseLine)
    .filter(line => line !== null)
    .forEach(line => {
      // 状态码统计
      statusCount.set(line.status, (statusCount.get(line.status) || 0) + 1);

      // 错误类型统计
      if (line.error) {
        errorCount.set(line.error, (errorCount.get(line.error) || 0) + 1);
      }

      // 慢请求,最多存 100 条
      if (line.cost > 500 && slowRequests.length < 100) {
        slowRequests.push({
          path: line.path,
          cost: line.cost,
          time: line.time,
        });
      }
    });

  return {
    status: Object.fromEntries(statusCount),
    errors: Object.fromEntries(errorCount),
    slow: slowRequests,
  };
}

整个过程只遍历了一遍输入,内存里同时存在的对象数量非常有限。慢请求那个数组还主动做了 100 条的上限,防止某些异常情况下全部堆进来。

对比一下原来的数组方案,光是 text.split('n') 这一步就会创建一个百万量级的字符串数组,接着 filter 再来一个,map 再来一个。三步下来,即便每步只占用一个引用,累积起来也是几百兆。

跟 AsyncGenerator 组合:处理来自网络的流

有读者可能注意到,上面那个 splitLines 拿到的还是整个字符串。这意味着你仍然需要一次性把文件读进内存。要真正做到流式,得用 ReadableStream。

好在这里的套路是一样的,只是把生成器换成异步生成器。先写一个从流里逐行读的异步生成器:

async function* streamLines(readable) {
  const reader = readable.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) {
        if (buffer.length > 0) yield buffer;
        break;
      }
      buffer += decoder.decode(value, { stream: true });

      let idx;
      while ((idx = buffer.indexOf('n')) !== -1) {
        yield buffer.slice(0, idx);
        buffer = buffer.slice(idx + 1);
      }
    }
  } finally {
    reader.releaseLock();
  }
}

拿文件输入的流:

const file = document.querySelector('input[type=file]').files[0];
const stream = file.stream();
const textStream = stream.pipeThrough(new TextDecoderStream());

然后把它接到分析逻辑上。异步迭代器不能直接用同步的 map/filter,需要用 for await 手写循环,或者用社区的几个辅助工具。我这里用的是 for await 加 if 判断:

async function analyzeStream(textStream) {
  const statusCount = new Map();
  const errorCount = new Map();
  const slowRequests = [];

  for await (const line of streamLines(textStream.getReader ? textStream : textStream)) {
    const parsed = parseLine(line);
    if (!parsed) continue;

    statusCount.set(parsed.status, (statusCount.get(parsed.status) || 0) + 1);
    if (parsed.error) {
      errorCount.set(parsed.error, (errorCount.get(parsed.error) || 0) + 1);
    }
    if (parsed.cost > 500 && slowRequests.length < 100) {
      slowRequests.push({
        path: parsed.path,
        cost: parsed.cost,
        time: parsed.time,
      });
    }
  }

  return {
    status: Object.fromEntries(statusCount),
    errors: Object.fromEntries(errorCount),
    slow: slowRequests,
  };
}

这是最关键的一步。用 streamLines 拉行,读多少处理多少,一个 340MB 的文件跑下来,峰值内存只有不到 60MB。因为读进来的 chunk 一处理完就被丢掉,真正存在内存里的只有那些统计用的 Map 和数组。

性能对比数据

我在一台 MacBook Air M1 上做了组对比测试,用的是同一个 340MB 的日志文件。三套方案各跑 3 次,取中位数。

方案 峰值内存 总耗时 是否崩溃
数组链式 filter/map 2.8GB 页面崩溃 是
字符串 split + 迭代器 780MB 4.2s 否
ReadableStream + AsyncGenerator 58MB 3.6s 否

内存差别是数量级的,而耗时反过来还稍微快了一点。这一点我一开始有点意外,后来想通了:数组方案里 GC 要不停歇地回收那些巨大的中间数组,一次 major GC 就是几百毫秒;而流式方案里垃圾产生得慢,GC 压力小很多,虽然逻辑上多了一些读写的调度,但整体反而更快。

几个容易踩的坑

用顺手之后有些坑也就踩出来了,分享给各位。

坑一:迭代器不能复用。数组可以遍历很多遍,但迭代器是有状态的,遍历完就没了。如果你需要同时做几个不同的分析,要么重新创建迭代器,要么在一次遍历里把所有指标都算完。我一开始把同一个迭代器传给了两个分析函数,第二个函数跑出来结果是空的,还以为是哪写错了。

坑二:.values() 和 [Symbol.iterator]() 走的是同一个东西。但 Map 和 Set 上调用 .values() 拿到的迭代器内容跟数组不一样。Map 拿到的是 [key, value] 数组,Set 拿到的是元素本身。写链式调用的时候注意区分,必要时先 .map() 一下转格式。

坑三:take() 是短路的,但这个”短路”是相对的。如果后面还有 forEach 或者 reduce 之类的终结操作,take 会让整个上游链条在”够数了”之后停下来。但如果 take 后面还有别的元素转换方法,那”够数”的判断会推迟到真正拉取时,靠编译器或解释器优化。这一点跟 Rust 里 Iterator 的语义一样,理解了就明白。

坑四:异步迭代器上没有这些方法。目前 Iterator Helpers 只挂在同步的 Iterator 原型上,AsyncIterator 上还是得手动 for await。这个问题 TC39 已经在讨论,但短期不会落地。

坑五:toArray() 是大杀器也是陷阱。前面讲的所有好处,前提都是你不调用 toArray()。一旦调用,效果等同于把所有元素收集到一个数组里,内存优势瞬间消失。用 toArray 之前想一下,是不是真的需要拿到全部结果,还是只要能 forEach 遍历一下就够。

什么时候值得用,什么时候不值得

Iterator Helpers 不是银弹,它的优势场景很具体。

数据量大且需要多次转换时值得用。一旦数据量过万,链式数组方法的中间数组开销就开始显著了。十万级、百万级是 Iterator Helpers 的主场。

数据源是流、网络、文件时值得用。这是它最有价值的地方。把流处理写成生成器链,可读性比手动管理 buffer 好太多,而且天然带着背压(backpressure),不会把内存撑爆。

能提前终止的时候值得用。有 take、find、some、every 这些短路方法时,Iterator Helpers 能避免无谓的遍历。特别是处理一个”取前 N 个符合条件的”的场景,传统做法把整个列表跑完才发现只需要前 5 个,浪费很大。

小数据量、一次性操作、需要随机访问的场景不值得用。数组的 [i] 访问、sort、reverse 这些能力迭代器都没有,为了一点点内存优化把代码写复杂,不值当。数据量在千级以内,直接用数组方法就好。

最后

回到那个日志分析工具。改完之后,用户端拖 300 多兆的文件进去,跑下来稳定在 3 到 4 秒,内存峰值不到 60MB,业务同学也没再抱怨。整个过程最耗时间的地方其实不是写代码,是想明白”为什么要用迭代器而不是数组”。

这件事给我的感受是,写 JavaScript 的时候太容易落入一个舒适区——数组能解决一切。但数组这个抽象本身,其实是为”随机访问、可反复遍历”的集合设计的,它并不擅长”顺序处理、只需一遍”的场景。Iterator Helpers 补上的恰好是这块。把它当成工具箱里多一把趁手的工具,而不是把它当”更现代的数组”去用,这个心态可能更合适。

别再链式 map/filter 了:Iterator Helpers 处理百万行数据的正确姿势
收藏 (0) 打赏

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

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

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

淘吗网 javascript 别再链式 map/filter 了:Iterator Helpers 处理百万行数据的正确姿势 https://www.taomawang.com/web/javascript/2891.html

常见问题

相关文章

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

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