我们的日志清洗脚本一直是这么写的:把整个文件读成字符串,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 万条。执行过程是这样的:
filter遍历完 200 万条,新建一个数组装下所有 5xx 记录;map再遍历一遍上一步的结果,又新建一个数组;sort原地排序,但排序本身要求全量数据在内存里;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 落地的时候,这组方法在所有主流浏览器里才算真正可用。
如果项目需要覆盖更老的运行时,有两条路:
- 引入
core-js的 polyfill(3.36 版本之后包含 Iterator Helpers); - 自己写一层薄封装,用生成器实现
map/filter/take三个最常用的方法,其余按需补。
如果只是想在 Node 脚本里用,最低版本卡到 Node 22 就能免掉 polyfill,这是最省事的做法。
最后一点经验
把日志清洗脚本改完之后,我最大的收获不是内存数字,而是对“数据什么时候被真正处理”这件事变得敏感了。以前写 filter 的时候不会去想它什么时候执行,因为数组方法总是立刻执行、立刻返回。用了迭代器之后,每一处 yield 都在提醒我:这里只是描述了一个步骤,真正干活是下游来拉的时候。
这种思维方式在处理流式数据时几乎是必需的,而 Iterator Helpers 恰好把这种思维方式的入门门槛降到了和数组方法一样的高度。

