在前端开发中,处理海量数据的渲染已有不少成熟方案:虚拟列表精准控制可视区域、时间分片平衡渲染与交互、分页加载渐进呈现内容。但当业务场景转向数据计算密集型任务时,就会面临截然不同的挑战:计算耗时引发交互冻结、复杂算法拖慢主线程、内存泄漏导致页面卡死等等。而这类问题的解决方案在前端领域却鲜少被讨论,相关可参考的文章也比较少。
本文将结合示例,探讨在10w+数据量级下可行的解决思路,涵盖数据结构优化、并行计算策略等关键技术点,抛砖引玉供大家参考讨论。
首先我这里假设一个场景:我们现在有一个表,当前这个表有10w+的数据量,现在有几个功能,分别是筛选、排序和分组。在前端需要根据筛选条件、排序规则和分组规则对数据进行计算,然后将最终的结果做一个展示。表格是可以操作的,用户修改了表格某个单元格的值,那么前端就要根据当前的数据重新进行筛选、排序和分组的计算操作,将计算后的排列结果呈现在表格上。
这个场景下很明确前端的计算耗时就在筛选、排序和分组上。我们先用代码简单模拟下。
常规实现
首先我们创建一个test.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 字段进行分组。
// 模拟真实业务的耗时操作
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;
}, {});
}
最后我们调用这三个函数,并进行计时。
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文件,将所有的计算函数都挪到这里来。同时监听主线程的消息,并根据消息中的任务名称执行对应的函数。然后将计算结果发送给主线程。
// 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 数量,因此不要将其视为用户系统中核心数量的绝对测量值。
//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: 因为是多线程处理,多以接收到的值是数组,需要进行扁平化处理。
const filterArray = async (array) => { const chunks = await workerPool.parallelize('filter', array) return [].concat(...chunks) } -
sortArray: 这里需要注意的是,接收到的多个数组,并不能直接合并成一个数组。因为不同数组之间是无序的,所以我们需要使用归并排序将多个数组合并成一个有序的数组。
const 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: 这里将对象的键值合并下就可以了。
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; }, {}); };
主线程完整代码如下:
// 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()。这里我们要清楚的两点是:首先 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 。
// 计算区间范围
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 是不做改动的。
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( + 2n), 2n 是获取区间范围数据和分配区间数据各遍历了一遍。
const sortArray = async (array) => {
const sorted = await workerPool.parallelize('sort', array);
return [].concat(...sorted)
};
1.3.4 结果验证
可以看到,排序阶段的耗时已经大幅下降到了 940ms 左右,并且排序后的结果和原始结果一致。总耗时也优化到了 1400ms 级别。
那么这里还能不能再优化呢?
答案是肯定的。我们可以注意到智能分片之前,我们进行两次遍历,一次是用来确定分片区间,一次是把数据分配到各个区间里。实际上第一次遍历的性能是浪费的,因为这一步只是用来寻找最大值和最小值。这里我们可以再优化下。
1.3.5 随机取样获取区间范围
对于 10w+ 级别的数据,我们不需要遍历所有的数据来计算区间范围。可以使用抽样方法估算数据分布,避免全量遍历,比如 1% 的数据量就够了。我们对之前的方法改造下。
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( + 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 中定义一个数据格式描述对象并导出。
// 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 并写入数据
// 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 视图来读取。
// 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 从共享内存中读取对应的数据。
// 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 从共享内存中读取对应的数据。
// 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, 就需要组装成普通对象返回了。
// 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。
//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 完整代码
// 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
}
};
//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