上个月同事扔过来一个脚本让我看一眼。需求很朴素:从一份商品导出的 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 真正提供的价值不是”新语法”,而是把”边读边过滤、够用就停”这件事写成了一条能看懂的声明式链条。如果你的脚本里也存在”先展开、再过滤、最后只取一小撮”的结构,那这三十行改造大概率值得动手试一次。

