在前端开发中,处理海量数据的渲染已有不少成熟方案:虚拟列表精准控制可视区域、时间分片平衡渲染与交互、分页加载渐进呈现内容。但当业务场景转向数据计算密集型任务时,就会面临截然不同的挑战:计算耗时引发交互冻结、复杂算法拖慢主线程、内存泄漏导致页面卡死等等。而这类问题的解决方案在前端领域却鲜少被讨论,相关可参考的文章也比较少。
本文将结合示例,探讨在10w+数据量级下可行的解决思路,涵盖数据结构优化、并行计算策略等关键技术点,抛砖引玉供大家参考讨论。
首先我这里假设一个场景:我们现在有一个表,当前这个表有10w+的数据量,现在有几个功能,分别是筛选、排序和分组。在前端需要根据筛选条件、排序规则和分组规则对数据进行计算,然后将最终的结果做一个展示。表格是可以操作的,用户修改了表格某个单元格的值,那么前端就要根据当前的数据重新进行筛选、排序和分组的计算操作,将计算后的排列结果呈现在表格上。
这个场景下很明确前端的计算耗时就在筛选、排序和分组上。我们先用代码简单模拟下。
常规实现
首先我们创建一个test.js文件,用一个简单的数组模拟下数据。
1 | const getRandomDate = () => { |
然后构建筛选、排序和分组这三个对应的计算函数,其中 timeConsuming
是模拟真实业务耗时操作。因为在真实的业务场景下,通常这几个函数内部的操作都比较复杂,会引用各种值或调用函数。我这里处理比较简单,所以用 timeConsuming
增加耗时。这里我们假设按 num 字段进行筛选,按 date 字段进行排序,按 type 字段进行分组。
1 | // 模拟真实业务的耗时操作 |
最后我们调用这三个函数,并进行计时。
1 | const excuteArray = (array) => { |
运行结果如下:
从结果中可以看到,筛选耗时在 480ms 左右,而排序耗时最长,高达 2800ms,分组耗时在 800ms 左右。总耗时呢也超过 4s 了,试想一下,用户只是修改了一个单元格,需要等将近 4s 才能看到最终的视图效果。这种交互耗时是肯定接受不了的。那么可以从哪些方面进行优化呢?
1.并行计算——Web Worker
Web Worker 是一种异步的脚本,它允许我们使用多线程来执行 JavaScript 代码。我们可以在 Web Worker 中执行一些计算密集型的任务,并通过消息传递机制将结果返回给主线程。这样,主线程就可以在计算过程中进行其他操作,而不需要等待计算完成。同时多个线程并行计算也能减少计算时间
1.1 Web Worker 实现
1.1.1 新建worker.js
首先,我们创建一个worker.js文件,将所有的计算函数都挪到这里来。同时监听主线程的消息,并根据消息中的任务名称执行对应的函数。然后将计算结果发送给主线程。
1 | // worker.js |
1.1.2 新增 WorkerPool 类
在主线程中,我们创建一个 WorkerPool
类,用于创建多个线程,并管理线程间的数据通信。使用 navigator.hardwareConcurrency
获取当前计算机上运行线程的逻辑处理器的数量,我这里是 12 个。然后根据线程数量对数据进行切片,每个线程处理一部分的数据。
现代计算机的 CPU 中有多个物理处理器核心(通常是两个或四个核心),但每个物理核心通常也能够使用先进的调度技术同时运行多个线程。例如,四核 CPU 可能提供八个逻辑处理器核心。逻辑处理器核心数量可以用来衡量能够有效同时运行的线程数量,而无需进行上下文切换。
但是,浏览器可能会选择报告更低的逻辑核心数量,以便更准确地表示可以同时运行的 Worker 数量,因此不要将其视为用户系统中核心数量的绝对测量值。
1 | //test.js |
1.1.3 修改之前的几个函数
然后之前的几个计算函数,我们需要改造一下。
-
filterArray: 因为是多线程处理,多以接收到的值是数组,需要进行扁平化处理。
1
2
3
4const filterArray = async (array) => {
const chunks = await workerPool.parallelize('filter', array)
return [].concat(...chunks)
} -
sortArray: 这里需要注意的是,接收到的多个数组,并不能直接合并成一个数组。因为不同数组之间是无序的,所以我们需要使用归并排序将多个数组合并成一个有序的数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36const sortArray = async (array) => {
const sorted = await workerPool.parallelize('sort', array);
return mergeSortedChunks(sorted)
};
// 多路归并实现
const mergeSortedChunks = (chunks) => {
const pointers = new Array(chunks.length).fill(0)
const result = []
let index = 0;
// 初始化最小堆
const heap = [];
chunks.forEach((chunk, i) => {
if (chunk.length > 0) {
heap.push({ chunkIndex: i, element: chunk[0] });
}
});
heap.sort((a, b) => sortFun(a.element, b.element));
while (heap.length > 0) {
// 取出当前最小元素
const { chunkIndex, element } = heap.shift();
result[index++] = element;
pointers[chunkIndex]++;
// 补充新元素到堆中
if (pointers[chunkIndex] < chunks[chunkIndex].length) {
const newElement = chunks[chunkIndex][pointers[chunkIndex]];
heap.push({ chunkIndex, element: newElement });
heap.sort((a, b) => sortFun(a.element, b.element));
}
}
return result;
}; -
groupArray: 这里将对象的键值合并下就可以了。
1
2
3
4
5
6
7const groupArray = async (array) => {
const chunks = await workerPool.parallelize('group', array);
return chunks.reduce((acc, map) => {
Object.entries(map).forEach(([k, v]) => acc[k] = (acc[k] || []).concat(v));
return acc;
}, {});
};
主线程完整代码如下:
1 | // test.js |
我们运行下代码,看下结果如何。
什么?使用webworker多线程处理后,总耗时居然没有显著提升。那我不是白优化了吗?别急,我们一个环节一个环节来分析。
1.2 问题分析
- filter: 首先 filter 耗时从 480ms 优化到了 330ms。是有一些提升,但是没有预想的那么大,因为从时间复杂度上来说是从 O(n) 降到 O()。这里我们要清楚的两点是:首先 webworker 的线程连接是需要耗时的,而 filter 函数是第一个去执行的,所以这部分的耗时就算在了它头上。其次,在数据的传输过程中,结构化克隆有序列化开销。这两部分的耗时使得 filter 看起来没有太大的提升。实际上 filter 真正耗时应该在一百多毫秒的样子。
- group:group 耗时从 800ms 优化到 140ms,这个提升还是挺大的。
- sort:耗时从 2800ms 增加到 将近 3500ms,这个直接反向优化了,总耗时没有显著提升的罪魁祸首。为什么多个线程排序反而消耗的时间更长了呢?首先我们先来分析下时间复杂度。
- 原始排序用的内置 sort 方法,时间复杂度为 O(nlogn)
- 使用webworker多线程排序,每个线程均分的数据量是 ,这里不考虑线程之间的差异,假设都是同一时间完成,那么时间复杂度就是 O()。然后使用多路归并排序,时间复杂度是 O(12nlog12)。总的时间复杂度就是 O( + 12nlog12)。这种情况下时间复杂度就已经比原始排序要高了。所以消耗的时间也就更长。
问题总结:
- webwoker 连接耗时
- 结构化克隆序列化时的开销
- 多路归并排序的时间复杂度
前两个问题看起来不太好解决,第三个问题的话,如果我们能找到一种不使用多路并归排序的算法,那问题是不是就解决了呢?那么想要不使用多路并归的话,就需要保障不同的线程之间数据依然是有序的。这就要求我们在进行数据切片时需要根据数据的范围智能切片。
1.3 智能分片策略
我们现在有12个线程,相当于12个盒子,我们要将数据根据大小放到不同的盒子里,这样就保障了不同盒子间的数据是有序的。
1.3.1 将数据数值化
首先,我们想要将数据根据大小进行区分的话,这个数据必须得是数字。而排序的规则使用的不一定是 number 类型的字段,但是既然是排序,那么它一定是可以根据自己业务要求转换为对应的一个数值,字符串也好、日期也好,都是可以转换成数值的。这个可以根据自己的业务要求实现一个转换函数,我这里用的日期字段,直接用时间戳就可以了。
1.3.2 根据数值划分区间
然后,我们根据数值范围划分区间。需要先拿到最大值和最小值,然后根据区间数量,计算每个区间的数值范围。因为我是随机生成的日期,大概可以认为是均匀分布的,所以我范围是等分的。在 WorkerPool 类里新增两个方法 calculateRanges
和 getRangeIndex
。
1 | // 计算区间范围 |
1.3.3 根据区间分配数据
同样,原先的 parallelize
方法也需要修改一下。对于 sort 方法时,每个线程的切片数据需要使用对应的数值区间数据。而 filter 和 group 是不做改动的。
1 | parallelize(taskName, array) { |
我们的数据就按照数值大小被分成了12个区间,区间是从小到大排列的,这样我们后续拿到排序完的结果直接组装就可以了。现在的时间复杂度就降为了O( + 2n), 2n 是获取区间范围数据和分配区间数据各遍历了一遍。
1 | const sortArray = async (array) => { |
1.3.4 结果验证
可以看到,排序阶段的耗时已经大幅下降到了 940ms 左右,并且排序后的结果和原始结果一致。总耗时也优化到了 1400ms 级别。
那么这里还能不能再优化呢?
答案是肯定的。我们可以注意到智能分片之前,我们进行两次遍历,一次是用来确定分片区间,一次是把数据分配到各个区间里。实际上第一次遍历的性能是浪费的,因为这一步只是用来寻找最大值和最小值。这里我们可以再优化下。
1.3.5 随机取样获取区间范围
对于 10w+ 级别的数据,我们不需要遍历所有的数据来计算区间范围。可以使用抽样方法估算数据分布,避免全量遍历,比如 1% 的数据量就够了。我们对之前的方法改造下。
1 | calculateRanges(array) { |
这样的话 1% 数据量遍历的时间复杂度可以忽略不计,排序时间复杂度降为了 O( + n)。 我们再来看下优化后的结果。可以看到优化了快 200ms,总耗时也降到了 1200 ms。
1.4 共享内存和可转移对象
在主线程与 worker 之间传递的数据是通过拷贝,而不是共享来完成的。传递给 worker 的对象需要经过序列化,接下来在另一端还需要反序列化。主线程与 worker 不会共享同一个实例,最终的结果就是在每次通信结束时生成了数据的一个副本。大部分浏览器使用结构化克隆来实现该特性。所以我们分析耗时操作时就有提到这一点。
但是,有些对象是可转移对象,即可以在主线程与 worker 之间直接传递,这样的数据就不需要经过结构化克隆,从而减少数据传输的开销。
可以被转移的不同规范的对象有多种,如 ArrayBuffer
、MessagePort
、ReadableStream
、WritableStream
等。我们这里使用 ArrayBuffer 来作为可转移对象。对这些概念不熟悉的可以参考 可转移对象。
同时还可以使用共享内存 SharedArrayBuffer,让主线程和 worker 线程共享一个数据块,这样对于一些大的数据就不需要反复进行传输了。共享内存可以被 worker 线程或主线程创建和同时更新。根据系统(CPU、操作系统、浏览器)的不同,需要一段时间才能将变化传递给所有上下文环境。因此需要通过 原子 操作来进行同步
1.4.1 数据结构改造
理清上述概念后,我们可以把原始数据使用 SharedArrayBuffer 来存储。让主线程和 worker 线程共享这个数据,然后筛选、排序和分组操作的结果我们只需要传输 id 数据就可以了,在每个操作里通过 id 来获取对应的数据。这样我们就可以减少数据传输的开销。id 的数据我们使用 ArrayBuffer 来存储,通信的时候转移。整体流程图如下:
因为 ArrayBuffer 是一个字节数组,所以原来的数据我们都需要转换成特定的格式来进行读写。首先在 worker.js 中定义一个数据格式描述对象并导出。
1 | // worker.js |
然后,在 test.js 中,引入 FIELD_MAP
并写入数据
1 | // test.js |
- 使用 SharedArrayBuffer 来作为共享内存,然后使用 DataView 来操作数据。
- 所有字段都要转成特定格式类型,这里 id 和 num 都是 0-100000 的随机数,所以使用Uint32Array类型。date 是随机日期,因时间戳数字较大,所以要使用 BigUint64Array 。type 是 0-9 的随机数, 用 Uint8Array 就够了。
- 最后,把 id 数据存入数组 idArray 中,后续会转为 ArrayBuffer,这样我们就可以减少数据传输的开销。
1.4.2 数据传输改造
在worker初始化时,需要把共享内存 compositeBuffer 传递给 worker。同时在每个操作里,把 id 的 ArrayBuffer 数据传递给 worker。接收数据使用 Uint32Array 视图来读取。
1 | // test.js |
在worker中,使用 sharedBuffer 变量存储初始化时接收到的共享内存数据。同时再接收到 id 的 buffer 数据后,使用 Uint32Array 视图来读取 id,后修的操作也是根据 id 从共享内存中读取对应的数据。
1 | // worker.js |
1.4.3 函数改造
各个函数需要根据数据格式的变化做一些改造。现在worker.js中导出一个公共函数,就是根据 id 从共享内存中读取对应的数据。
1 | // worker.js |
这个函数非常关键。因为 SharedArrayBuffer 是一个共享内存,它是一个原始二进制数据缓冲区,想要读取对应的数据需要提供对应的索引,也就是数据的位置。也就是说,想要通过 id 从共享内存中读取对应的数据,那 id 里至少得包含位置信息。而我定义的 id 数据,就是一个索引,在初始化 SharedArrayBuffer 时,是通过 index
来按序填充数据的。所以我这里就可以通过 id 来获取对应数据的值。如果说 id 是一个字符串的话,可以先将 id 转成数字,然后在后面拼接上索引信息,然后再转成数字(这样才能存在 SharedArrayBuffer 里)。获取数据时根据 id 截取后几位就可以拿到索引信息了。
然后核心的执行函数filter、sort和group也需要修改。在filter和sort阶段,依然要返回 ArrayBuffer 数据,在最后一步 group, 就需要组装成普通对象返回了。
1 | // worker.js |
在主线程中,智能切片也同样需要修改,从共享内存中取值,同时 filter 和 sort 函数对数据做组装也要改成操作 ArrayBuffer。
1 | //test.js |
1.4.4 完整代码
1 | // worker.js |
1 | //test.js |
1.4.5 结果测试
我们来运行下代码看看结果
最终得到得结果是正确的,没问题。同时可以发现 filter 阶段得提升是最大的,快了 100多ms, sort 和 group 提升没那么显著,但也有 20-30ms 左右。总耗时也来到了 1000ms 级别,比之前降了 200ms。
为什么 filter 提升是最大的呢?我个人推测是,除了省去结构化克隆的消耗所带来的提升外。还有 filter 阶段执行时,缓存命中率非常高。因为 ArrayBuffer 和 SharedArrayBuffer 的内存都是连续的,并且过滤操作也是顺序访问。CPU检测到顺序访问模式会自动预取后续数据,所以缓存命中率非常高。这部分的性能提升也是非常可观的。
1.5 webworker 小结
到这里,我们实现了一个基于 webworker 的数据处理框架,实现了对数据处理的性能优化。从最初的 4s 降到了 1s,性能提升了 400%。当然这个数据只是参考,不同业务场景和数据量可能得到的结果差异会比较大。而且我里面对数据做遍历时都执行了 timeComsuming
的耗时函数操作,这个影响也会比较大。同时我们也需要注意如果单纯启用 Web Worker 多线程机制并不能保证性能提升,甚至还可能造成时间复杂度变高。合理的对数据进行切片搭配算法上的优化,以及对数据结构的优化,才能做的更好。
2.WebAssembly
WebAssembly(简称 WASM)是一种面向现代浏览器的低级字节码格式,它的核心设计目标是实现高性能计算。以下是对其核心概念的通俗解读:
- 跨平台性能引擎: WASM 不是编程语言,而是编译目标。允许将 C/C++/Rust 等语言编译成紧凑的二进制格式,在浏览器中接近原生速度运行。
- 沙箱化安全执行: 通过内存隔离和类型检查机制,确保代码在受限环境中安全运行,避免传统插件的安全隐患。
- 多语言生态桥梁: 前端开发者可通过 JavaScript API 调用 WASM 模块,实现关键计算逻辑的性能突破。
应用流程
- 源码准备:使用 C/Rust 等系统级语言编写计算密集型函数(如矩阵运算、物理模拟)。
- 编译转换:通过 Emscripten/wasm-pack 等工具链将源码编译为 .wasm 二进制模块。
- 前端集成:JavaScript 通过 WebAssembly.instantiate() 加载模块,并通过内存共享机制传递数据。
- 性能调优:结合 Worker 线程与 SIMD 指令集,最大化利用硬件资源。
由于 WASM 涉及多语言工具链整合、内存管理优化等进阶主题,作者尚在结合具体业务场景进行技术验证。建议感兴趣的同学可参考 MDN 官方教程 进行实践探索。
- 本文作者: puppy
- 本文链接: https://ispuppy.github.io/2025/03/20/massDataProcess/
- 版权声明: 本BLOG上原创文章未经本人许可,不得用于商业用途及传统媒体。网络媒体转载请注明出处,否则属于侵权行为。