Iterator Helpers 实战:用惰性管道重写 JavaScript 日志清洗代码

我们的日志清洗脚本一直是这么写的:把整个文件读成字符串,split 成数组,然后一串 filter / map / sort / slice 收尾。文件涨到 1.2GB 的那一周,脚本开始 OOM。排查下来才发现,罪魁祸首不是文件大小,而是我把数据当成了数组——数组的每一步操作都会把结果完整物化出来,哪怕你最终只想要前十条。

改用 Iterator Helpers 重写之后,同样的输出,内存峰值降了一个数量级。这篇文章把改造过程完整写一遍,包括中途踩的几个坑。

问题出在“物化”这一步

先看一段典型代码:

const top = rows
  .filter((r) => r.status >= 500)
  .map((r) => ({ path: r.path, ms: r.ms }))
  .sort((a, b) => b.ms - a.ms)
  .slice(0, 10);

假设 rows 有 200 万条。执行过程是这样的:

  1. filter 遍历完 200 万条,新建一个数组装下所有 5xx 记录;
  2. map 再遍历一遍上一步的结果,又新建一个数组;
  3. sort 原地排序,但排序本身要求全量数据在内存里;
  4. slice 再切出一小块。

三个中间数组,每个都可能比原始数据还大。而真正的输出只有 10 个对象。

这里还有个更隐蔽的问题:如果业务只需要“遇到第一个 5xx 就报警”,数组写法的 filter 依然会老老实实把 200 万条全部扫完。它没法提前停,因为 filter 的契约是“返回完整结果集”。

迭代器和可迭代对象,两句话分清楚

在往下走之前,先把两个容易混的概念摆正:

  • 可迭代对象:有 [Symbol.iterator]() 方法,调用后返回一个迭代器。数组、字符串、Map、Set、生成器返回的对象都属于这一类。
  • 迭代器:有 next() 方法,每次调用返回 { value, done }。它内部维护一个游标,只能往前走。

Iterator Helpers 提供的方法挂在 Iterator.prototype 上,所以只有迭代器能直接用。数组是“可迭代对象”但不是迭代器,需要先转换。

API 速览

目前 Iterator.prototype 上提供了这些方法,命名和数组方法基本对齐:

// 转换类
it.map(fn)            // 映射
it.filter(fn)         // 过滤
it.flatMap(fn)        // 映射后展开一层
it.take(n)            // 取前 n 个,取够就停
it.drop(n)            // 跳过前 n 个

// 收敛类(会消费整个迭代器,除非能短路)
it.reduce(fn, init)   // 注意:初始值是必填的
it.toArray()          // 收集成数组
it.forEach(fn)
it.some(fn)           // 能短路
it.every(fn)          // 能短路
it.find(fn)           // 能短路

入口是静态方法 Iterator.from(),它接受任何可迭代对象或迭代器,统一返回一个带完整 helpers 的迭代器:

Iterator.from([1, 2, 3, 4, 5])
  .filter((n) => n % 2 === 1)
  .map((n) => n * 10)
  .toArray();
// [10, 30, 50]

这里有个细节值得单独拎出来:Iterator.prototype.reduce 的初始值不是可选的。和 Array.prototype.reduce 不一样,你不传第二个参数会直接抛 TypeError。空迭代器的边界情况在规范里被规避掉了,代价是你必须每次都显式给初值。

惰性到底省了什么

光看 API 说明感受不出来,跑一段代码就清楚了:

let pulled = 0;

function* source() {
  for (let i = 1; i <= 1_000_000; i++) {
    pulled++;
    yield i;
  }
}

const result = Iterator.from(source())
  .filter((n) => n % 3 === 0)
  .map((n) => n * 2)
  .take(5)
  .toArray();

console.log(result);  // [6, 12, 18, 24, 30]
console.log(pulled);  // 15

答案来了:源生成器总共只被拉取了 15 次。

整个过程是“按需拉动”的——toArray() 向 take(5) 要值,take(5) 向 map 要值,map 向 filter 要值,filter 向生成器要值。生成器从 1 一直吐到 15,凑够了 5 个满足条件的结果,take 立刻叫停,剩下的 999985 次循环根本没有发生。

同样的逻辑用数组写,那 100 万次循环一次都少不了。

坑一:迭代器是一次性的

这是从数组切过来最容易翻车的地方:

const iter = Iterator.from([1, 2, 3]);

iter.toArray(); // [1, 2, 3]
iter.toArray(); // []

第二次调用返回空数组,不报错,也不警告。原因很直白:游标已经走到头了,迭代器无法回退。

这个问题在函数传参时更隐蔽:

function summarize(iter) {
  const total = iter.reduce((sum, n) => sum + n, 0);
  const count = iter.reduce((c) => c + 1, 0);  // 这里恒为 0
  return { total, average: total / count };     // average 变成 Infinity
}

稳妥的做法是:迭代器只在最里层传,不要跨函数传递。如果确实需要复用,传生成器函数而不是生成器对象,每次用的时候重新调用一次:

function* numbers() {
  yield 1;
  yield 2;
  yield 3;
}

// 传函数,调用方自己决定什么时候创建
function summarize(makeIter) {
  const total = Iterator.from(makeIter()).reduce((s, n) => s + n, 0);
  const count = Iterator.from(makeIter()).reduce((c) => c + 1, 0);
  return { total, average: total / count };
}

summarize(numbers); // { total: 6, average: 2 }

坑二:提前终止会触发清理

这个行为是好事,但不知道的话容易被吓一跳。take()、find()、some() 这类会提前停止的方法,在收手时会调用上游迭代器的 return() 方法:

function* ticker() {
  try {
    let i = 0;
    while (true) yield i++;
  } finally {
    console.log('上游被清理了');
  }
}

Iterator.from(ticker()).take(3).toArray();
// [0, 1, 2]
// 打印:上游被清理了

这意味着如果你在生成器里开了文件句柄、数据库游标或者定时器,写 finally 块就能保证它们被释放,不用手动兜底。这一点在流式读取场景里非常实用。

坑三:Iterator.from 不是万能的

它只认可迭代对象和迭代器。类数组对象(比如 arguments、NodeList 的老版本实现)不能直接塞进去:

// TypeError: Iterator.from called on non-object
Iterator.from({ length: 3, 0: 'a', 1: 'b', 2: 'c' });

先转成数组再进:Iterator.from(Array.from(likeArray))。

补上 helpers 没提供的能力:chunk

Iterator Helpers 没有分组方法(Object.groupBy 处理的是数组),但用生成器补上只要十几行:

function* chunk(iterable, size) {
  let buffer = [];
  for (const item of iterable) {
    buffer.push(item);
    if (buffer.length === size) {
      yield buffer;
      buffer = [];
    }
  }
  if (buffer.length > 0) yield buffer;
}

关键在于它是惰性的:调用方不往下取,它就不会继续从上游拉数据。批量写数据库、批量调接口的时候,内存里同时只存在一批。

实战:清洗一份访问日志

下面这份日志格式是 Nginx 默认 combined 格式的精简版:

2025-04-12T08:31:02Z 203.0.113.7 GET /api/orders 200 142ms
2025-04-12T08:31:02Z 198.51.100.23 POST /api/login 401 63ms
2025-04-12T08:31:03Z 203.0.113.7 GET /api/orders 502 1180ms
2025-04-12T08:31:03Z 192.0.2.88 GET /static/app.js 200 12ms
2025-04-12T08:31:04Z 198.51.100.23 POST /api/login 401 58ms
2025-04-12T08:31:05Z 192.0.2.88 GET /api/orders/9 500 2400ms
2025-04-12T08:31:06Z 203.0.113.7 DELETE /api/orders/9 403 21ms
2025-04-12T08:31:07Z 198.51.100.23 GET /api/profile 200 97ms

第一步:写一个真正惰性的行分割器

不要用 text.split('n')——那会立刻创建一个包含所有行的数组,前面所有的努力就白费了。用一个扫描生成器:

function* lines(text) {
  let start = 0;
  for (let i = 0; i < text.length; i++) {
    if (text.charCodeAt(i) === 10) {   // 'n'
      yield text.slice(start, i);
      start = i + 1;
    }
  }
  if (start < text.length) yield text.slice(start);
}

配合 Node 的 readline 或者 Web Streams,这里还能进一步改成从磁盘分块读,内存占用就是常数级的。

第二步:把每行解析成结构体

let parsedLines = 0;

function* parseAccessLog(text) {
  let lineNo = 0;
  for (const line of lines(text)) {
    lineNo++;
    if (!line.trim()) continue;

    const [ts, ip, method, path, status, duration] = line.split(' ');
    parsedLines++;

    yield {
      lineNo,
      ts,
      ip,
      method,
      path,
      status: Number(status),
      ms: Number.parseInt(duration, 10),
    };
  }
}

const LOG = `2025-04-12T08:31:02Z 203.0.113.7 GET /api/orders 200 142ms
2025-04-12T08:31:02Z 198.51.100.23 POST /api/login 401 63ms
2025-04-12T08:31:03Z 203.0.113.7 GET /api/orders 502 1180ms
2025-04-12T08:31:03Z 192.0.2.88 GET /static/app.js 200 12ms
2025-04-12T08:31:04Z 198.51.100.23 POST /api/login 401 58ms
2025-04-12T08:31:05Z 192.0.2.88 GET /api/orders/9 500 2400ms
2025-04-12T08:31:06Z 203.0.113.7 DELETE /api/orders/9 403 21ms
2025-04-12T08:31:07Z 198.51.100.23 GET /api/profile 200 97ms`;

第三步:任务 A —— 找出最慢的 5xx 请求

这个任务需要全量数据来排序,所以惰性帮不上最后的忙,但前半段依然可以省掉中间数组:

const slowest = Iterator.from(parseAccessLog(LOG))
  .filter((r) => r.status >= 500)
  .toArray()                              // 这里才物化
  .sort((a, b) => b.ms - a.ms)
  .slice(0, 3)
  .map((r) => `${r.ms}ms  ${r.path}`);

console.log(slowest);
// ['2400ms  /api/orders/9', '1180ms  /api/orders']

和原来的写法相比,中间少了 map 产生的那一个完整数组,而且 filter 和 toArray 之间不需要再复制一次数据。

第四步:任务 B —— 统计 4xx 的路径分布

const tally = Iterator.from(parseAccessLog(LOG))
  .filter((r) => r.status >= 400 && r.status < 500)
  .reduce((acc, r) => {
    acc.set(r.path, (acc.get(r.path) ?? 0) + 1);
    return acc;
  }, new Map());

console.log([...tally]);
// [['/api/login', 2], ['/api/orders/9', 1]]

注意 reduce 的第二个参数不能省。这个任务里用 Map 做累加器比对象更合适,因为 URL 里可能包含 __proto__ 这种在普通对象上会踩坑的键名。

第五步:任务 C —— 验证惰性短路真的生效了

parsedLines = 0;

const alerts = Iterator.from(parseAccessLog(LOG))
  .filter((r) => r.status >= 500)
  .take(2)
  .toArray();

console.log(alerts.length);  // 2
console.log(parsedLines);    // 6

日志一共 8 行,但只解析了 6 行。第 7、8 行从头到尾没被碰过。

这个数字在真实场景里意味着什么?如果第 6 行之后就出现了足够多的 5xx,后面的几千万行全都可以跳过。在“只关心最近几条告警”的巡检脚本里,这个差别是分钟级的。

第六步:任务 D —— 分批上报错误

用前面写的 chunk,按两条一批打包:

const reports = [];

for (const batch of chunk(
  Iterator.from(parseAccessLog(LOG)).filter((r) => r.status >= 400),
  2
)) {
  reports.push(batch.map((r) => `${r.status} ${r.path}`));
}

console.log(reports);
// [
//   ['401 /api/login', '502 /api/orders'],
//   ['401 /api/login', '500 /api/orders/9'],
//   ['403 /api/orders/9']
// ]

把 reports.push 换成 await fetch(...),就是一个内存友好的批量上报器。每一批处理完,上一批的数据就可以被回收了。

该用和不该用的分界线

迭代器不是银弹,用错地方反而更慢、更难读。

适合用的场景:

  • 数据源本身是流式的(文件、网络、数据库游标),不可能一次性拿到全部;
  • 处理链路很长,中间步骤多,每一步都会产生大数组;
  • 最终只需要结果集的一小部分(前 N 条、第一个匹配项、任意一个满足条件的);
  • 数据量无限或者接近无限,比如持续产生的传感器读数。

不该用的场景:

  • 数据量在几万条以内。这种规模下数组方法通常更快,因为迭代器的每次 next() 都有调用开销,而数组的 map / filter 是引擎高度优化的路径。
  • 需要随机访问或者反复遍历同一份数据。arr[100] 这种操作迭代器做不到,硬要用就得先 toArray()。
  • 链式调用只有两三个步骤。这时候省下来的中间数组还不如代码可读性值钱。

还有一个调试层面的代价得提前知道:迭代器链中间是看不到值的。写数组写法时,你可以在 filter 和 map 之间敲一行 console.log 立刻看到中间结果;迭代器链上你得插入一个 map 做副作用,或者临时加一个 toArray(),调完再删掉。这个来回会消耗掉不少时间。

浏览器与运行时支持

Iterator Helpers 进入 ES2025 之后,三大引擎的落地时间点如下:

  • Chrome / Edge 122(2024 年 2 月)
  • Firefox 131(2024 年 10 月)
  • Safari 18.4(2025 年 3 月)

Node.js 方面,22 及以上版本随 V8 一起带上了。当 Safari 18.4 落地的时候,这组方法在所有主流浏览器里才算真正可用。

如果项目需要覆盖更老的运行时,有两条路:

  1. 引入 core-js 的 polyfill(3.36 版本之后包含 Iterator Helpers);
  2. 自己写一层薄封装,用生成器实现 map / filter / take 三个最常用的方法,其余按需补。

如果只是想在 Node 脚本里用,最低版本卡到 Node 22 就能免掉 polyfill,这是最省事的做法。

最后一点经验

把日志清洗脚本改完之后,我最大的收获不是内存数字,而是对“数据什么时候被真正处理”这件事变得敏感了。以前写 filter 的时候不会去想它什么时候执行,因为数组方法总是立刻执行、立刻返回。用了迭代器之后,每一处 yield 都在提醒我:这里只是描述了一个步骤,真正干活是下游来拉的时候。

这种思维方式在处理流式数据时几乎是必需的,而 Iterator Helpers 恰好把这种思维方式的入门门槛降到了和数组方法一样的高度。

Iterator Helpers 实战:用惰性管道重写 JavaScript 日志清洗代码
收藏 (0) 打赏

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

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

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

淘吗网 javascript Iterator Helpers 实战:用惰性管道重写 JavaScript 日志清洗代码 https://www.taomawang.com/web/javascript/2916.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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