上个月接了个内部工具需求,做一个前端日志分析器。需求本身不复杂:用户拖一个 log 文件进来,工具跑一遍,统计各种 HTTP 状态码、错误类型、慢请求分布,最后画几张图。
我想着这不就是 Array 那一套吗,一行行读出来扔进数组,然后 filter、map、reduce 三连,一小时内搞定。本地拿一个 12MB 的测试日志跑了一下,完美。
结果用户端一次真实场景,拖了一个 340MB 的日志文件进来。
Chrome 直接 white screen。打开 Performance 面板一看,堆内存瞬间飙到 2.8GB,页签直接 crash 掉。事后分析,光是那一句 lines.filter(l => l.includes('ERROR')),就在堆上分配了一个包含一百多万个字符串的新数组。后面还有 .map()、.filter() 又叠了两个数组。整个过程其实只需要一次遍历,却因为链式调用被迫做了五六次遍历和五六次内存分配。
那天晚上翻 TC39 提案的时候,想起了 Iterator Helpers。之前扫过一眼没当回事,这次是真派上用场了。
Iterator Helpers 到底是个啥
简单说,就是把数组上那些熟得不能再熟的 map、filter、take、drop、flatMap、reduce、some、every、find 这些方法,挂到了 Iterator 原型上。你在数组上会写的链式调用,现在也能在迭代器上写。
听起来好像只是”位置换了一下”,但背后是本质的区别。
数组的方法会立即求值。[1,2,3].map(f) 是”现在就把每个元素跑一遍 f,把结果装进一个新数组”。而迭代器的方法是惰性的。它不会立刻执行,只是记录一个待执行的操作序列,等你真正去 for...of、toArray() 或者调用终结方法的时候,才一个元素一个元素地”拉”出去。
这个区别在处理小数组时看不出来,但一旦数据量上到几十万、几百万,就不一样了。链式调用数组方法会产生多个中间数组,每个中间数组都占用完整的内存;而迭代器是流水线式的,同一时刻内存里最多只有当前正在处理的这一个元素。
浏览器支持情况
写之前先讲下支持面,免得有人踩坑。Chrome 122 开始默认开启,Firefox 131、Safari 18.4 也都跟上了。Node.js 从 22 开始可用,Express、Koa 那一套之后写数据处理会方便很多。
如果一定要兼容老的浏览器,可以考虑 core-js 的 polyfill,但性能收益会打折。要我说,这种新特性用在内部工具或者已经明确过基线的前端项目里最合适,别绑死在支持 IE 的祖传项目上。
第一个例子:跟数组写法正面对比
先看一段经典代码。假设有一个用户列表,要筛出成年用户,计算每个人的全名,然后取前 5 个。
数组写法:
const result = users
.filter(u => u.age >= 18)
.map(u => u.firstName + ' ' + u.lastName)
.slice(0, 5);
迭代器写法:
const result = users.values()
.filter(u => u.age >= 18)
.map(u => u.firstName + ' ' + u.lastName)
.take(5)
.toArray();
结构几乎一样,但执行方式有本质差别。
数组写法里,filter 会遍历整个 users,不管你能不能立刻凑够 5 个成年人都会跑完,然后创建一个新的数组。map 同样遍历一遍完整的新数组。最后 slice 再生成第三个数组。三个数组,三次完整遍历。
迭代器写法里,链条挂上之后不会立刻执行。toArray() 开始拉数据,每拉一个元素,依次穿过 filter、map,如果通过了就放进结果,凑够 5 个就停下。整个过程只创建了一个长度不超过 5 的结果数组,而且如果 users 里前 10 个就有 5 个成年人,后面的元素一个都不会被处理。
take 这个方法是关键。它相当于一个”熔断器”,一旦下游已经拿够了,就通知上游别再送了。数组方法里没有对应的东西,你只能用 slice 事后裁剪,但上游已经跑完了。
案例:百万行日志流式分析
回到我那个崩溃的日志分析工具。日志格式大概是这样的:
2026-01-18T09:12:34.221Z [INFO] GET /api/products 200 45ms user=10023
2026-01-18T09:12:34.428Z [ERROR] POST /api/orders 500 1280ms user=10087 err=DB_TIMEOUT
2026-01-18T09:12:34.511Z [INFO] GET /api/cart 200 22ms user=10023
...
我要统计的是:每种状态码出现的次数、每种错误类型出现的次数、以及耗时超过 500ms 的请求列表(最多展示 100 条)。
用数组写,文件一大就爆内存。用迭代器写,可以做到边读边算,内存占用几乎是常数级别。
先写一个把字符串按行切开的生成器:
function* splitLines(text) {
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) { // 换行符
yield text.slice(start, i);
start = i + 1;
}
}
if (start < text.length) {
yield text.slice(start);
}
}
这个生成器本身也是惰性的,for...of 拉一行它才切一行。这样就不会一次性把整个文件切开生成上百万个字符串。
然后定义解析函数,把一行日志变成一个对象:
const LINE_RE = /^(S+) [(w+)] (w+) (S+) (d{3}) (d+)ms user=(d+)(?: err=(w+))?/;
function parseLine(line) {
const m = LINE_RE.exec(line);
if (!m) return null;
return {
time: m[1],
level: m[2],
method: m[3],
path: m[4],
status: Number(m[5]),
cost: Number(m[6]),
userId: m[7],
error: m[8] || null,
};
}
接下来是分析主逻辑。我用了迭代器链:
function analyze(rawText) {
const statusCount = new Map();
const errorCount = new Map();
const slowRequests = [];
splitLines(rawText)
.map(parseLine)
.filter(line => line !== null)
.forEach(line => {
// 状态码统计
statusCount.set(line.status, (statusCount.get(line.status) || 0) + 1);
// 错误类型统计
if (line.error) {
errorCount.set(line.error, (errorCount.get(line.error) || 0) + 1);
}
// 慢请求,最多存 100 条
if (line.cost > 500 && slowRequests.length < 100) {
slowRequests.push({
path: line.path,
cost: line.cost,
time: line.time,
});
}
});
return {
status: Object.fromEntries(statusCount),
errors: Object.fromEntries(errorCount),
slow: slowRequests,
};
}
整个过程只遍历了一遍输入,内存里同时存在的对象数量非常有限。慢请求那个数组还主动做了 100 条的上限,防止某些异常情况下全部堆进来。
对比一下原来的数组方案,光是 text.split('n') 这一步就会创建一个百万量级的字符串数组,接着 filter 再来一个,map 再来一个。三步下来,即便每步只占用一个引用,累积起来也是几百兆。
跟 AsyncGenerator 组合:处理来自网络的流
有读者可能注意到,上面那个 splitLines 拿到的还是整个字符串。这意味着你仍然需要一次性把文件读进内存。要真正做到流式,得用 ReadableStream。
好在这里的套路是一样的,只是把生成器换成异步生成器。先写一个从流里逐行读的异步生成器:
async function* streamLines(readable) {
const reader = readable.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
if (buffer.length > 0) yield buffer;
break;
}
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf('n')) !== -1) {
yield buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
}
}
} finally {
reader.releaseLock();
}
}
拿文件输入的流:
const file = document.querySelector('input[type=file]').files[0];
const stream = file.stream();
const textStream = stream.pipeThrough(new TextDecoderStream());
然后把它接到分析逻辑上。异步迭代器不能直接用同步的 map/filter,需要用 for await 手写循环,或者用社区的几个辅助工具。我这里用的是 for await 加 if 判断:
async function analyzeStream(textStream) {
const statusCount = new Map();
const errorCount = new Map();
const slowRequests = [];
for await (const line of streamLines(textStream.getReader ? textStream : textStream)) {
const parsed = parseLine(line);
if (!parsed) continue;
statusCount.set(parsed.status, (statusCount.get(parsed.status) || 0) + 1);
if (parsed.error) {
errorCount.set(parsed.error, (errorCount.get(parsed.error) || 0) + 1);
}
if (parsed.cost > 500 && slowRequests.length < 100) {
slowRequests.push({
path: parsed.path,
cost: parsed.cost,
time: parsed.time,
});
}
}
return {
status: Object.fromEntries(statusCount),
errors: Object.fromEntries(errorCount),
slow: slowRequests,
};
}
这是最关键的一步。用 streamLines 拉行,读多少处理多少,一个 340MB 的文件跑下来,峰值内存只有不到 60MB。因为读进来的 chunk 一处理完就被丢掉,真正存在内存里的只有那些统计用的 Map 和数组。
性能对比数据
我在一台 MacBook Air M1 上做了组对比测试,用的是同一个 340MB 的日志文件。三套方案各跑 3 次,取中位数。
| 方案 | 峰值内存 | 总耗时 | 是否崩溃 |
|---|---|---|---|
| 数组链式 filter/map | 2.8GB | 页面崩溃 | 是 |
| 字符串 split + 迭代器 | 780MB | 4.2s | 否 |
| ReadableStream + AsyncGenerator | 58MB | 3.6s | 否 |
内存差别是数量级的,而耗时反过来还稍微快了一点。这一点我一开始有点意外,后来想通了:数组方案里 GC 要不停歇地回收那些巨大的中间数组,一次 major GC 就是几百毫秒;而流式方案里垃圾产生得慢,GC 压力小很多,虽然逻辑上多了一些读写的调度,但整体反而更快。
几个容易踩的坑
用顺手之后有些坑也就踩出来了,分享给各位。
坑一:迭代器不能复用。数组可以遍历很多遍,但迭代器是有状态的,遍历完就没了。如果你需要同时做几个不同的分析,要么重新创建迭代器,要么在一次遍历里把所有指标都算完。我一开始把同一个迭代器传给了两个分析函数,第二个函数跑出来结果是空的,还以为是哪写错了。
坑二:.values() 和 [Symbol.iterator]() 走的是同一个东西。但 Map 和 Set 上调用 .values() 拿到的迭代器内容跟数组不一样。Map 拿到的是 [key, value] 数组,Set 拿到的是元素本身。写链式调用的时候注意区分,必要时先 .map() 一下转格式。
坑三:take() 是短路的,但这个”短路”是相对的。如果后面还有 forEach 或者 reduce 之类的终结操作,take 会让整个上游链条在”够数了”之后停下来。但如果 take 后面还有别的元素转换方法,那”够数”的判断会推迟到真正拉取时,靠编译器或解释器优化。这一点跟 Rust 里 Iterator 的语义一样,理解了就明白。
坑四:异步迭代器上没有这些方法。目前 Iterator Helpers 只挂在同步的 Iterator 原型上,AsyncIterator 上还是得手动 for await。这个问题 TC39 已经在讨论,但短期不会落地。
坑五:toArray() 是大杀器也是陷阱。前面讲的所有好处,前提都是你不调用 toArray()。一旦调用,效果等同于把所有元素收集到一个数组里,内存优势瞬间消失。用 toArray 之前想一下,是不是真的需要拿到全部结果,还是只要能 forEach 遍历一下就够。
什么时候值得用,什么时候不值得
Iterator Helpers 不是银弹,它的优势场景很具体。
数据量大且需要多次转换时值得用。一旦数据量过万,链式数组方法的中间数组开销就开始显著了。十万级、百万级是 Iterator Helpers 的主场。
数据源是流、网络、文件时值得用。这是它最有价值的地方。把流处理写成生成器链,可读性比手动管理 buffer 好太多,而且天然带着背压(backpressure),不会把内存撑爆。
能提前终止的时候值得用。有 take、find、some、every 这些短路方法时,Iterator Helpers 能避免无谓的遍历。特别是处理一个”取前 N 个符合条件的”的场景,传统做法把整个列表跑完才发现只需要前 5 个,浪费很大。
小数据量、一次性操作、需要随机访问的场景不值得用。数组的 [i] 访问、sort、reverse 这些能力迭代器都没有,为了一点点内存优化把代码写复杂,不值当。数据量在千级以内,直接用数组方法就好。
最后
回到那个日志分析工具。改完之后,用户端拖 300 多兆的文件进去,跑下来稳定在 3 到 4 秒,内存峰值不到 60MB,业务同学也没再抱怨。整个过程最耗时间的地方其实不是写代码,是想明白”为什么要用迭代器而不是数组”。
这件事给我的感受是,写 JavaScript 的时候太容易落入一个舒适区——数组能解决一切。但数组这个抽象本身,其实是为”随机访问、可反复遍历”的集合设计的,它并不擅长”顺序处理、只需一遍”的场景。Iterator Helpers 补上的恰好是这块。把它当成工具箱里多一把趁手的工具,而不是把它当”更现代的数组”去用,这个心态可能更合适。

