【万字长文】前端如何处理计算密集型操作(数据量10w+)

374 阅读16分钟

  在前端开发中,处理海量数据的渲染已有不少成熟方案:虚拟列表精准控制可视区域、时间分片平衡渲染与交互、分页加载渐进呈现内容。但当业务场景转向数据计算密集型任务时,就会面临截然不同的挑战:计算耗时引发交互冻结、复杂算法拖慢主线程、内存泄漏导致页面卡死等等。而这类问题的解决方案在前端领域却鲜少被讨论,相关可参考的文章也比较少。

  本文将结合示例,探讨在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))

  运行结果如下:

normalComsumer.png   从结果中可以看到,筛选耗时在 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);

  我们运行下代码,看下结果如何。

webworkerComsumer.png

  什么?使用webworker多线程处理后,总耗时居然没有显著提升。那我不是白优化了吗?别急,我们一个环节一个环节来分析。

1.2 问题分析

  • filter: 首先 filter 耗时从 480ms 优化到了 330ms。是有一些提升,但是没有预想的那么大,因为从时间复杂度上来说是从 O(n) 降到 O(n12\frac{n}{12})。这里我们要清楚的两点是:首先 webworker 的线程连接是需要耗时的,而 filter 函数是第一个去执行的,所以这部分的耗时就算在了它头上。其次,在数据的传输过程中,结构化克隆有序列化开销。这两部分的耗时使得 filter 看起来没有太大的提升。实际上 filter 真正耗时应该在一百多毫秒的样子。
  • group:group 耗时从 800ms 优化到 140ms,这个提升还是挺大的。
  • sort:耗时从 2800ms 增加到 将近 3500ms,这个直接反向优化了,总耗时没有显著提升的罪魁祸首。为什么多个线程排序反而消耗的时间更长了呢?首先我们先来分析下时间复杂度。
    • 原始排序用的内置 sort 方法,时间复杂度为 O(nlogn)
    • 使用webworker多线程排序,每个线程均分的数据量是 n12\frac{n}{12},这里不考虑线程之间的差异,假设都是同一时间完成,那么时间复杂度就是 O(n12log(n12)\frac{n}{12} \cdot \log\left(\frac{n}{12}\right))。然后使用多路归并排序,时间复杂度是 O(12nlog12)。总的时间复杂度就是 O(n12log(n12)\frac{n}{12} \cdot \log\left(\frac{n}{12}\right) + 12nlog12)。这种情况下时间复杂度就已经比原始排序要高了。所以消耗的时间也就更长。

问题总结:

  1. webwoker 连接耗时
  2. 结构化克隆序列化时的开销
  3. 多路归并排序的时间复杂度

  前两个问题看起来不太好解决,第三个问题的话,如果我们能找到一种不使用多路并归排序的算法,那问题是不是就解决了呢?那么想要不使用多路并归的话,就需要保障不同的线程之间数据依然是有序的。这就要求我们在进行数据切片时需要根据数据的范围智能切片。

1.3 智能分片策略

  我们现在有12个线程,相当于12个盒子,我们要将数据根据大小放到不同的盒子里,这样就保障了不同盒子间的数据是有序的。

1.3.1 将数据数值化

  首先,我们想要将数据根据大小进行区分的话,这个数据必须得是数字。而排序的规则使用的不一定是 number 类型的字段,但是既然是排序,那么它一定是可以根据自己业务要求转换为对应的一个数值,字符串也好、日期也好,都是可以转换成数值的。这个可以根据自己的业务要求实现一个转换函数,我这里用的日期字段,直接用时间戳就可以了。

1.3.2 根据数值划分区间

  然后,我们根据数值范围划分区间。需要先拿到最大值和最小值,然后根据区间数量,计算每个区间的数值范围。因为我是随机生成的日期,大概可以认为是均匀分布的,所以我范围是等分的。在 WorkerPool 类里新增两个方法 calculateRangesgetRangeIndex

// 计算区间范围
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(n12log(n12)\frac{n}{12} \cdot \log\left(\frac{n}{12}\right) + 2n), 2n 是获取区间范围数据和分配区间数据各遍历了一遍。

const sortArray = async (array) => {
  const sorted = await workerPool.parallelize('sort', array);
  return [].concat(...sorted)
};

1.3.4 结果验证

  可以看到,排序阶段的耗时已经大幅下降到了 940ms 左右,并且排序后的结果和原始结果一致。总耗时也优化到了 1400ms 级别。

webworkerComsumer2.png

  那么这里还能不能再优化呢?
  答案是肯定的。我们可以注意到智能分片之前,我们进行两次遍历,一次是用来确定分片区间,一次是把数据分配到各个区间里。实际上第一次遍历的性能是浪费的,因为这一步只是用来寻找最大值和最小值。这里我们可以再优化下。

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(n12log(n12)\frac{n}{12} \cdot \log\left(\frac{n}{12}\right) + n)。 我们再来看下优化后的结果。可以看到优化了快 200ms,总耗时也降到了 1200 ms。

webworkerComsumer3.png

1.4 共享内存和可转移对象

  在主线程与 worker 之间传递的数据是通过拷贝,而不是共享来完成的。传递给 worker 的对象需要经过序列化,接下来在另一端还需要反序列化。主线程与 worker 不会共享同一个实例,最终的结果就是在每次通信结束时生成了数据的一个副本。大部分浏览器使用结构化克隆来实现该特性。所以我们分析耗时操作时就有提到这一点。
  但是,有些对象是可转移对象,即可以在主线程与 worker 之间直接传递,这样的数据就不需要经过结构化克隆,从而减少数据传输的开销。
  可以被转移的不同规范的对象有多种,如 ArrayBufferMessagePortReadableStreamWritableStream 等。我们这里使用 ArrayBuffer 来作为可转移对象。对这些概念不熟悉的可以参考 可转移对象
  同时还可以使用共享内存 SharedArrayBuffer,让主线程和 worker 线程共享一个数据块,这样对于一些大的数据就不需要反复进行传输了。共享内存可以被 worker 线程或主线程创建和同时更新。根据系统(CPU、操作系统、浏览器)的不同,需要一段时间才能将变化传递给所有上下文环境。因此需要通过 原子 操作来进行同步

1.4.1 数据结构改造

  理清上述概念后,我们可以把原始数据使用 SharedArrayBuffer 来存储。让主线程和 worker 线程共享这个数据,然后筛选、排序和分组操作的结果我们只需要传输 id 数据就可以了,在每个操作里通过 id 来获取对应的数据。这样我们就可以减少数据传输的开销。id 的数据我们使用 ArrayBuffer 来存储,通信的时候转移。整体流程图如下:

liucheng.png

  因为 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 结果测试

  我们来运行下代码看看结果

ArrayBuffer.png   最终得到得结果是正确的,没问题。同时可以发现 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)是一种面向现代浏览器的低级字节码格式,它的核心设计目标是实现高性能计算。以下是对其核心概念的通俗解读:

  1. 跨平台性能引擎: WASM 不是编程语言,而是编译目标。允许将 C/C++/Rust 等语言编译成紧凑的二进制格式,在浏览器中接近原生速度运行。
  2. 沙箱化安全执行: 通过内存隔离和类型检查机制,确保代码在受限环境中安全运行,避免传统插件的安全隐患。
  3. 多语言生态桥梁: 前端开发者可通过 JavaScript API 调用 WASM 模块,实现关键计算逻辑的性能突破。

应用流程

  1. 源码准备:使用 C/Rust 等系统级语言编写计算密集型函数(如矩阵运算、物理模拟)。
  2. 编译转换:通过 Emscripten/wasm-pack 等工具链将源码编译为 .wasm 二进制模块。
  3. 前端集成:JavaScript 通过 WebAssembly.instantiate() 加载模块,并通过内存共享机制传递数据。
  4. 性能调优:结合 Worker 线程与 SIMD 指令集,最大化利用硬件资源。

  由于 WASM 涉及多语言工具链整合、内存管理优化等进阶主题,作者尚在结合具体业务场景进行技术验证。建议感兴趣的同学可参考 MDN 官方教程 进行实践探索。

如果大家还有其他的方案欢迎一起讨论!

欢迎大家关注我的个人博客:https://puppy.xin