前阵子把后台的一个日志分析脚本重写了一遍。功能很简单——读一份 JSON Lines 格式的用户行为日志,做三件事:按日期统计每个页面的 PV、找出「付费用户」和「活跃用户」访问过的页面的交集与差集、把当天产生错误的记录挑出来。
原来的实现大概 60 行,用了三个 for 循环、两遍 map、一个 reduce 做分组。功能没问题,但每加一个筛选条件就要动结构,改到第三个需求的时候我决定推倒重写。
重写用了三个 ES2025 的特性:Iterator Helpers、Set 的原生集合运算、还有挂在 Object 上的 groupBy。最终代码从 60 行压到 15 行,而且处理大文件时内存占用明显下降——这一点是最意外的收获。
下面把重写过程完整走一遍。
Iterator Helpers:把链式操作搬到迭代器上
数组有 map、filter、reduce,这个大家都知道。问题是这些方法会一次性返回一个新数组。如果输入是 50 万行日志,split('n') 之后每一步都在内存里复制一遍。
Iterator Helpers 解决的正是这个问题。它把同一套链式 API 挂在 Iterator 接口上,但每一步都是惰性的——不调用终结方法,流就不走。
先看老代码:
function collectErrors(content) {
const lines = content.split('n');
const result = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
let record;
try {
record = JSON.parse(line);
} catch {
continue;
}
if (record.level === 'error') {
result.push(record);
}
}
return result;
}
新写法:
function collectErrors(content) {
return Iterator.from(content.split('n'))
.map(line => line.trim())
.filter(line => line.length > 0)
.flatMap(line => {
try {
return [JSON.parse(line)];
} catch {
return [];
}
})
.filter(record => record.level === 'error')
.toArray();
}
注意 flatMap 那里——正常情况下 map 就够了,但 JSON 解析失败时我们要「丢弃」这个元素,而不是压进去一个 null。flatMap 返回空数组就相当于丢弃,返回单元素数组就相当于保留。这是用迭代器链做容错的常见手法。
关键点是,Iterator.from 接受任何可迭代对象,返回一个 Iterator。之后 map、filter、take、drop、flatMap、reduce、toArray、forEach、some、every、find 这些方法就能链式调用了。
跟数组最大的差别是执行时机。上面那条链,在 .toArray() 跑起来之前,一行都没执行过。
这个特性在真实场景里怎么用?假设需求改成「找出前 10 条错误就够了」:
const top10 = Iterator.from(lines)
.map(parseLine)
.filter(record => record && record.level === 'error')
.take(10)
.toArray();
take(10) 会让迭代器在第 10 条错误出现后立刻停止。如果这 10 条错误恰好是日志里的前 200 行,那后面几万行根本不会被读——用数组写的话,filter 还是得把整个数组扫完。
这就是惰性带来的收益。跟 for...break 类似,但代码结构清楚得多。
Set 的原生集合运算:不用再手动写循环了
原来的脚本里有一段逻辑,用来找「付费用户和活跃用户都访问过的页面」:
const sharedPages = [];
for (const page of activePages) {
if (paidPages.has(page)) {
sharedPages.push(page);
}
}
一行 for 循环,看起来人畜无害,问题是这只是六种组合里的一种。如果产品经理后来说「再给我看看只在活跃用户里出现、付费用户没访问过的页面」,你就得再写一个循环。
ES2025 给 Set 补上了六个方法:
const a = new Set(['/home', '/search', '/detail']);
const b = new Set(['/search', '/cart']);
a.intersection(b); // Set {'/search'}
a.union(b); // Set {'/home', '/search', '/detail', '/cart'}
a.difference(b); // Set {'/home', '/detail'}
a.symmetricDifference(b); // Set {'/home', '/detail', '/cart'}
a.isSubsetOf(b); // false
a.isSupersetOf(b); // false
a.isDisjointFrom(b); // false
注意所有返回值都是新的 Set,调用方自己那个集合不会被改动。
用这些方法重写上面的逻辑:
const both = activePages.intersection(paidPages);
const onlyActive = activePages.difference(paidPages);
const onlyPaid = paidPages.difference(activePages);
const anyPage = activePages.union(paidPages);
四行,而且看名字就知道在干什么。
这几个方法的价值不只是短,还在于它们把「集合运算」这层语义从 for 循环里拎出来了。老代码里那句 if (paidPages.has(page)) 读起来像在遍历,实际上想表达的是「求交集」。代码的可读性差别就在这。
Object.groupBy:告别手写 reduce
原来按日期分组的那段:
const byDate = records.reduce((acc, r) => {
(acc[r.date] ??= []).push(r);
return acc;
}, {});
现在可以写成:
const byDate = Object.groupBy(records, r => r.date);
一行。
Object.groupBy 适合键是字符串或数字的场景,因为它底层是普通对象——所有键都会被字符串化。如果键本身是对象,或者你需要保留键的原始类型,用 Map.groupBy:
const byAccount = Map.groupBy(orders, o => o.account);
byAccount.get(someAccount);
两个方法返回的集合里,键都是按照第一次出现的顺序排列的,不是插入顺序。这个细节和 Map 的迭代顺序一致。
完整示例:重构后的脚本
把上面三样东西串起来,就是重构后的完整版本。假设日志每行长这样:
{"userId":"u1","date":"2025-05-06","path":"/home","visits":5,"plan":"paid","level":"info"}
分析脚本:
import { readFile } from 'node:fs/promises';
function parseSafely(line) {
try {
return [JSON.parse(line)];
} catch {
return [];
}
}
async function analyze(filePath) {
const content = await readFile(filePath, 'utf8');
// 逐行解析,解析失败的直接丢弃
const records = Iterator.from(content.split('n'))
.map(line => line.trim())
.filter(Boolean)
.flatMap(parseSafely)
.toArray();
// 按日期分组
const byDate = Object.groupBy(records, r => r.date);
// 逐天统计
const result = {};
for (const [date, dayRecords] of Object.entries(byDate)) {
const activeUsers = new Set(
dayRecords.filter(r => r.visits >= 3).map(r => r.userId)
);
const paidUsers = new Set(
dayRecords.filter(r => r.plan === 'paid').map(r => r.userId)
);
result[date] = {
pv: dayRecords.length,
both: activeUsers.intersection(paidUsers).size,
onlyActive: activeUsers.difference(paidUsers).size,
onlyPaid: paidUsers.difference(activeUsers).size
};
}
return result;
}
看一下改动前后的对比:
- 原来 60 行,现在主体逻辑 30 行出头,可读的实质部分不到 15 行;
- 原来三个循环分散在不同位置,现在只有最外层一个循环,其余都是声明式的;
- 老版本需要一次性把解析结果全塞进数组,新版本理论上还能进一步优化(如果不需要全局分组的话,迭代器链可以边读边处理);
- 原来加一个筛选条件要改三处,现在改一处
filter就行。
三个容易踩的坑
一、Object.groupBy 返回的不是普通对象
它返回一个 null 原型对象:
const g = Object.groupBy([1, 2], () => 'x');
g.hasOwnProperty; // undefined
g.toString; // undefined
判断属性存在用 Object.hasOwn(g, key) 或 key in g,别用 g.hasOwnProperty(key)。序列化成 JSON 倒是不受影响,JSON.stringify 依然正常工作。
二、Iterator 只能消费一次
Iterator 和数组不一样,它不是可重用的。下面这段会出问题:
const it = Iterator.from([1, 2, 3]).map(x => x * 2);
it.toArray(); // [2, 4, 6]
it.toArray(); // [] —— 空的,迭代器已经走到底了
链式调用中得到的是 Iterator 而不是数组,如果你需要多次遍历同一个结果,中间得 .toArray() 一下。
三、Set 方法的返回是新集合,原集合不动
这一点和 Array 的 sort 正好相反——sort 是原地排序,Set 的方法是纯函数:
const a = new Set([1, 2, 3]);
a.union(new Set([4]));
console.log(a); // Set {1, 2, 3},a 没变
看起来是好事,但如果你想「把 b 里的元素并进 a」,写 a.union(b) 是不够的,得写成:
for (const x of b) a.add(x);
或者干脆 a = a.union(b),前提是 a 得是 let 声明的。
什么时候不该用这套东西
新 API 好用,但不是万能钥匙。我自己的判断标准是:
数据量小的时候不用。一个十几条元素的数组,for 循环比迭代器链快,也比链式写法更容易 debug。别为了用新 API 而用。
需要日志和断点的时候要谨慎。惰性链的执行是延迟的,错误堆栈会指向 toArray(),跟实际出错的 map 步骤隔了好几层。调试时在链路中间插一个 .map(x => { debugger; return x; }) 会方便很多。
频繁重复遍历同一份数据,先 toArray。前面已经说了,迭代器是一次性的。
团队环境需要看兼容性。这几个特性在 Node.js 22+、Chrome 122+ 都全绿,但如果项目要支持 Safari 早期版本或者旧版 Node,要么上 polyfill,要么先写数组版本。
写在最后
这些东西单看都不复杂,凑在一起就把「数据清洗 + 统计」这一整类任务的代码压下去了。
以前写这类脚本的习惯是:拉出一个临时数组,跑几个循环,最后 reduce 成想要的结构。现在回头看,那种写法里的循环有相当一部分只是在搬运数据,没有承载任何业务语义。把搬运的部分交给标准库,剩下的代码才是最该被人看到的部分。

