数据分析实战中常用的技术与避坑指南
又踩坑了,前端做数据聚合差点把浏览器干崩
上周搞一个数据看板,后端甩过来一堆原始日志,说“前端自己聚合下吧,量不大”。我信了,结果一拉数据——好家伙,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(垃圾回收)都追不上创建速度。
我第一反应是“分片处理”——用 setTimeout 或 requestIdleCallback 把大任务拆成小块。试了下,确实不卡死了,但聚合一次要七八秒,用户体验还是烂。而且用户切换筛选条件时,还得等半天,根本没法用。
换个思路:别在 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 做本地缓存避免重复计算?这些我还没试,但感觉值得探索。

暂无评论