在前端开发中,处理海量数据的渲染已有不少成熟方案:虚拟列表精准控制可视区域、时间分片平衡渲染与交互、分页加载渐进呈现内容。但当业务场景转向数据计算密集型 任务时,就会面临截然不同的挑战:计算耗时引发交互冻结、复杂算法拖慢主线程、内存泄漏导致页面卡死 等等。而这类问题的解决方案在前端领域却鲜少被讨论,相关可参考的文章也比较少。
本文将结合示例,探讨在10w+数据量级下可行的解决思路,涵盖数据结构优化 、并行计算 策略等关键技术点,抛砖引玉供大家参考讨论。
首先我这里假设一个场景:我们现在有一个表,当前这个表有10w+的数据量,现在有几个功能,分别是筛选、排序和分组 。在前端需要根据筛选条件、排序规则和分组规则对数据进行计算,然后将最终的结果做一个展示。表格是可以操作的,用户修改了表格某个单元格的值,那么前端就要根据当前的数据重新进行筛选、排序和分组的计算操作,将计算后的排列结果呈现在表格上。
这个场景下很明确前端的计算耗时就在筛选、排序和分组上。我们先用代码简单模拟下。
常规实现
首先我们创建一个test.js文件,用一个简单的数组模拟下数据。
js
const getRandomDate = () => {
const year = new Date().getFullYear();
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
const randomDate = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
const pad = n => n.toString().padStart(2, '0');
return `${year}-${pad(randomDate.getMonth() + 1)}-${pad(randomDate.getDate())}`;
}
const array = Array.from({length: 100000}, (_, idx) => ({
id: idx, // id键
num: Math.floor(Math.random() * 100000), // num值为0-99999之间的随机数
date: getRandomDate(), // 日期值,随机为当年的任一天
type: Math.floor(Math.random() * 10), // type值为0-9之间的随机数
// ... 其它字段
}))
然后构建筛选、排序和分组这三个对应的计算函数,其中 timeConsuming
是模拟真实业务耗时操作。因为在真实的业务场景下,通常这几个函数内部的操作都比较复杂,会引用各种值或调用函数。我这里处理比较简单,所以用 timeConsuming
增加耗时。这里我们假设按 num 字段进行筛选,按 date 字段进行排序,按 type 字段进行分组。
js
// 模拟真实业务的耗时操作
const timeConsuming = () => {
let dummy = 0;
for(let i = 0; i < 10000; i++) {
dummy += i % 2;
}
}
// 筛选
const filterArray = (array) => {
return array.filter(item => {
timeConsuming()
return item.num % 2 === 0
});
}
// 排序
const sortArray = (array) => {
return array.sort((a, b) => {
timeConsuming()
return new Date(a.date).getTime() - new Date(b.date).getTime();
});
}
// 分组
const groupArray = (array) => {
return array.reduce((acc, item) => {
timeConsuming()
acc[type] = (acc[type] || []).concat(item);
return acc;
}, {});
}
最后我们调用这三个函数,并进行计时。
js
const excuteArray = (array) => {
console.time('total')
console.time('filter')
const filteredArray = filterArray(array);
console.timeEnd('filter')
console.time('sort')
const sortedArray = sortArray(filteredArray);
console.timeEnd('sort')
console.time('group')
const groupedArray = groupArray(sortedArray);
console.timeEnd('group')
console.timeEnd('total')
return groupedArray;
}
console.log(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文件,将所有的计算函数都挪到这里来。同时监听主线程的消息,并根据消息中的任务名称执行对应的函数。然后将计算结果发送给主线程。
js
// worker.js
const timeConsuming = () => {
let dummy = 0;
for(let i = 0; i < 10000; i++) {
dummy += i % 2;
}
}
// 单独提出来是后续主线程也要用到它
export const sortFun = (a, b) => {
timeConsuming()
return new Date(a.date).getTime() - new Date(b.date).getTime();
}
const methods = {
filter: (array) => array.filter(item => {
timeConsuming();
return item.num % 2 === 0;
}),
sort: (array) => array.sort(sortFun),
group: (array) => array.reduce((acc, item) => {
timeConsuming()
acc[item.type] = (acc[item.type] || []).concat(item);
return acc;
}, {})
};
// 监听主线程发来的消息,同时将结果发回主线程
self.onmessage = ({data: message}) => {
if(!message) return
const {taskName, data} = message
const result = methods[taskName]?.(data);
self.postMessage(result)
};
1.1.2 新增 WorkerPool 类
在主线程中,我们创建一个 WorkerPool
类,用于创建多个线程 ,并管理线程间的数据通信。使用 navigator.hardwareConcurrency
获取当前计算机上运行线程的逻辑处理器的数量,我这里是 12 个。然后根据线程数量对数据进行切片,每个线程处理一部分的数据。
现代计算机的 CPU 中有多个物理处理器核心(通常是两个或四个核心),但每个物理核心通常也能够使用先进的调度技术同时运行多个线程。例如,四核 CPU 可能提供八个逻辑处理器核心。逻辑处理器核心数量可以用来衡量能够有效同时运行的线程数量,而无需进行上下文切换。 但是,浏览器可能会选择报告更低的逻辑核心数量,以便更准确地表示可以同时运行的 Worker 数量,因此不要将其视为用户系统中核心数量的绝对测量值。
js
//test.js
class WorkerPool {
constructor(workerURL, concurrency = navigator.hardwareConcurrency) {
this.workers = Array.from({length: concurrency}, () => new Worker(workerURL))
}
parallelize(taskName, array) {
// 计算每个线程处理的数据量
const chunkSize = Math.ceil(array.length / this.workers.length)
return Promise.all(this.workers.map((worker, i) => {
const chunk = array.slice(i * chunkSize, (i+1) * chunkSize)
return new Promise(resolve => {
worker.onmessage = e => resolve(e.data)
worker.postMessage({taskName, data: chunk})
})
}))
}
}
// 实例化 WorkerPool
const workerPool = new WorkerPool('worker.js');
1.1.3 修改之前的几个函数
然后之前的几个计算函数,我们需要改造一下。
-
filterArray: 因为是多线程处理,多以接收到的值是数组,需要进行扁平化处理。
jsconst filterArray = async (array) => { const chunks = await workerPool.parallelize('filter', array) return [].concat(...chunks) }
-
sortArray: 这里需要注意的是,接收到的多个数组,并不能直接合并成一个数组。因为不同数组之间是无序的,所以我们需要使用归并排序将多个数组合并成一个有序的数组。
jsconst 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: 这里将对象的键值合并下就可以了。
jsconst 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; }, {}); };
主线程完整代码如下:
js
// test.js
import { sortFun } from './worker.js'
const getRandomDate = () => {
const year = new Date().getFullYear();
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
const randomDate = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
const pad = n => n.toString().padStart(2, '0');
return `${year}-${pad(randomDate.getMonth() + 1)}-${pad(randomDate.getDate())}`;
}
const array = Array.from({length: 100000}, (_, idx) => ({
id: idx,
num: Math.floor(Math.random() * 100000),
date: getRandomDate(),
type: Math.floor(Math.random() * 10),
}))
class WorkerPool {
constructor(workerURL, concurrency = navigator.hardwareConcurrency) {
this.workers = Array.from({length: concurrency}, () => new Worker(workerURL, {type: 'module'}))
}
parallelize(taskName, array) {
const chunkSize = Math.ceil(array.length / this.workers.length)
return Promise.all(this.workers.map((worker, i) => {
const chunk = array.slice(i * chunkSize, (i+1) * chunkSize)
return new Promise(resolve => {
worker.onmessage = e => resolve(e.data)
worker.postMessage({taskName, data: chunk})
})
}))
}
}
const workerPool = new WorkerPool('worker.js');
const filterArray = async (array) => {
const chunks = await workerPool.parallelize('filter', array)
return [].concat(...chunks)
}
const sortArray = async (array) => {
const sorted = await workerPool.parallelize('sort', array); // 全量排序
return mergeSortedChunks(sorted)
};
const 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;
}, {});
};
// 多路归并实现
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;
};
const excuteArray = async (array) => {
console.time('total');
console.time('filter')
const filteredArray = await filterArray(array);
console.timeEnd('filter')
console.time('sort')
const sortedArray = await sortArray(filteredArray);
console.timeEnd('sort')
console.time('group')
const groupedArray = await groupArray(sortedArray);
console.timeEnd('group')
console.timeEnd('total');
return groupedArray;
};
excuteArray(array).then(console.log);
我们运行下代码,看下结果如何。
什么?使用webworker多线程处理后,总耗时居然没有显著提升。那我不是白优化了吗?别急,我们一个环节一个环节来分析。
1.2 问题分析
- filter : 首先 filter 耗时从 480ms 优化到了 330ms。是有一些提升,但是没有预想的那么大,因为从时间复杂度上来说是从 O(n) 降到 O( <math xmlns="http://www.w3.org/1998/Math/MathML"> n 12 \frac{n}{12} </math>12n)。这里我们要清楚的两点是:首先 webworker 的线程连接是需要耗时 的,而 filter 函数是第一个去执行的,所以这部分的耗时就算在了它头上。其次,在数据的传输过程中,结构化克隆有序列化开销。这两部分的耗时使得 filter 看起来没有太大的提升。实际上 filter 真正耗时应该在一百多毫秒的样子。
- group:group 耗时从 800ms 优化到 140ms,这个提升还是挺大的。
- sort :耗时从 2800ms 增加到 将近 3500ms,这个直接反向优化了,总耗时没有显著提升的罪魁祸首。为什么多个线程排序反而消耗的时间更长了呢?首先我们先来分析下时间复杂度。
- 原始排序用的内置 sort 方法,时间复杂度为 O(nlogn)
- 使用webworker多线程排序,每个线程均分的数据量是 <math xmlns="http://www.w3.org/1998/Math/MathML"> n 12 \frac{n}{12} </math>12n,这里不考虑线程之间的差异,假设都是同一时间完成,那么时间复杂度就是 O( <math xmlns="http://www.w3.org/1998/Math/MathML"> n 12 ⋅ log ( n 12 ) \frac{n}{12} \cdot \log\left(\frac{n}{12}\right) </math>12n⋅log(12n))。然后使用多路归并排序,时间复杂度是 O(12nlog12)。总的时间复杂度就是 O( <math xmlns="http://www.w3.org/1998/Math/MathML"> n 12 ⋅ log ( n 12 ) \frac{n}{12} \cdot \log\left(\frac{n}{12}\right) </math>12n⋅log(12n) + 12nlog12)。这种情况下时间复杂度就已经比原始排序要高了。所以消耗的时间也就更长。
问题总结:
- webwoker 连接耗时
- 结构化克隆序列化时的开销
- 多路归并排序的时间复杂度
前两个问题看起来不太好解决,第三个问题的话,如果我们能找到一种不使用多路并归排序的算法,那问题是不是就解决了呢?那么想要不使用多路并归的话,就需要保障不同的线程之间数据依然是有序的。这就要求我们在进行数据切片时需要根据数据的范围智能切片。
1.3 智能分片策略
我们现在有12个线程,相当于12个盒子,我们要将数据根据大小放到不同的盒子里,这样就保障了不同盒子间的数据是有序的。
1.3.1 将数据数值化
首先,我们想要将数据根据大小进行区分的话,这个数据必须得是数字。而排序的规则使用的不一定是 number 类型的字段,但是既然是排序,那么它一定是可以根据自己业务要求转换为对应的一个数值,字符串也好、日期也好,都是可以转换成数值的。这个可以根据自己的业务要求实现一个转换函数,我这里用的日期字段,直接用时间戳就可以了。
1.3.2 根据数值划分区间
然后,我们根据数值范围划分区间。需要先拿到最大值和最小值,然后根据区间数量,计算每个区间的数值范围。因为我是随机生成的日期,大概可以认为是均匀分布的,所以我范围是等分的。在 WorkerPool 类里新增两个方法 calculateRanges
和 getRangeIndex
。
js
// 计算区间范围
calculateRanges(array) {
let min = Infinity
let max = -Infinity
for(let i = 0; i < array.length; i++) {
timeConsuming() // 遍历数据是依然要模拟耗时
const value = new Date(array[i].date).getTime()
min = Math.min(min, value)
max = Math.max(max, value)
}
const step = (max - min) / this.workers.length;
return Array.from({length: this.workers.length}, (_,i) => ({
min: min + i * step
}));
}
// 根据数值大小计算区间索引
getRangeIndex(number, ranges) {
const minVals = ranges.map(r => r.min);
let low = 0
let high = ranges.length - 1
let pos = 0
while (low <= high) {
const mid = (low + high) >> 1
if (number >= minVals[mid]) {
pos = mid
low = mid + 1
} else {
high = mid - 1
}
}
return pos
}
1.3.3 根据区间分配数据
同样,原先的 parallelize
方法也需要修改一下。对于 sort 方法时,每个线程的切片数据需要使用对应的数值区间数据。而 filter 和 group 是不做改动的。
js
parallelize(taskName, array) {
const chunkArray = Array.from({length: this.workers.length}, () => [])
if(taskName === 'sort') {
const ranges = this.calculateRanges(array)
for(const item of array) {
timeConsuming()
const value = new Date(item.date).getTime()
const index = this.getRangeIndex(value, ranges)
chunkArray[index].push(item)
}
} else {
const chunkSize = Math.ceil(array.length / this.workers.length)
this.workers.forEach((_, i) => {
const chunk = array.slice(i * chunkSize, (i+1) * chunkSize)
chunkArray[i] = chunk
})
}
return Promise.all(this.workers.map((worker, i) => {
const chunk = chunkArray[i]
return new Promise(resolve => {
worker.onmessage = e => resolve(e.data)
worker.postMessage({taskName, data: chunk})
})
}))
}
我们的数据就按照数值大小被分成了12个区间,区间是从小到大排列的,这样我们后续拿到排序完的结果直接组装就可以了。现在的时间复杂度就降为了O( <math xmlns="http://www.w3.org/1998/Math/MathML"> n 12 ⋅ log ( n 12 ) \frac{n}{12} \cdot \log\left(\frac{n}{12}\right) </math>12n⋅log(12n) + 2n), 2n 是获取区间范围数据和分配区间数据各遍历了一遍。
js
const sortArray = async (array) => {
const sorted = await workerPool.parallelize('sort', array);
return [].concat(...sorted)
};
1.3.4 结果验证
可以看到,排序阶段的耗时已经大幅下降到了 940ms 左右,并且排序后的结果和原始结果一致。总耗时也优化到了 1400ms 级别。
那么这里还能不能再优化呢?
答案是肯定的。我们可以注意到智能分片之前,我们进行两次遍历,一次是用来确定分片区间,一次是把数据分配到各个区间里。实际上第一次遍历的性能是浪费的,因为这一步只是用来寻找最大值和最小值。这里我们可以再优化下。
1.3.5 随机取样获取区间范围
对于 10w+ 级别的数据,我们不需要遍历所有的数据来计算区间范围。可以使用抽样方法估算数据分布,避免全量遍历,比如 1% 的数据量就够了。我们对之前的方法改造下。
js
calculateRanges(array) {
let min = Infinity
let max = -Infinity
const sampleSize = Math.ceil(array.length * 0.01);
Array.from({length: sampleSize}).forEach(() => {
const randomIndex = Math.floor(Math.random() * array.length)
const randomItem = array[randomIndex]
timeConsuming()
const value = new Date(randomItem.date).getTime()
min = Math.min(min, value)
max = Math.max(max, value)
})
const step = (max - min) / this.workers.length;
return Array.from({length: this.workers.length}, (_,i) => ({
// 随机取样无法确定整个数据里最小值,所以头部的区间需要特殊处理
min: i === 0 ? -Infinity : min + i * step,
}));
}
这样的话 1% 数据量遍历的时间复杂度可以忽略不计,排序时间复杂度降为了 O( <math xmlns="http://www.w3.org/1998/Math/MathML"> n 12 ⋅ log ( n 12 ) \frac{n}{12} \cdot \log\left(\frac{n}{12}\right) </math>12n⋅log(12n) + 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 中定义一个数据格式描述对象并导出。
js
// worker.js
export const FIELD_MAP = {
ID: { offset: 0, type: Uint32Array },
NUM: { offset: 4, type: Uint32Array },
DATE_TS: { offset: 8, type: BigUint64Array },
TYPE: { offset: 16, type: Uint8Array },
ITEM_SIZE: 17 // 4+4+8+1=17字节
};
然后,在 test.js 中,引入 FIELD_MAP
并写入数据
js
// test.js
import { FIELD_MAP } from './worker.js';
const getRandomDate = () => {
const year = new Date().getFullYear();
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
const randomDate = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
const pad = n => n.toString().padStart(2, '0');
return `${year}-${pad(randomDate.getMonth() + 1)}-${pad(randomDate.getDate())}`;
}
const array = Array.from({length: 100000}, (_, idx) => ({
id: idx,
num: Math.floor(Math.random() * 100000),
date: getRandomDate(),
type: Math.floor(Math.random() * 10),
}))
const compositeBuffer = new SharedArrayBuffer(array.length * FIELD_MAP.ITEM_SIZE)
const bufferView = new DataView(compositeBuffer)
const idArray = array.map((item, index) => {
const offset = index * FIELD_MAP.ITEM_SIZE
bufferView.setUint32(offset + FIELD_MAP.ID.offset, item.id)
bufferView.setUint32(offset + FIELD_MAP.NUM.offset, item.num)
bufferView.setBigUint64(offset + FIELD_MAP.DATE_TS.offset, BigInt(new Date(item.date).getTime()))
bufferView.setUint8(offset + FIELD_MAP.TYPE.offset, item.type)
return item.id;
})
- 使用 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 视图来读取。
js
// test.js
class WorkerPool {
constructor(workerURL, concurrency = navigator.hardwareConcurrency) {
this.workers = Array.from({length: concurrency}, () => {
const worker = new Worker(workerURL, {type: 'module'})
worker.postMessage({type: 'INIT_DATA', data: compositeBuffer})
return worker;
})
}
// 第一次传入的array是id数组,后续都是uint32Array类型
parallelize(taskName, array) {
// 最终存储的元素都是buffer
const chunkArray = Array.from({length: this.workers.length}, () => [])
// 判断array是不是Uint32Array类型,不是就转一下
const dataArray = array instanceof Uint32Array ? array : new Uint32Array(array)
if(taskName === 'sort') {
this.sortParallel(chunkArray, dataArray)
} else {
this._defaultParallel(chunkArray, dataArray)
}
return Promise.all(this.workers.map((worker, i) => {
const chunk = chunkArray[i]
return new Promise(resolve => {
worker.onmessage = e => resolve(taskName === 'group' ? e.data : new Uint32Array(e.data.buffer, 0, e.data.count))
worker.postMessage({taskName, data: chunk}, [chunk]) // 第二个参数转移 buffer
})
}))
}
sortParallel(chunkArray, dataArray) {
const ranges = this.calculateRanges(dataArray)
for(const id of dataArray) {
timeConsuming()
const { date: value } = getDataById(id, compositeBuffer)
const index = this.getRangeIndex(value, ranges)
chunkArray[index].push(id)
}
chunkArray.forEach((chunkItem, index) => {
chunkArray[index] = new Uint32Array(chunkItem).buffer
})
}
_defaultParallel(chunkArray, dataArray) {
const chunkSize = Math.ceil(dataArray.length / this.workers.length)
this.workers.forEach((_, i) => {
const start = i * chunkSize
const end = Math.min(start + chunkSize, dataArray.length)
const chunk = dataArray.slice(start, end)
chunkArray[i] = chunk.buffer
})
}
}
在worker中,使用 sharedBuffer 变量存储初始化时接收到的共享内存数据。同时再接收到 id 的 buffer 数据后,使用 Uint32Array 视图来读取 id,后修的操作也是根据 id 从共享内存中读取对应的数据。
js
// worker.js
let sharedBuffer
self.onmessage = ({data: message}) => {
if(!message) return
if (message.type === 'INIT_DATA') {
sharedBuffer = message.data; // 初始化共享数据
return;
}
const {taskName, data: chunkData} = message
if(!taskName) return
const idArray = new Uint32Array(chunkData)
const result = methods[taskName]?.(idArray);
if (taskName === 'group') {
self.postMessage(result); // 普通对象直接发送
} else {
self.postMessage(result, [result.buffer]); // ArrayBuffer使用Transferable
}
};
1.4.3 函数改造
各个函数需要根据数据格式的变化做一些改造。现在worker.js中导出一个公共函数,就是根据 id 从共享内存中读取对应的数据。
js
// worker.js
export const getDataById = (id, buffer) => {
const dataView = new DataView(buffer);
const offset = id * FIELD_MAP.ITEM_SIZE
return {
id: dataView.getUint32(offset + FIELD_MAP.ID.offset, false),
num: dataView.getUint32(offset + FIELD_MAP.NUM.offset, false),
date: dataView.getBigUint64(offset + FIELD_MAP.DATE_TS.offset, false),
type: dataView.getUint8(offset + FIELD_MAP.TYPE.offset, false)
}
}
这个函数非常关键。因为 SharedArrayBuffer 是一个共享内存,它是一个原始二进制数据缓冲区 ,想要读取对应的数据需要提供对应的索引 ,也就是数据的位置。也就是说,想要通过 id 从共享内存中读取对应的数据,那 id 里至少得包含位置信息 。而我定义的 id 数据,就是一个索引,在初始化 SharedArrayBuffer 时,是通过 index
来按序填充数据的。所以我这里就可以通过 id 来获取对应数据的值。如果说 id 是一个字符串的话,可以先将 id 转成数字,然后在后面拼接上索引信息,然后再转成数字(这样才能存在 SharedArrayBuffer 里)。获取数据时根据 id 截取后几位就可以拿到索引信息了。
然后核心的执行函数filter、sort和group也需要修改。在filter和sort阶段,依然要返回 ArrayBuffer 数据,在最后一步 group, 就需要组装成普通对象返回了。
js
// worker.js
const transDate = (dateTime) => {
const date = new Date(dateTime);
const pad = n => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
const methods = {
filter: (data) => {
const filtered = new Uint32Array(data.length);
let count = 0
for(let i = 0; i < data.length; i++) {
timeConsuming();
const id = data[i]
const result = getDataById(id, sharedBuffer)
if(result.num % 2 === 0) {
filtered[count++] = id
}
}
return {
count,
buffer: filtered.subarray(0, count).buffer
}
},
sort: (array) => {
const sorted = array.sort((a, b) => {
timeConsuming();
const { date: aDate} = getDataById(a, sharedBuffer)
const { date: bDate} = getDataById(b, sharedBuffer)
return Number(aDate) - Number(bDate)
})
return {
count: sorted.length,
buffer: sorted.buffer
}
},
group: (array) => array.reduce((acc, id) => {
timeConsuming();
const item = getDataById(id, sharedBuffer)
item.date = transDate(Number(item.date))
acc[item.type] = (acc[item.type] || []).concat(item);
return acc;
}, {})
};
在主线程中,智能切片也同样需要修改,从共享内存中取值,同时 filter 和 sort 函数对数据做组装也要改成操作 ArrayBuffer。
js
//test.js
calculateRanges(array) {
let min = Infinity
let max = -Infinity
const sampleSize = Math.ceil(array.length * 0.01);
Array.from({length: sampleSize}).forEach(() => {
const randomIndex = Math.floor(Math.random() * array.length)
const randomId = array[randomIndex]
const { date: value } = getDataById(randomId, compositeBuffer)
timeConsuming()
min = Math.min(min, Number(value))
max = Math.max(max, Number(value))
})
const step = (max - min) / this.workers.length;
return Array.from({length: this.workers.length}, (_,i) => ({
min: i === 0 ? -Infinity : min + i * step,
// max: i === (this.workers.length - 1) ? Infinity : min + (i + 1) * step
}));
}
const flattenTypedArray = chunks => {
// 计算总长度
const totalLength = chunks.reduce((sum, arr) => sum + arr.length, 0);
// 创建目标数组
const result = new Uint32Array(totalLength);
// 分段复制数据
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
};
const filterArray = async (array) => {
const chunks = await workerPool.parallelize('filter', array);
return flattenTypedArray(chunks);
};
const sortArray = async (array) => {
const sorted = await workerPool.parallelize('sort', array); // 全量排序
return flattenTypedArray(sorted);
};
const 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.4.4 完整代码
js
// worker.js
let sharedBuffer
export const timeConsuming = () => {
let dummy = 0;
for(let i = 0; i < 10000; i++) {
dummy += i % 2;
}
}
export const FIELD_MAP = {
ID: { offset: 0, type: Uint32Array },
NUM: { offset: 4, type: Uint32Array },
DATE_TS: { offset: 8, type: BigUint64Array },
TYPE: { offset: 16, type: Uint8Array },
ITEM_SIZE: 17 // 4+4+8+1=17字节
};
export const getDataById = (id, buffer) => {
const dataView = new DataView(buffer);
const offset = id * FIELD_MAP.ITEM_SIZE
return {
id: dataView.getUint32(offset + FIELD_MAP.ID.offset, false),
num: dataView.getUint32(offset + FIELD_MAP.NUM.offset, false),
date: dataView.getBigUint64(offset + FIELD_MAP.DATE_TS.offset, false),
type: dataView.getUint8(offset + FIELD_MAP.TYPE.offset, false)
}
}
const transDate = (dateTime) => {
const date = new Date(dateTime);
const pad = n => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
const methods = {
filter: (data) => {
const filtered = new Uint32Array(data.length);
let count = 0
for(let i = 0; i < data.length; i++) {
timeConsuming();
const id = data[i]
const result = getDataById(id, sharedBuffer)
if(result.num % 2 === 0) {
filtered[count++] = id
}
}
return {
count,
buffer: filtered.subarray(0, count).buffer
}
},
sort: (array) => {
const sorted = array.sort((a, b) => {
timeConsuming();
const { date: aDate} = getDataById(a, sharedBuffer)
const { date: bDate} = getDataById(b, sharedBuffer)
return Number(aDate) - Number(bDate)
})
return {
count: sorted.length,
buffer: sorted.buffer
}
},
group: (array) => array.reduce((acc, id) => {
timeConsuming();
const item = getDataById(id, sharedBuffer)
item.date = transDate(Number(item.date))
acc[item.type] = (acc[item.type] || []).concat(item);
return acc;
}, {})
};
self.onmessage = ({data: message}) => {
if(!message) return
if (message.type === 'INIT_DATA') {
sharedBuffer = message.data; // 初始化共享数据
return;
}
const {taskName, data: chunkData} = message
if(!taskName) return
const idArray = new Uint32Array(chunkData)
const result = methods[taskName]?.(idArray);
if (taskName === 'group') {
self.postMessage(result); // 普通对象直接发送
} else {
self.postMessage(result, [result.buffer]); // ArrayBuffer使用Transferable
}
};
js
//test.js
import { timeConsuming, FIELD_MAP, getDataById } from './worker.js'
const getRandomDate = () => {
const year = new Date().getFullYear();
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
const randomDate = new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
const pad = n => n.toString().padStart(2, '0');
return `${year}-${pad(randomDate.getMonth() + 1)}-${pad(randomDate.getDate())}`;
}
const array = Array.from({length: 100000}, (_, idx) => ({
id: idx,
num: Math.floor(Math.random() * 100000),
date: getRandomDate(),
type: Math.floor(Math.random() * 10),
}))
const compositeBuffer = new SharedArrayBuffer(array.length * FIELD_MAP.ITEM_SIZE)
const bufferView = new DataView(compositeBuffer)
const idArray = array.map((item, index) => {
const offset = index * FIELD_MAP.ITEM_SIZE
bufferView.setUint32(offset + FIELD_MAP.ID.offset, item.id)
bufferView.setUint32(offset + FIELD_MAP.NUM.offset, item.num)
bufferView.setBigUint64(offset + FIELD_MAP.DATE_TS.offset, BigInt(new Date(item.date).getTime()))
bufferView.setUint8(offset + FIELD_MAP.TYPE.offset, item.type)
return item.id;
})
class WorkerPool {
constructor(workerURL, concurrency = navigator.hardwareConcurrency) {
this.workers = Array.from({length: concurrency}, () => {
const worker = new Worker(workerURL, {type: 'module'})
worker.postMessage({type: 'INIT_DATA', data: compositeBuffer})
return worker;
})
}
parallelize(taskName, array) {
const chunkArray = Array.from({length: this.workers.length}, () => [])
const dataArray = array instanceof Uint32Array ? array : new Uint32Array(array)
if(taskName === 'sort') {
this.sortParallel(chunkArray, dataArray)
} else {
this._defaultParallel(chunkArray, dataArray)
}
return Promise.all(this.workers.map((worker, i) => {
const chunk = chunkArray[i]
return new Promise(resolve => {
worker.onmessage = e => resolve(taskName === 'group' ? e.data : new Uint32Array(e.data.buffer, 0, e.data.count))
worker.postMessage({taskName, data: chunk}, [chunk])
})
}))
}
sortParallel(chunkArray, dataArray) {
const ranges = this.calculateRanges(dataArray)
for(const id of dataArray) {
timeConsuming()
const { date: value } = getDataById(id, compositeBuffer)
const index = this.getRangeIndex(value, ranges)
chunkArray[index].push(id)
}
chunkArray.forEach((chunkItem, index) => {
chunkArray[index] = new Uint32Array(chunkItem).buffer
})
}
_defaultParallel(chunkArray, dataArray) {
const chunkSize = Math.ceil(dataArray.length / this.workers.length)
this.workers.forEach((_, i) => {
const start = i * chunkSize
const end = Math.min(start + chunkSize, dataArray.length)
const chunk = dataArray.slice(start, end)
chunkArray[i] = chunk.buffer
})
}
calculateRanges(array) {
let min = Infinity
let max = -Infinity
const sampleSize = Math.ceil(array.length * 0.01);
Array.from({length: sampleSize}).forEach(() => {
const randomIndex = Math.floor(Math.random() * array.length)
const randomId = array[randomIndex]
const { date: value } = getDataById(randomId, compositeBuffer)
timeConsuming()
min = Math.min(min, Number(value))
max = Math.max(max, Number(value))
})
const step = (max - min) / this.workers.length;
return Array.from({length: this.workers.length}, (_,i) => ({
min: i === 0 ? -Infinity : min + i * step,
// max: i === (this.workers.length - 1) ? Infinity : min + (i + 1) * step
}));
}
getRangeIndex(number, ranges) {
const minVals = ranges.map(r => r.min);
let low = 0
let high = ranges.length - 1
let pos = 0
while (low <= high) {
const mid = (low + high) >> 1
if (number >= minVals[mid]) {
pos = mid
low = mid + 1
} else {
high = mid - 1
}
}
return pos
}
}
const workerPool = new WorkerPool('worker.js');
const flattenTypedArray = chunks => {
// 计算总长度
const totalLength = chunks.reduce((sum, arr) => sum + arr.length, 0);
// 创建目标数组
const result = new Uint32Array(totalLength);
// 分段复制数据
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
};
// 在filterArray中使用
const filterArray = async (array) => {
const chunks = await workerPool.parallelize('filter', array);
return flattenTypedArray(chunks);
};
const sortArray = async (array) => {
const sorted = await workerPool.parallelize('sort', array); // 全量排序
return flattenTypedArray(sorted);
};
const 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;
}, {});
};
// 异步执行流程
const excuteArray = async (array) => {
console.time('total');
console.time('filter')
const filteredArray = await filterArray(array);
console.timeEnd('filter')
console.time('sort')
const sortedArray = await sortArray(filteredArray);
console.timeEnd('sort')
console.time('group')
const groupedArray = await groupArray(sortedArray);
console.timeEnd('group')
console.timeEnd('total');
return groupedArray;
};
excuteArray(idArray).then(console.log);
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 官方教程 进行实践探索。
如果大家还有其他的方案欢迎一起讨论!
欢迎大家关注我的个人博客:https://puppy.xin