数据分析实战中常用的技术与避坑指南

Mr-贝贝 前端 阅读 2,204
赞 11 收藏
二维码
手机扫码查看
反馈

又踩坑了,前端做数据聚合差点把浏览器干崩

上周搞一个数据看板,后端甩过来一堆原始日志,说“前端自己聚合下吧,量不大”。我信了,结果一拉数据——好家伙,12万条 JSON 记录,每条还带几十个字段。本地跑了个 Array.reduce(),Chrome 直接卡死,风扇狂转,任务管理器里内存飙到 2G。这哪是“量不大”,这是拿前端当数据库用啊!

数据分析实战中常用的技术与避坑指南

其实一开始我也偷懒,想着先跑通逻辑再说。代码大概是这样:

// 别学我,这写法就是事故现场
const grouped = rawData.reduce((acc, item) => {
  const key = ${item.date}-${item.category};
  if (!acc[key]) acc[key] = { count: 0, total: 0 };
  acc[key].count += 1;
  acc[key].total += item.value;
  return acc;
}, {});

本地测试几百条没问题,一上真数据就完蛋。折腾了半天发现,问题不在算法复杂度(O(n) 其实还好),而在于 频繁的对象属性访问和内存分配。每次 acc[key] 都要查哈希表,新建对象时 V8 引擎压力山大。更惨的是,这些中间对象没法被及时回收,GC(垃圾回收)都追不上创建速度。

我第一反应是“分片处理”——用 setTimeoutrequestIdleCallback 把大任务拆成小块。试了下,确实不卡死了,但聚合一次要七八秒,用户体验还是烂。而且用户切换筛选条件时,还得等半天,根本没法用。

换个思路:别在 JS 里硬刚,用 TypedArray 压数据

后来想到,这些日志其实结构很规整:日期、分类、数值。完全可以转成二进制格式,用 Int32Array 存日期时间戳,Uint8Array 存分类 ID(假设不超过 255 类),Float32Array 存数值。这样内存占用直接砍掉 70% 以上,而且遍历速度飞快。

但问题来了:原始数据是字符串日期和分类名,得先映射。我搞了个预处理步骤,把分类名转成数字 ID,日期转成时间戳。虽然多了一次遍历,但后续聚合快太多了。

核心代码长这样:

// 1. 预处理:建立分类映射
const categoryMap = new Map();
let categoryId = 0;
rawData.forEach(item => {
  if (!categoryMap.has(item.category)) {
    categoryMap.set(item.category, categoryId++);
  }
});

// 2. 转成 TypedArray
const dates = new Int32Array(rawData.length);
const categories = new Uint8Array(rawData.length);
const values = new Float32Array(rawData.length);

rawData.forEach((item, i) => {
  dates[i] = new Date(item.date).getTime(); // 简化处理,实际需考虑时区
  categories[i] = categoryMap.get(item.category);
  values[i] = item.value;
});

// 3. 聚合:用 Map 存结果,但只存索引
const groupMap = new Map();
for (let i = 0; i < rawData.length; i++) {
  const key = ${dates[i]}-${categories[i]};
  if (!groupMap.has(key)) {
    groupMap.set(key, []);
  }
  groupMap.get(key).push(i);
}

// 4. 最后计算汇总值
const result = {};
for (const [key, indices] of groupMap) {
  let total = 0;
  for (const idx of indices) {
    total += values[idx];
  }
  result[key] = { count: indices.length, total };
}

这么一改,12 万条数据聚合时间从卡死降到 300ms 左右。但说实话,还是有点慢,而且代码啰嗦。最烦的是日期转时间戳那步,如果原始数据格式不统一,还得加各种容错。

终极方案:Web Worker + 流式处理

其实最优解早就该想到——别让主线程干这活!我把整个聚合逻辑扔到 Web Worker 里,主线程只负责展示 loading 和接收结果。这样就算聚合要 1 秒,页面也不会卡。

但光用 Worker 还不够,我加了个“流式”处理:Worker 分批读取数据,每处理 5000 条就发一次进度,主线程可以更新进度条。用户知道没卡死,心理感受好很多。

完整代码如下(含进度反馈):

// main.js
function aggregateData(rawData) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('/worker.js');
    worker.postMessage({ data: rawData, chunkSize: 5000 });
    
    worker.onmessage = (e) => {
      if (e.data.type === 'progress') {
        // 更新进度条,比如 setProgress(e.data.percent)
        console.log(处理进度: ${e.data.percent}%);
      } else if (e.data.type === 'result') {
        resolve(e.data.result);
        worker.terminate();
      }
    };
    
    worker.onerror = (err) => {
      reject(err);
      worker.terminate();
    };
  });
}

// worker.js
self.onmessage = (e) => {
  const { data, chunkSize } = e.data;
  const total = data.length;
  let processed = 0;
  
  // 预处理映射(同上)
  const categoryMap = new Map();
  let categoryId = 0;
  data.forEach(item => {
    if (!categoryMap.has(item.category)) {
      categoryMap.set(item.category, categoryId++);
    }
  });
  
  // 聚合逻辑
  const result = {};
  for (let i = 0; i < total; i++) {
    const item = data[i];
    const dateKey = new Date(item.date).toDateString(); // 简化:按天聚合
    const catId = categoryMap.get(item.category);
    const key = ${dateKey}-${catId};
    
    if (!result[key]) {
      result[key] = { count: 0, total: 0 };
    }
    result[key].count += 1;
    result[key].total += item.value;
    
    // 每 chunkSize 条发一次进度
    if ((i + 1) % chunkSize === 0 || i === total - 1) {
      processed = i + 1;
      self.postMessage({
        type: 'progress',
        percent: Math.round((processed / total) * 100)
      });
    }
  }
  
  self.postMessage({ type: 'result', result });
};

这个方案上线后稳如老狗。即使数据量再翻倍,用户也只会看到进度条走,不会觉得卡。而且 Worker 里就算出错,也不会崩掉主页面。

踩坑提醒:这三点一定注意

  • 别在主线程做大数据运算:哪怕你觉得“就几万条”,浏览器对 JS 内存和 CPU 的容忍度比你想象中低得多。超过 1 万条就该考虑 Worker。
  • TypedArray 不是万能的:它适合数值型数据,但如果你的字段里有大量字符串(比如用户昵称),转二进制反而更费劲。这时候不如老老实实用 Map + 对象池优化。
  • 进度反馈很重要:用户不怕慢,怕不知道还要等多久。哪怕只是个简单的百分比,体验提升巨大。我一开始没加,产品直接问我“是不是卡死了”。

对了,还有个小问题没完美解决:如果用户快速切换筛选条件,旧的 Worker 可能还在跑,得手动 terminate()。我现在的做法是在发起新请求前先清理旧 Worker,但偶尔会有 race condition。不过影响不大,毕竟聚合完成前用户看不到结果,点多次也无妨。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有可能用 WebAssembly 再提速?或者用 IndexedDB 做本地缓存避免重复计算?这些我还没试,但感觉值得探索。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论