从 60 行到 15 行:用 ES2025 新 API 重写一个日志分析脚本

前阵子把后台的一个日志分析脚本重写了一遍。功能很简单——读一份 JSON Lines 格式的用户行为日志,做三件事:按日期统计每个页面的 PV、找出「付费用户」和「活跃用户」访问过的页面的交集与差集、把当天产生错误的记录挑出来。

原来的实现大概 60 行,用了三个 for 循环、两遍 map、一个 reduce 做分组。功能没问题,但每加一个筛选条件就要动结构,改到第三个需求的时候我决定推倒重写。

重写用了三个 ES2025 的特性:Iterator Helpers、Set 的原生集合运算、还有挂在 Object 上的 groupBy。最终代码从 60 行压到 15 行,而且处理大文件时内存占用明显下降——这一点是最意外的收获。

下面把重写过程完整走一遍。

Iterator Helpers:把链式操作搬到迭代器上

数组有 mapfilterreduce,这个大家都知道。问题是这些方法会一次性返回一个新数组。如果输入是 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。之后 mapfiltertakedropflatMapreducetoArrayforEachsomeeveryfind 这些方法就能链式调用了。

跟数组最大的差别是执行时机。上面那条链,在 .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 成想要的结构。现在回头看,那种写法里的循环有相当一部分只是在搬运数据,没有承载任何业务语义。把搬运的部分交给标准库,剩下的代码才是最该被人看到的部分。

从 60 行到 15 行:用 ES2025 新 API 重写一个日志分析脚本
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript 从 60 行到 15 行:用 ES2025 新 API 重写一个日志分析脚本 https://www.taomawang.com/web/javascript/2792.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务