Web Worker多线程计算实战:主线程零阻塞
发布日期: 2026/08/12 阅读总量: 1

一个真实的性能事故

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万点同步计算418418卡顿明显
setTimeout分片1,20515830~45
Web Worker392260稳定
5万点同步计算2,2912,291完全卡死
setTimeout分片3,84028020~35
Web Worker1,743360稳定
50万点同步计算26,85026,850完全卡死
setTimeout分片52,1006805~10
Web Worker18,420460稳定

关键结论:

  • 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。用户无感知计算完成。这是我这个项目里最满意的一次优化。