前阵子帮同事看一个脚本,逻辑很简单:读一份日志文件,挑出 ERROR 级别的记录,取前 50 条统计一下。文件 200MB 左右,跑起来直接把 Node 的内存顶到 4G 然后 OOM。
代码长这样:
const result = raw
.split('n')
.filter(line => line.includes('ERROR'))
.map(parseLine)
.filter(rec => rec && rec.level === 'ERROR')
.slice(0, 50);
看上去没问题,直觉上还挺优雅。但每一步都在造一个新数组,而且——这是最要命的地方——slice(0, 50) 明明只想要 50 条,前面几十万条记录该解析的解析了,该分配的对象也全都分配了。计算整个白做,内存也整个白占。
这不是算法问题,是数据结构选错了。数组链式调用天然是”急切的、全量的”,而这里的需求是”惰性的、流式的”。
JavaScript 现在有原生的解决方案:迭代器辅助方法(Iterator Helpers)。
一、环境确认与最小验证
这个提案已经落地到所有主流引擎了,时间线大概是:
- Chrome / Edge 122+(V8 12.2,2024 年 2 月)
- Firefox 117+(2023 年 9 月)
- Safari 18.0+(2024 年 9 月)
- Node.js 22 及以上
打开控制台跑一行就能确认:
console.log(typeof [].values().map);
// "function" —— 有的话就说明支持
先用最短的例子感受一下写法上的变化:
// 数组链式调用
const a = [1, 2, 3, 4, 5]
.map(x => x * 2)
.filter(x => x > 4);
// [6, 8, 10]
// 迭代器辅助方法
const b = [1, 2, 3, 4, 5]
.values()
.map(x => x * 2)
.filter(x => x > 4)
.toArray();
// [6, 8, 10]
多出来的两个东西:开头要拿到一个迭代器(.values() 或者 Iterator.from()),结尾要显式收口(.toArray())。中间那些 map、filter 的写法一模一样。
差别全在”什么时候执行”上。
const it = [1, 2, 3].values()
.map(x => { console.log('map 被调用', x); return x * 2; })
.filter(x => x > 2);
console.log('到这一步为止,什么都没打印');
const out = it.toArray();
// 现在才开始逐个打印 map 被调用 1 / 2 / 3
console.log(out); // [4, 6]
这两段日志的顺序就是全部秘密。迭代器辅助方法不做任何事,直到你终结它。每个 .map()、.filter() 只是包了一层新的迭代器,返回值永远是迭代器本身。
而”逐个”这两个字同样关键:数据是一条一条流过整条管道的,不是每一站都攒一堆再交给下一站。
二、十二个方法,按用途分三类
辅助方法挂在一个共享的 %IteratorPrototype% 上,所以任何迭代器都能直接用——包括生成器对象、Map / Set 的迭代器、Array.prototype.values()。
变换类
it.map(fn) // 逐个映射
it.filter(fn) // 逐个筛选
it.flatMap(fn) // 映射后展开一层
it.drop(n) // 跳过前 n 项
it.take(n) // 最多取 n 项,取满就终止上游
消费类(调用后迭代器就废了)
it.toArray() // 收成数组
it.reduce(fn, i) // 归约
it.forEach(fn) // 遍历,返回 undefined
it.some(fn) / it.every(fn) / it.find(fn)
入口
Iterator.from(x) // 可迭代对象或迭代器 → 迭代器
Iterator.from 有个细节值得知道:如果传进去的已经是迭代器,它会原样返回,不会多包一层。所以写工具函数的时候可以无脑用它做归一化。
另外注意,take 是唯一一个会主动”打断”上游的方法。取够数量之后它就不再调用 next(),上游的生产逻辑自然停在那里。这个特性是后面所有优化的基础。
三、案例:把日志管道改成惰性的
回到开头那个 OOM 的脚本。分两步改。
第一步:先把 split 干掉
很多人改到 .values() 就以为完事了,其实 raw.split('n') 这一步本身就制造了一个几十万元素的数组。要用迭代器,得先有个惰性的按行切分:
function* splitLines(text) {
let start = 0;
while (true) {
const i = text.indexOf('n', start);
if (i === -1) {
if (start < text.length) yield text.slice(start);
return;
}
yield text.slice(start, i);
start = i + 1;
}
}
逐行切分这件事,用生成器写反而比 split 更自然:找到换行符就 yield,不用管后面还剩多少。
第二步:换成迭代器管道
function parseLine(line) {
const m = /^(S+)s+(ERROR|WARN|INFO)s+[(w+)]s+(.*)$/.exec(line);
if (!m) return null;
return { ts: m[1], level: m[2], mod: m[3], msg: m[4] };
}
const errors = Iterator.from(splitLines(raw))
.map(parseLine)
.filter(Boolean)
.filter(rec => rec.level === 'ERROR')
.take(50)
.toArray();
关键在于执行顺序被彻底反转了。take(50) 会在第 50 条 ERROR 被找到的那一刻停止向下游索要数据,于是上面的 map 也停止向 filter 要,splitLines 也停止 yield。
实际跑下来,如果第 3000 行就凑齐了 50 条 ERROR,那 3000 行之后的内容连 indexOf 都不会执行,更不用说正则匹配和对象分配。
想亲眼看到这个效果,加个计数器:
let mapped = 0;
Iterator.from(splitLines(raw))
.map(line => { mapped++; return parseLine(line); })
.filter(Boolean)
.filter(r => r.level === 'ERROR')
.take(3)
.toArray();
console.log(mapped); // 假设前三行里就凑齐了 3 条,这里就是 3
把同样的计数器加到数组版本上,你会看到 mapped 等于全部行数。差距就是这么来的。
顺手说一下流式读取
上面这个版本虽然不再制造中间数组,但整个 raw 字符串还在内存里。真要处理超大文件,源头应该换成流:
async function* readLines(readableStream) {
const decoder = new TextDecoder();
let buf = '';
for await (const chunk of readableStream) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf('n')) >= 0) {
yield buf.slice(0, i);
buf = buf.slice(i + 1);
}
}
if (buf) yield buf;
}
但这里有个现实约束:同步的迭代器辅助方法用不到异步迭代器上。for await (const line of readLines(...)) 可以正常遍历,但你不能写 readLines(...).map(...)——异步迭代器的辅助方法还在提案阶段,没有落地到引擎里。
眼下的做法是:用 for await 把异步源转成一个同步数组(或分批处理),再交给迭代器管道。或者干脆手写 async function* 做变换,等标准跟上。
四、案例:数组做不到的无限序列
惰性求值最直观的价值,其实在于那些”根本没有终点”的数据源。
比如取前 10 个质数。用数组写不出来——你没法先造一个包含所有自然数的数组。用迭代器则毫无压力:
function* naturals(start = 2) {
let n = start;
while (true) yield n++;
}
function isPrime(n) {
if (n < 2) return false;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) return false;
}
return true;
}
const first10 = naturals()
.filter(isPrime)
.take(10)
.toArray();
console.log(first10);
// [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
生成器对象本身就是迭代器,所以可以直接 .filter(),不需要 Iterator.from 包一层。
这个模式用处比看起来多。像分页拉取、轮询重试、读不可知长度的输入流、遍历 DOM 的某一层节点,本质上都是”我不知道有多少,但我需要多少就拿多少”。这类场景用数组思维去写,要么写不出来,要么写出个带手写 break 的怪循环。
五、案例:自定义惰性操作符
内置的十二个方法不可能覆盖所有需求。好在这里的扩展方式特别轻——写生成器就行,它们自动兼容整条管道。
比如按某个字段去重,同时保持顺序。如果攒成数组再处理,内存又上去了;用生成器就是个 Set 加一个循环:
function* uniqueBy(source, keyFn) {
const seen = new Set();
for (const item of source) {
const k = keyFn(item);
if (seen.has(k)) continue;
seen.add(k);
yield item;
}
}
它会自动接在已有的管道上:
const activeUsers = Iterator.from(userStream)
.filter(u => u.lastActive > WEEK_AGO)
.pipe(uniqueBy, u => u.id) // 见下方说明
.take(100)
.toArray();
上面的 .pipe() 不在标准里,是我自己加的顺手工具,十几行就够:
if (!Iterator.prototype.pipe) {
Object.defineProperty(Iterator.prototype, 'pipe', {
value: function (fn, ...args) {
return fn(this, ...args);
},
writable: true,
configurable: true,
});
}
有了它,自己写的生成器操作符和内置方法的写法就统一了,管道读起来是直的一条线。
再举一个:分块。处理批量 API 调用时特别有用,每 50 条发一次请求。
function* chunk(source, size) {
let buf = [];
for (const item of source) {
buf.push(item);
if (buf.length === size) {
yield buf;
buf = [];
}
}
if (buf.length) yield buf;
}
for (const batch of chunk(Iterator.from(recordStream).map(normalize), 50)) {
await sendBatch(batch);
}
注意这里 chunk 每次 yield 的都是一个新数组,所以调用方可以放心持有。但如果写的是”复用同一个 buffer”的版本,就得留个心眼——下游一旦异步或延迟处理,数组内容会被覆盖。这是我踩过的真实坑,调试了两个小时。
六、六个必须提前知道的坑
1. 一次性消费,用完就没了
const it = [1, 2, 3].values().map(x => x * 2);
console.log(it.toArray()); // [2, 4, 6]
console.log(it.toArray()); // [] ← 不是 [2, 4, 6]
第二次是空数组,而且不会报错。这个行为在”先把结果算出来缓存一下”的场景里特别容易出事。要复用得重新构造一遍迭代器。
2. 忘记收口,代码静默不执行
Iterator.from(data).map(f).filter(g);
// 什么都不会发生,也不报错
这是新手最常见的失误,尤其是从数组链式调用切换过来的时候——那边点了最后一个括号就立刻执行,这边还差一个 .toArray() 或者 .forEach()。
ESLint 有个 no-unused-expressions 规则能捞出一部分,但靠工具不如靠肌肉记忆:看到迭代器管道的末尾没有终结方法,就说明漏了。
3. 回调参数只有值,没有索引
数组的 map 回调签名是 (value, index, array),迭代器的只有 (value)。下面这段从数组版改成迭代器版,会静默出错:
// 数组版:正常
[10, 20, 30].map((v, i) => `${i}:${v}`);
// ["0:10", "1:20", "2:30"]
// 迭代器版:i 永远是 undefined
[10, 20, 30].values().map((v, i) => `${i}:${v}`).toArray();
// ["undefined:10", "undefined:20", "undefined:30"]
不报错,只是结果错了。要索引的话得自己维护计数器,或者先 toArray() 用数组那套。
4. 没有 sort、slice、length
迭代器上不存在 .sort(),因为它需要看到全部数据才能排序——这跟惰性的前提直接冲突。.slice() 和 .length 同理。
遇到需要排序的场景,做法是 toArray() 之后再排。这不是妥协,是问题性质决定的:你要么流式,要么随机访问,很难同时要。
5. 调试信息全丢了
数组管道可以在任意位置 console.log 看中间结果:
const step1 = data.filter(f);
console.log(step1); // 能看到东西
const step2 = step1.map(g);
迭代器管道不行。你 console.log(it) 只会得到一个不透明的 Iterator Helper 对象,展开也没有内容。
调试办法是在变换函数里打点:
.map(x => { console.log('map 输入', x); return f(x); })
或者临时插一个 take(10).toArray() 看前十条长什么样。前几次会比较别扭,习惯之后其实还好——因为管道里的每一步逻辑都很小,问题定位往往比数组版更快。
6. 短管道不一定更快
迭代器辅助方法每次 next() 都要走一遍协议,每个操作符都是一层函数调用。数据量小、又没有提前终止的时候,它比数组版慢。
粗略的适用区间:
- 少于几千条、整条管道要跑完 → 用数组,别折腾
- 有几万条以上,或者带
take/find/some这类提前终止 → 迭代器优势明显 - 数据源本身是无限的 → 只能用迭代器
另外,如果管道需要多次遍历同一批数据(比如先算总数再算平均值),迭代器的一次性特性会让这个需求变得别扭,老老实实用数组。
七、最后给个选择清单
我自己的判断顺序是这样的,从下往上问:
- 数据源是不是无限的、或者不知道有多长?→ 迭代器
- 是不是只需要前 N 条?→ 迭代器
- 数据量是不是上万了?→ 迭代器
- 中间要不要排序?→ 数组
- 同一批数据要不要反复用?→ 数组
- 以上都不是 → 数组,能少写两个方法调用
大部分业务代码其实落在最后一条上。日常 CRUD 里那种三五十条数据的 .map().filter(),换成迭代器纯属给自己找麻烦,还慢一点。
迭代器辅助方法真正的战场是数据管道:日志处理、ETL 脚本、批量导入导出、爬虫翻页、流式解析。这些地方的共同点是数据量大、往往只需要一部分、并且天然按顺序来。在这种场景下,把 .values() 和 .toArray() 加上,往往比调优任何算法都管用。

