Iterator Helpers 实战:给爆内存的日志筛选脚本换一套惰性流水线

上个月同事扔过来一个脚本让我看一眼。需求很朴素:从一份商品导出的 JSONL 文件里,挑出库存告急、且还在上架状态的商品,取前 30 条,输出成制表符分隔的文本丢给运营。

文件 8 万行的时候,脚本跑 1.2 秒。行数涨到 400 万之后,风扇起飞,进程被系统 kill 掉。

代码本身挑不出语法毛病:

const text = fs.readFileSync('./products.jsonl', 'utf8');
const rows = text.split('n');

const alerts = rows
  .filter(Boolean)
  .map(line => JSON.parse(line))
  .filter(p => p.stock < 10 && p.onSale)
  .map(p => `${p.sku}t${p.name}t${p.stock}`)
  .slice(0, 30);

每个方法都用对了,链式调用看起来也清爽。但它同时做了三件很贵的事,而这三件事在大文件上会被放大到致命。

先看清楚钱花在哪了

第一笔开销在 split('n')。它一次性造出一个长度等于行数的数组。400 万行就是 400 万个字符串(V8 里 split 产出的都是独立字符串,不是视图),再加上数组本身的指针开销,峰值内存直接往 1.5 GB 以上走。而且这一步是”全量”的,不管后面只需要 30 条结果。

第二笔开销是中间数组。filter 产出一个,map 再产出一个,下一个 filter 又产出一个。同一批数据在内存里同时存在三四份,垃圾回收器在后面追着跑。

第三笔开销是白干。slice(0, 30) 只想要 30 条,但前面所有步骤都已经把 400 万行全部解析成对象了。剩下 399.9 万次 JSON.parse 的 CPU 时间纯属浪费。

这三个问题有一个共同的解法:让数据在流水线上”按需流动”,而不是”先全量展开、再层层过滤”。这就是 Iterator Helpers 要解决的事。

Iterator Helpers 到底是什么

简单说,它是被挂到 Iterator.prototype 上的一批方法:map、filter、take、drop、flatMap、reduce、toArray、forEach、some、every、find。名字和数组上的那些几乎一一对应,但行为完全不同。

数组上的 map 是”立刻算完,给你一个新数组”。迭代器上的 map 是”先记下这个转换操作,等你要结果的时候再算”。前者是急切的,后者是懒惰的。

既然是懒的,那什么时候才真正计算?答案是遇到”终结操作”的时候。toArray、reduce、forEach、some、every、find 属于这一类,它们会拉动整条流水线开始运转。

任何实现了迭代器协议的对象都能用上这些方法:生成器对象、Map 和 Set 的迭代器、arr.values() 的返回值,当然还有你自己写的对象。

动手改造

回到那个脚本。第一步,把 split 换成生成器:

function* splitLines(text) {
  let start = 0;
  while (start < text.length) {
    const end = text.indexOf('n', start);
    if (end === -1) {
      const tail = text.slice(start);
      if (tail) yield tail;
      return;
    }
    yield text.slice(start, end);
    start = end + 1;
  }
}

这里有个容易被忽略的细节:V8 里 String.prototype.slice 返回的是 SlicedString,也就是指向原字符串的一个视图,不复制内容。所以这个生成器每吐出一行,成本几乎为零,内存里始终只留着一份大字符串。这是它比 split 便宜的关键。

第二步,把链式调用换成迭代器版本。改动其实只有一处——在开头加一个 .values() 之类的入口,在结尾加一个 .toArray():

const alerts = splitLines(text)
  .filter(line => line.length > 0)
  .map(line => JSON.parse(line))
  .filter(p => p.stock < 10 && p.onSale)
  .map(p => `${p.sku}t${p.name}t${p.stock}`)
  .take(30)
  .toArray();

看起来只改了两行,但执行模型彻底变了。

splitLines(text) 返回一个生成器对象,此时它一行都还没读,游标停在 0。.filter(...) 返回一个新的迭代器,它内部记录着”我要过滤上游”这件事。.map(...) 同样只是返回一个包装迭代器。这一整串调用跑完,实际执行的计算量是零。

直到 .toArray() 出现,它开始向 .take(30) 要第一个元素。.take 转手向它上面的 .map 要,.map 向 .filter 要,.filter 向生成器要。生成器这才开始 indexOf、slice,吐出第一行。这行数据顺着链条往下走:过滤、解析、再过滤、再转换,最后被 .take 收进结果。

然后是第二个、第三个……直到 .take 凑满 30 个。这时它停止向上游要数据,整条流水线随之停摆。

关键就在这:如果第 800 行就凑满了 30 个结果,那第 801 行到第 400 万行压根不会被读到,更不会被解析。省下的不只是内存,还有实打实的 CPU 时间。

五个真会踩到的坑

1. 数组上没有 take

最常见的报错是这个:

[1, 2, 3].take(2);
// TypeError: [1,2,3].take is not a function

数组是 Array 类型的实例,它有自己的 map、filter,但不继承 Iterator.prototype。想用迭代器方法,得先从数组拿到一个迭代器:

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

2. 迭代器是一次性的

这一点和数组的直觉差得最远:

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

迭代器内部维护着”读到哪了”的状态,被消费过就没了,不会重置。所以别把迭代器存进变量反复用。需要复用就存生成器函数本身,每次调用重新生成一个。

3. 一个展开运算符就把惰性打回原形

const rows = [...splitLines(hugeText)]; // 立刻全量展开,白改了

展开运算符、Array.from、解构赋值都会强制消费整个迭代器。如果你在流水线中途写了这些,前面的惰性设计就全废了。要用 .toArray(),并且只在链条最末端用。

4. take 会关闭上游,别指望它”暂停”

这个行为其实挺贴心。生成器里的 finally 会在 take 取够之后被触发:

function* tick() {
  try {
    let i = 0;
    while (true) yield i++;
  } finally {
    console.log('上游已关闭');
  }
}

for (const n of tick().take(3)) {
  console.log(n);
}
// 0
// 1
// 2
// 上游已关闭

如果你在生成器里持有数据库游标、文件句柄之类的资源,依赖这个行为做清理是靠谱的。但反过来,如果你希望”取 3 个之后还能接着取第 4 个”,那就得换个思路,别用 take。

5. 迭代器没有 length,也不能倒着读

迭代器的本质是”只能往前走的游标”。它没有 length,没有索引,不能排序,不能随机访问。需要这些能力的时候,老老实实用数组。技术选型要匹配场景,不是新的就更好。

什么时候它反而更慢

得说句公道话:迭代器方法并不总是更快。

数组的 map 和 filter 是 V8 里被优化到极致的原生实现,还带了内联缓存等一系列加速手段。而迭代器流水线每处理一个元素,都要多走几层函数调用的转发。数据量小的时候(几千到几万条),数组版本往往明显更快,量级差个两三倍很正常。

所以选择标准不是”数据大就用迭代器”,而是看两件事:

  • 有没有提前终止的机会。像 take、find、some 这种能提前收工的,迭代器优势巨大,数据量越大越明显。
  • 中间数组的内存代价能不能接受。如果每条记录解析出来都是几百字节的对象,几百万元素,那中间数组带来的内存压力和 GC 停顿,通常比那点函数调用开销更值得担心。

反过来,如果数据量不大、或者你确实需要排序和多次遍历,那数组链式调用依然是更好的选择。

完整的可运行脚本

把前面的碎片拼起来,这就是最终版本:

// alerts.mjs
// 用法: node alerts.mjs ./products.jsonl 30
import fs from 'node:fs';

const FILE = process.argv[2] ?? './products.jsonl';
const LIMIT = Number(process.argv[3] ?? 30);

function* splitLines(text) {
  let start = 0;
  while (start < text.length) {
    const end = text.indexOf('n', start);
    if (end === -1) {
      const tail = text.slice(start);
      if (tail) yield tail;
      return;
    }
    yield text.slice(start, end);
    start = end + 1;
  }
}

function parse(line) {
  if (!line) return null;
  try {
    return JSON.parse(line);
  } catch {
    return null;
  }
}

function isLowStock(p) {
  return p !== null && p.stock < 10 && p.onSale === true;
}

function format(p) {
  return `${p.sku}t${p.name}t${p.stock}`;
}

const text = fs.readFileSync(FILE, 'utf8');

const alerts = splitLines(text)
  .filter(line => line.length > 0)
  .map(parse)
  .filter(isLowStock)
  .map(format)
  .take(LIMIT)
  .toArray();

process.stdout.write(alerts.join('n') + 'n');

几处值得说明的设计:

parse 里做了容错,坏行返回 null 而不是抛异常。JSONL 文件里混进半行数据是常事,整批任务不该因为一行坏数据中断。

isLowStock 里先判 p !== null,因为上一步可能产出 null。把 null 检查放在条件里而不是单独一个 filter,少一次遍历开销。

format 放在 take 之前,但它只会对通过筛选的少数记录执行,代价可以忽略。这个顺序很重要——如果把它放到 take 之后,就得先 toArray 拿到对象,再转一次数组,反而更啰嗦。

环境支持和降级方案

Chrome 122 / Node.js 22 之后可以直接用,Firefox 131 跟上,Safari 到 18.4 才补齐。在旧环境里会直接报 is not a function。

上线前做个运行时探测比查兼容表更实在:

const supported = typeof [].values().take === 'function';
console.log(supported); // true 表示当前环境可用

需要支持老浏览器的话,有几个选择:引入 core-js 的 iterator helpers 模块;或者把流水线退化成一个普通的 for...of 循环加 break。后者的可读性其实也不差,而且零依赖:

const alerts = [];
for (const line of splitLines(text)) {
  if (!line) continue;
  const p = parse(line);
  if (!isLowStock(p)) continue;
  alerts.push(format(p));
  if (alerts.length === LIMIT) break;
}

说到底,Iterator Helpers 真正提供的价值不是”新语法”,而是把”边读边过滤、够用就停”这件事写成了一条能看懂的声明式链条。如果你的脚本里也存在”先展开、再过滤、最后只取一小撮”的结构,那这三十行改造大概率值得动手试一次。

Iterator Helpers 实战:给爆内存的日志筛选脚本换一套惰性流水线
收藏 (0) 打赏

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

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

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

淘吗网 javascript Iterator Helpers 实战:给爆内存的日志筛选脚本换一套惰性流水线 https://www.taomawang.com/web/javascript/2842.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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