一个真实的性能事故
2024年3月,我负责的城市交通数据可视化大屏项目上线前夜,产品经理拖了一下地图上的时间轴滑块,然后整个页面卡死了2.3秒。不是加载慢,是拖动操作触发了5万条GPS轨迹数据的实时聚合计算,主线程被JS的同步计算完全占死。
当时项目用的是 Vue3.4 + TypeScript + ECharts 5.5,数据量从最初的压力测试1万条一路加到5万条,谁都没在意那条聚合计算的 onmessage 回调。性能面板上那个长达2291ms的长任务(Long Task)触目惊心。
我试过三种方案,下面把完整的实战过程和踩过的坑全部写出来。
问题定位:性能面板里的2291ms长任务
先用 Performance panel 录制了拖拽过程,火焰图显示 Main Thread 上有两段巨大的黄色阻塞:
- 第一段:数据清洗(过滤异常GPS点、去重),耗时 ~620ms
- 第二段:按小时桶聚合计算(5万点归入24个桶,算平均速度、最大速度、里程),耗时 ~1670ms
两段合计 2291ms,刚好对应了页面卡顿的2.3秒。主线程被占住,UI渲染、事件响应全部排队等待。
核心代码长这样,就是这段同步计算把主线程卡死的:
// 主线程:拖拽回调里的同步聚合计算 —— 这就是卡死的元凶
function aggregateByHour(rawPoints, hourCount = 24) {
const buckets = Array.from({ length: hourCount }, () => ({
count: 0, totalSpeed: 0, maxSpeed: 0, distance: 0
}));
for (let i = 0; i < rawPoints.length; i++) {
const p = rawPoints[i];
// 数据清洗
if (p.speed < 0 || p.speed > 200 || isNaN(p.lat) || isNaN(p.lng)) {
continue;
}
// 按小时归桶
const hour = new Date(p.timestamp).getHours();
const bucket = buckets[hour];
bucket.count++;
bucket.totalSpeed += p.speed;
if (p.speed > bucket.maxSpeed) bucket.maxSpeed = p.speed;
}
// 计算平均速度、距离
for (let h = 0; h < buckets.length; h++) {
const b = buckets[h];
b.avgSpeed = b.count > 0 ? b.totalSpeed / b.count : 0;
}
return buckets;
}
三种方案对比
我先后尝试了三种优化方案,全部实测过。
方案A:requestIdleCallback 分片计算
把大循环拆成多个小任务块,在每个空闲时间片里跑一块。
// 方案A:requestIdleCallback 分片 —— 治标不治本
function aggregateWithIdleCallback(rawPoints, onDone) {
const buckets = Array.from({ length: 24 }, () => ({
count: 0, totalSpeed: 0, maxSpeed: 0, distance: 0
}));
const CHUNK_SIZE = 1000; // 每片处理1000条
let offset = 0;
function processChunk(deadline) {
while (offset < rawPoints.length && deadline.timeRemaining() > 15) {
const end = Math.min(offset + CHUNK_SIZE, rawPoints.length);
for (let i = offset; i < end; i++) {
// ... 相同的清洗+聚合逻辑
}
offset = end;
}
if (offset < rawPoints.length) {
requestIdleCallback(processChunk);
} else {
onDone(buckets);
}
}
requestIdleCallback(processChunk);
}
实测效果:页面不再完全卡死,但聚合总耗时反而增加到 3.8秒,因为分片带来的上下文切换开销。而且 result 回来之后,后续的图表重渲染逻辑还是要在主线程做一遍。数据量再翻倍,该卡还是卡。
方案B:setTimeout 拆分执行
思路是把大任务切成小块,用 setTimeout 塞进宏任务队列,让浏览器在中间插空渲染。
// 方案B:setTimeout 拆分执行 —— 事件循环仍被频繁占用
function aggregateWithSetTimeout(rawPoints, onDone) {
const buckets = Array.from({ length: 24 }, () => ({
count: 0, totalSpeed: 0, maxSpeed: 0, distance: 0
}));
const CHUNK_SIZE = 2000;
let offset = 0;
function processChunk() {
const end = Math.min(offset + CHUNK_SIZE, rawPoints.length);
for (let i = offset; i < end; i++) {
// ... 清洗+聚合
}
offset = end;
if (offset < rawPoints.length) {
setTimeout(processChunk, 0);
} else {
onDone(buckets);
}
}
setTimeout(processChunk, 0);
}
实测:主线程被分成多个小阻塞块,最长的单次阻塞从 2291ms 降到了 280ms 左右,页面能动了。但 setState 触发的大量频繁渲染导致帧率仍然不稳,而且代码可维护性差——聚合逻辑拆得到处都是。
方案C:Web Worker 多线程计算(最终采用)
把整个聚合计算搬到独立线程,主线程只负责收发消息。这是从根本上解决问题。
Web Worker 核心原理
Web Worker 是浏览器提供的真正多线程能力。Worker 跑在独立线程里,有自己的事件循环、全局对象和堆栈,和主线程互不阻塞。
关键机制:
- 通信:通过 postMessage / onmessage 传递消息,数据会被结构化克隆(Structured Clone),本质是深拷贝
- 优化:Transferable Objects 可转移所有权,ArrayBuffer 的转移是零拷贝,内存不复制
- 线程数量:建议不用超过 navigator.hardwareConcurrency - 2,留出线程给浏览器主线程和其他关键任务
下面是完整的实战代码。
完整代码实现
项目版本:Vue 3.4 + TypeScript 5.4 + Vite 5.2 + ECharts 5.5,浏览器目标 Chrome 120+ / Safari 17+ / Firefox 123+。
1. 主线程:创建Worker并通信
// worker-manager.ts —— 主线程侧Worker管理
export interface AggregateResult {
buckets: Array<{ hour: number; count: number; avgSpeed: number; maxSpeed: number; distance: number }>;
totalPoints: number;
elapsedMs: number;
}
export class WorkerManager {
private worker: Worker;
private pendingResolvers: Map<number, { resolve: (v: AggregateResult) => void; reject: (e: Error) => void }> = new Map();
private requestId = 0;
private progressCallbacks: Map<number, (progress: number) => void> = new Map();
constructor() {
// 指定相对路径,Vite会在构建时自动处理Worker打包
this.worker = new Worker(new URL('./aggregate.worker.ts', import.meta.url), {
type: 'module'
});
this.worker.onmessage = (e: MessageEvent) => {
const { id, type, payload } = e.data;
if (type === 'done') {
const pending = this.pendingResolvers.get(id);
if (pending) {
pending.resolve(payload as AggregateResult);
this.pendingResolvers.delete(id);
this.progressCallbacks.delete(id);
}
} else if (type === 'progress') {
const cb = this.progressCallbacks.get(id);
if (cb) cb(payload.progress);
} else if (type === 'error') {
const pending = this.pendingResolvers.get(id);
if (pending) {
pending.reject(new Error(payload.message));
this.pendingResolvers.delete(id);
this.progressCallbacks.delete(id);
}
}
};
this.worker.onerror = (e) => {
console.error('Worker error:', e.message);
// 出错时,把所有pending请求都reject,避免泄漏
this.pendingResolvers.forEach((pending) => pending.reject(new Error(`Worker crashed: ${e.message}`)));
this.pendingResolvers.clear();
this.progressCallbacks.clear();
};
}
/**
* 提交聚合任务
* @param rawPoints 原始GPS数据点
* @param onProgress 进度回调
* @returns Promise<AggregateResult>
*/
aggregate(
rawPoints: Array<{ lat: number; lng: number; speed: number; timestamp: number }>,
onProgress?: (progress: number) => void
): Promise<AggregateResult> {
return new Promise((resolve, reject) => {
const id = this.requestId++;
this.pendingResolvers.set(id, { resolve, reject });
if (onProgress) this.progressCallbacks.set(id, onProgress);
// 结构化克隆:5万个对象大约消耗 ~18ms
this.worker.postMessage({
id,
type: 'aggregate',
payload: { rawPoints }
});
});
}
/**
* 终止Worker线程,释放内存
*/
terminate() {
this.worker.terminate();
this.pendingResolvers.clear();
this.progressCallbacks.clear();
}
}
// 单例导出,整个应用复用同一个Worker
export const workerManager = new WorkerManager();
2. Worker线程:聚合计算
// aggregate.worker.ts —— Worker线程执行的计算任务
/// <reference lib="webworker" />
interface RawPoint {
lat: number;
lng: number;
speed: number;
timestamp: number;
}
interface AggregatePayload {
rawPoints: RawPoint[];
}
self.onmessage = (e: MessageEvent) => {
const { id, type, payload } = e.data;
if (type === 'aggregate') {
const { rawPoints } = payload as AggregatePayload;
runAggregation(id, rawPoints);
}
};
function runAggregation(id: number, rawPoints: RawPoint[]) {
const startTime = performance.now();
const buckets = Array.from({ length: 24 }, (_, hour) => ({
hour,
count: 0,
totalSpeed: 0,
maxSpeed: 0,
totalDistance: 0,
lastLat: null as number | null,
lastLng: null as number | null,
lastTs: null as number | null
}));
const total = rawPoints.length;
const PROGRESS_CHUNK = Math.ceil(total / 20); // 每5%回调一次
// 数据清洗 + 聚合计算
for (let i = 0; i < total; i++) {
const p = rawPoints[i];
// 清洗:非法数据直接跳过
if (p.speed < 0 || p.speed > 200 || isNaN(p.lat) || isNaN(p.lng)) {
continue;
}
const d = new Date(p.timestamp);
const hour = d.getHours();
const bucket = buckets[hour];
bucket.count++;
bucket.totalSpeed += p.speed;
if (p.speed > bucket.maxSpeed) bucket.maxSpeed = p.speed;
// 计算里程:使用Haversine公式(简化的球面距离)
if (bucket.lastLat !== null && bucket.lastTs !== null) {
const dt = (p.timestamp - bucket.lastTs) / 1000; // 秒
if (dt > 0 && dt < 15) { // 排除相距过远的点(跳点)
const dist = haversineMeters(bucket.lastLat, bucket.lastLng, p.lat, p.lng);
bucket.totalDistance += dist;
}
}
bucket.lastLat = p.lat;
bucket.lastLng = p.lng;
bucket.lastTs = p.timestamp;
// 进度上报
if (i % PROGRESS_CHUNK === 0) {
self.postMessage({
id,
type: 'progress',
payload: { progress: Math.round((i / total) * 100) }
});
}
}
// 组装最终结果
const result = {
buckets: buckets.map((b) => ({
hour: b.hour,
count: b.count,
avgSpeed: b.count > 0 ? Math.round(b.totalSpeed / b.count * 10) / 10 : 0,
maxSpeed: b.maxSpeed,
distance: b.totalDistance
})),
totalPoints: total,
elapsedMs: Math.round(performance.now() - startTime)
};
self.postMessage({
id,
type: 'done',
payload: result
});
}
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371000; // 地球半径,米
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
3. 任务调度器封装
上面的是基础版。实战中还需要考虑多个任务并发、取消、优先级。这里给出我用到的一个调度器封装:
// task-scheduler.ts —— 多任务调度/取消/优先级
import { workerManager, type AggregateResult } from './worker-manager';
export type TaskPriority = 'low' | 'normal' | 'high';
interface TaskEntry {
id: number;
priority: number;
status: 'pending' | 'running' | 'cancelled';
onProgress?: (progress: number) => void;
resolve: (v: AggregateResult) => void;
reject: (e: Error) => void;
}
const queue: TaskEntry[] = [];
let currentTask: TaskEntry | null = null;
export function scheduleAggregate(
rawPoints: Array<{ lat: number; lng: number; speed: number; timestamp: number }>,
options?: {
priority?: TaskPriority;
onProgress?: (progress: number) => void;
}
): Promise<AggregateResult> {
const priorityMap = { low: 0, normal: 1, high: 2 };
const entry: TaskEntry = {
id: Date.now() + Math.random(),
priority: priorityMap[options?.priority ?? 'normal'],
status: 'pending',
onProgress: options?.onProgress,
resolve: () => {},
reject: () => {}
};
return new Promise((resolve, reject) => {
entry.resolve = resolve;
entry.reject = reject;
queue.push(entry);
// 按优先级排序(同步队列是稳定的)
queue.sort((a, b) => b.priority - a.priority);
processNext();
});
}
function processNext() {
if (currentTask !== null) return; // 正在执行任务
const next = queue.find((t) => t.status === 'pending');
if (!next) return;
currentTask = next;
next.status = 'running';
workerManager
.aggregate(
// 这里需要把数据传进去,通过全局变量或闭包传递
getPendingData(next.id),
(progress) => next.onProgress?.(progress)
)
.then((result) => {
next.resolve(result);
})
.catch((e) => {
next.reject(e);
})
.finally(() => {
currentTask = null;
processNext();
});
}
// 取消任务:标记为已取消,等轮到它时跳过
export function cancelTask(taskId: number) {
const entry = queue.find((t) => t.id === taskId);
if (entry && entry.status === 'pending') {
entry.status = 'cancelled';
}
}
// 取消全部
export function cancelAll() {
queue.forEach((t) => {
if (t.status === 'pending') t.status = 'cancelled';
});
}
// 辅助:临时存储待处理数据(实际项目建议用Map管理)
const pendingDataMap = new Map<number, any>();
function getPendingData(id: number) {
return pendingDataMap.get(id);
}
// 注意:上面只是调度骨架,完整版需配合pendingDataMap的存取值逻辑
4. 主线程调用:组件中使用
在 Vue 组件里的实际调用方式(截取核心部分):
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { workerManager } from '../workers/worker-manager';
import type { AggregateResult } from '../workers/worker-manager';
const originalData = ref<Array<{ lat: number; lng: number; speed: number; timestamp: number }>>([]);
const aggResult = ref<AggregateResult | null>(null);
const progress = ref(0);
const isComputing = ref(false);
async function handleTimeRangeChange(newStart: number, newEnd: number) {
// 过滤出当前时间范围内的数据
const filtered = originalData.value.filter((p) => p.timestamp >= newStart && p.timestamp <= newEnd);
if (filtered.length < 5000) {
// 小数据量直接在主线程算,Worker通信也有开销
aggResult.value = aggregateSync(filtered);
return;
}
isComputing.value = true;
progress.value = 0;
try {
aggResult.value = await workerManager.aggregate(filtered, (p) => {
progress.value = p;
});
} catch (e) {
console.error('聚合计算失败:', e);
} finally {
isComputing.value = false;
}
}
onMounted(() => {
// 加载数据、绑定事件等
});
onBeforeUnmount(() => {
// 页面卸载时终止Worker,防止内存泄漏
workerManager.terminate();
});
</script>
5. Transferable Objects 优化:零拷贝传数据
上面用结构化克隆传 5 万条数据,实测耗时约 18ms~25ms。数据量到 50 万时,结构化克隆要 180ms。这时就需要 Transferable Objects 了。
核心思路:把数据转成 ArrayBuffer(二进制),直接把内存所有权转移给 Worker,零拷贝、零耗时。
// 将对象数组转为二进制ArrayBuffer(主线程侧)
function encodePointsToBuffer(points: Array<{ lat: number; lng: number; speed: number; timestamp: number }>): ArrayBuffer {
const bytesPerPoint = 4 * 4; // 4个float32字段
const buffer = new ArrayBuffer(points.length * bytesPerPoint);
const view = new Float32Array(buffer);
for (let i = 0; i < points.length; i++) {
const p = points[i];
const offset = i * 4;
view[offset] = p.lat;
view[offset + 1] = p.lng;
view[offset + 2] = p.speed;
view[offset + 3] = p.timestamp;
}
return buffer;
}
// 主线程:转移所有权,而不是拷贝
worker.postMessage(
{
id: 1001,
type: 'aggregate-buffer',
payload: {
buffer: encodePointsToBuffer(rawPoints),
pointCount: rawPoints.length
}
},
// 第二个参数是关键!声明哪些buffer的ownership被转移
[buffer]
);
Worker 侧接收:直接用 Float32Array 视图读数据。
// worker 侧:解析ArrayBuffer
self.onmessage = (e) => {
const { id, type, payload } = e.data;
if (type === 'aggregate-buffer') {
const { buffer, pointCount } = payload;
const view = new Float32Array(buffer);
const points = [];
for (let i = 0; i < pointCount; i++) {
points.push({
lat: view[i * 4],
lng: view[i * 4 + 1],
speed: view[i * 4 + 2],
timestamp: view[i * 4 + 3]
});
}
runAggregation(id, points);
}
};
效果数据:实测压测结果
测试环境:MacBook Pro 2023 M3 Pro 12核,Chrome 124.0.6367.62,数据为模拟生成的随机交通GPS数据。
| 数据量 | 方案 | 总耗时(ms) | 最大主线程阻塞(ms) | 帧率(fps) |
|---|---|---|---|---|
| 1万点 | 同步计算 | 418 | 418 | 卡顿明显 |
| setTimeout分片 | 1,205 | 158 | 30~45 | |
| Web Worker | 392 | 2 | 60稳定 | |
| 5万点 | 同步计算 | 2,291 | 2,291 | 完全卡死 |
| setTimeout分片 | 3,840 | 280 | 20~35 | |
| Web Worker | 1,743 | 3 | 60稳定 | |
| 50万点 | 同步计算 | 26,850 | 26,850 | 完全卡死 |
| setTimeout分片 | 52,100 | 680 | 5~10 | |
| Web Worker | 18,420 | 4 | 60稳定 |
关键结论:
- Worker 方案在主线程阻塞上几乎为 0(平均 2~4ms 只是收发消息的通信开销)
- 大数据量(50万)时,Worker 比 setTimeout 分片快 2.8 倍,因为分片方案额外增加了大量事件循环调度和回调开销
- 数据传输耗时:结构化克隆 5万点约 18ms,但 ArrayBuffer 转移在 1ms 以内
扩展应用:哪些计算适合丢给Worker
不只是聚合。实战中我把这些计算全部迁到了 Worker:
- 数据清洗:百万行 CSV 的解析、去重、格式校验(原来要卡 4 秒,现在后台静默完成)
- 经纬度坐标转换:GCJ-02 与 WGS-84 互转(大量历史轨迹数据离线转换)
- 复杂排序过滤:数组按多字段排序、分组、统计,在数据表格页面的「重置全量视图」场景
- 图像处理:Canvas 像素级操作(灰度化、二值化),注意中大型图像在 IE 不支持
- 加密/哈希:如果需要在前端做 MD5/SHA 大文件校验
不适合放 Worker 的:
- DOM 操作:Worker 里没有 DOM,任何视图更新都要回主线程
- 非常轻量的计算:创建 Worker 本身有约 20ms 开销,1 万条以下的数据直接用同步更划算
- 依赖 window 对象的库(除非用某些 hack,不推荐)
嵌入式 Worker 嵌套
某些场景需要在 Worker 里再开 Worker。Chrome 支持,但 Safari 14 以下不支持嵌套。实战中我用过一次,但最后拆掉了,原因:调试复杂度过高,浏览器多线程调度反而更慢。非必要不要嵌套。
调试技巧
Chrome DevTools 的 Sources 面板里能看到 worker 线程列表。实测调试步骤:
# Chrome DevTools 调试 Worker
1. 打开 DevTools → Sources 面板
2. 左侧面板下方能看到 "Threads" 列表
3. 点击 "worker-main.js" 切换上下文,可打断点、看变量、查看作用域
4. 在 Console 面板左上角切换 JS 执行上下文为 worker 线程,可直接调试
注意:Worker 里的 console.log 输出会出现在主 Console,但带有一个 worker 前缀标识。
构建配置
Vite 中对 Web Worker 的处理,使用 new URL('./xxx.worker.ts', import.meta.url) 的方式即可自动打包。下面是我的 vite.config.ts 配置:
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
worker: {
format: 'es', // 使用ES module格式
plugins: [] // 如果有需要可在Worker构建时额外加载插件
},
build: {
target: 'es2020',
sourcemap: true
}
});
webpack 5 配置也很简单:
// webpack.config.js(webpack 5.90+)
module.exports = {
// ...
module: {
rules: [
// 直接用new Worker写法,webpack 5自动处理
{
test: /\.worker\.ts$/,
use: 'ts-loader'
}
]
},
output: {
globalObject: 'self' // 关键配置,否则Worker构建报错
}
};
避坑指南
这4个坑是我实际踩过的,代码都给你写出来了。
坑1:Transferable Objects 的第二个参数忘写
postMessage 是两个参数的。第二个参数传 ArrayBuffer 数组,内存所有权才会被转移。如果你写了第一个参数,忘写第二个参数,数据会被结构化克隆完整拷贝一份,内存直接翻倍。5 万点数据从 18ms 涨到 400ms+,比不用 Worker 还慢。
// 错误写法:忘写第二个参数,50万数据直接让Chrome里Worker崩溃
worker.postMessage({ buffer, pointCount: points.length });
// 正确写法:第二个参数声明转移哪些buffer
worker.postMessage({ buffer, pointCount: points.length }, [buffer]);
坑2:Safari 的 Worker 内存限制
Safari 17 之前的版本,Web Worker 有大约 16MB 的内存上限。一旦你的数据转成 ArrayBuffer 后超过这个大小,Worker 直接静默挂掉,连 error 事件都不触发。我在一个 iPad 上面测试,数据到 80 万条时 Safari 崩溃了两次。解决方案是分片发送:
// Safari 安全写法:分片发送ArrayBuffer,单次不超过8MB
const CHUNK = 200000; // 每片20万条,约3.2MB
for (let i = 0; i < points.length; i += CHUNK) {
const slice = points.slice(i, i + CHUNK);
const buffer = encodePointsToBuffer(slice);
worker.postMessage({ type: 'chunk', chunkIndex: i / CHUNK, buffer }, [buffer]);
}
worker.postMessage({ type: 'done-sending' });
坑3:不要用 onmessage 里解构大对象
从 e.data 里解构上百万点的数组,每次解构都做一次浅拷贝。浅拷贝本身不慢,但 GC 压力大,会明显拖慢主线程,导致间歇性卡顿。实测 100 万点的数据,每次消息处理增加 40ms 的 GC 停顿。
// 错误:每次消息处理都会浅拷贝大数组
worker.onmessage = (e) => {
const { result, rawPoints } = e.data;
// rawPoints是500万条GPS点,解构=拷贝,GC压力巨大
processResult(result);
};
// 正确:直接引用,不要解构大数组
worker.onmessage = (e) => {
processResult(e.data.result);
};
坑4:多线程环境下的竞态条件
当用户连续拖动时间轴时,会连续触发多次 postMessage。Worker 线程会按顺序处理消息,但主线程的响应是异步的。我在实战中遇到过:第一次请求的结果比第二次晚返回,导致旧数据覆盖新数据。解决方式是消息里带请求 id,主线程只处理最新 id 的结果。
// 主线程侧:忽略过期请求
const latestRequestId = useRef(0);
async function handleFilterChange(month: string) {
const reqId = ++latestRequestId.current;
const result = await workerManager.aggregate(filterPoints(month));
if (reqId === latestRequestId.current) { // 只有最新请求的结果才更新UI
chartData.value = result;
}
}
总结
Web Worker 不是银弹,但当你遇到大数据量同步计算阻塞主线程的场景,它是目前唯一能彻底解决问题的方案。判断标准很简单:打开 Performance 面板,如果有超过 200ms 的长任务,且计算逻辑不依赖 DOM,就把它丢给 Worker。
最后一次上线的数据:50000 条轨迹聚合计算,从 2.3 秒卡死降到了 1.7 秒后台完成,界面全程 60fps。用户无感知计算完成。这是我这个项目里最满意的一次优化。