一个常见的场景:用户上传了一份几千行的 CSV,前端要按多字段组合过滤、排序、还要做几个聚合计算。你的 UI 在点击按钮后直接卡死两秒,滚动条拖不动,动画像 PPT。这不是 Vue 的问题,是主线程在干不该它干的体力活。

浏览器的主线程要管 DOM 渲染、事件分发、CSS 计算,还要跑你的 JavaScript。当一段循环 O(n²) 的数据过滤占用了 300ms 以上,用户就能感知到掉帧。你打开 Performance 面板,看到长长的黄色 Task 横条——那就是罪魁祸首。

Web Worker 的解决方案其实不算新,2011 年就有了。它开出一个独立线程,不能操作 DOM,但能跑纯计算,通过 postMessage 和主线程通信。问题是它用起来挺别扭——要单独写一个文件,消息传递得像发电报,还得自己管理生命周期。所以很多人知道这个东西,但很少在 Vue 项目里真正用上。

Vue 3 的组合式 API 刚好破了这个局。你能把 Worker 的创建、消息收发、销毁全部封装到 useWorker 这个 composable 里,组件只需要调用一个函数、拿到一个响应式的返回值。数据过滤的逻辑剥离出去,主线程只负责把结果往模板里一扔。关注点分离得干干净净。

复杂计算不该在 setup 里裸跑,也不该交给 setTimeout 假装不卡。给 Worker 一条活路,Vue 3 的封装能让你写起来像在调用本地函数。

从零搭一个可复用的 useWorker

先把架子搭起来。我们写一个 useWorker,它接收一个计算函数,返回 resultloadingerror 三个响应式状态,再暴露一个 run 方法触发执行。这样组件里就只剩纯粹的消费端。

Worker 不能操作 DOM,但它能把繁重的循环、正则匹配、聚合统计搬到另一条时间线。我们用 new Worker(url, { type: 'module' }) 启动,并通过 postMessage 发送数据,onmessage 接收结果。为了避免每次都手写消息协议,我们在 composable 里统一做一层包装。

export function useWorker(fn) {
  const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
  const result = ref(null);
  const loading = ref(false);
  const error = ref(null);
  worker.onmessage = (e) => {
    const { type, data } = e.data;
    if (type === 'success') result.value = data;
    if (type === 'error') error.value = data;
    if (type !== 'progress') loading.value = false;
  };
  const run = (payload) => {
    loading.value = true;
    error.value = null;
    worker.postMessage({ fn, payload });
  };
  return { result, loading, error, run };
}

组件拿到这几个 ref,直接丢进模板或 watch 就行;业务逻辑全部留在 worker 线程里跑。你甚至可以在 UI 上放一个进度条,配合 onmessage 里的 'progress' 类型实时更新,体验跟本地函数调用几乎没差。

别忘了在组件卸载时把线程关掉。composable 里导出一个 dispose 方法,内部调 worker.terminate(),再把引用清空。如果你忘了手动调用,也可以在 onUnmounted 里自动执行,保证不会留下无人认领的线程。

useWorker composable function Vue 3

实际场景:万级数据过滤与排序

说个实际场景吧。你有个用户管理系统,一万条记录,姓名、邮箱、注册时间、状态。用户在前端输入关键词,要实时过滤——每敲一个字母,列表就得更新。用主线程做这事,Chrome DevTools 的 Performance 面板会告诉你:每次过滤掉 60-80ms,帧率从 60fps 直接跌到 20fps 以下。输入框的反馈开始卡顿,光标闪得都不自然了。

上一章封装的 useWorker 刚好派上用场。先写一个 worker 文件:

// filter.worker.js
self.onmessage = (e) => {
  const { list, keyword, field } = e.data.payload;
  const start = performance.now();
  const lower = keyword.toLowerCase();
  const result = list.filter(item => {
    const val = item[field] || '';
    return val.toLowerCase().includes(lower);
  });
  const elapsed = performance.now() - start;
  self.postMessage({
    type: 'success',
    data: { result, elapsed, total: list.length, matched: result.length }
  });
};

然后在组件里这样用:

const { result, loading, run, dispose } = useWorker(() => {});

// 注意 useWorker 内部会把 worker 的 onmessage 统一处理
// 所以这里只需要把过滤参数传进去就行
watch(keyword, (newVal) => {
  if (!newVal) {
    filteredList.value = rawList.value;
    return;
  }
  run({ list: rawList.value, keyword: newVal, field: 'name' });
});

onUnmounted(() => dispose());

等等,useWorker 内部不是已经处理了 run 吗?对,但你得注意一点:rawList 如果很大,把它序列化再 postMessage 也有开销。一万条 JSON 对象大概 2MB,序列化+传输约 30-50ms。这还是在主线程做的,不过它只发生一次(数据首次加载时),过滤阶段传过去的只是用户敲的几个字符,开销忽略不计。

我在 MacBook Pro M1 上测过。不加 worker:每次按键触发过滤,主线程阻塞 40-70ms,输入框出现明显的丢帧感。 回调间隔从 16ms 跳到 30-50ms。加上 worker 之后,每次过滤耗时从主线程转移到后台线程,主线程的帧率稳定在 55-60fps。过滤本身在 worker 里跑了多久?跟主线程差不多,也是 40-60ms——但用户感知不到,因为它是异步的,UI 该渲染渲染,worker 算完再通知主线程更新。唯一多出来的开销是 postMessage 传输结果(约 5-10ms),但这点时间换 60fps 流畅度,值了。

不过有个坑:如果你用 computed 做过滤,Vue 3 的响应式系统会在依赖变化时同步重新计算。这意味着你没法把 computed 直接扔给 worker——computed 是同步的,而 worker 是异步的。解决方案是用 watch 触发异步过滤,把结果赋值给一个普通的 ref,模板里直接绑这个 ref。渲染列表时记得给 :key 绑定唯一 id,否则 Vue 的 diff 算法会在整个列表重排时额外消耗性能。

需要排序的场景也类似。用户点击表头切换排序字段,把排序逻辑扔到 worker 里跑。可以复用同一个 worker,在 payload 里多传一个 sortBysortOrder,worker 里先过滤再排序,一次返回。这种复合操作在万级数据上大概耗时 80-100ms,但全在后台,主线程只负责接收最终数组并更新 DOM。

最后提醒一句:别把所有计算都扔 worker。如果数据只有几百条,主线程过滤根本不到 5ms,引入 worker 反而多了线程创建和序列化的固定开销。阈值可以设在一千条左右,低于这个数,老老实实用 computed 就行。高于它,上 worker 才划算。

large dataset filtering Web Worker performance comparison

Worker 里塞库和类型

上一章我们只传原生数组,真遇到结构化数据就抓瞎。举个例子,你想在 worker 里用 lodash_.chunk 分块处理,或者用 papaparse 解析 CSV,这些库默认依赖 DOM 环境,塞进 worker 会直接报错。

第一次我照旧 import * as _ from 'lodash-es',结果 Vite 把整包打进主线程,bundle 飙到 600KB。第二次改在 worker 里写 importScripts('https://unpkg.com/lodash@4.17.21'),本地跑通却在部署后报 404。翻文档才发现 Vite 对 worker 有自己的一套解析规则,必须走 ?worker 查询参数才能标记成独立 chunk。

// useLodashWorker.ts
export function useLodashWorker() {
  const worker = new Worker(new URL('./filter.worker.ts', import.meta.url), { type: 'module' });
  return worker;
}

照着改完,控制台终于看到熟悉的 _.chunk 输出,首屏 JS 也回落到 280KB。

解析 CSV 我首选 papaparse,可它在浏览器环境默认挂载到 window。把脚本塞 worker 后立刻报错:Papa is not defined。官方文档没写 worker 场景,只能自己动手:先通过 importScripts 加载 papaparse CDN,再把解析回调包装成 promise,最后用 postMessage 把结果分段回主线程。实测 5MB 文件在 M1 MacBook 上 9 秒啃完,期间页面依旧丝滑滚动。

// filter.worker.ts
importScripts('https://cdn.jsdelivr.net/npm/papaparse@5.3.0/papaparse.min.js');
self.onmessage = async (e) => {
  const { csv, query } = e.data;
  const result = Papa.parse(csv, { header: true }).data;
  const filtered = result.filter(row => row.title.includes(query));
  self.postMessage({ filtered });
};

TypeScript 4.9 起 worker 支持 import.meta.url,于是我们在 src 目录下新建 types/worker.d.ts,手动补全 PostMessageData 接口,IDE 就能提示 payload 字段,编译期拦住拼写错误。配合 vue-tsc --noEmit,CI 流程里也能抓到类型漏洞。

// types/worker.d.ts
declare module '*.worker.ts' {
  const worker: {
    new (): Worker;
  };
  export default worker;
}

整套流程走通了之后,在组内做了一次分享。结果大家最在意的不是性能提升,而是“这么搞会不会被安全审计当成风险点”。怎么说呢,跟到处挂第三方脚本比起来,自己维护一个 worker 反而容易解释清楚。

错误处理与超时控制

Worker 跑起来容易,但崩溃起来也干脆。没有 DOM、没有 console 面板,错误一旦吞掉,用户那边就是白屏或者死循环。更麻烦的是——网络波动、数据格式突变、CPU 被后台任务占满,这些场景在开发机上根本复现不了。上生产第一周,客服群里就有人反馈“筛选完页面卡死了”。查了日志,Worker 里抛了个 TypeError,主线程连个提示都没收到。

先补上最基本的错误监听。Worker 本身会触发 error 事件,但默认行为是静默失败。我们在 useWorker Composable 里主动监听:

// composables/useWorker.ts
export function useWorker<T>(workerPath: string) {
  const workerRef = shallowRef<Worker | null>(null)
  const error = ref<Error | null>(null)
  const isRunning = ref(false)

  function createWorker() {
    const w = new Worker(workerPath, { type: 'module' })

    w.onerror = (e) => {
      console.error('[Worker Error]', e.message, e.filename, e.lineno)
      error.value = new Error(e.message)
      isRunning.value = false
    }

    w.onmessageerror = () => {
      error.value = new Error('Worker 消息反序列化失败')
    }

    workerRef.value = w
    return w
  }

  // ...
}

onerror 只能捕获 Worker 内部未处理的异常。如果 Worker 里用了 try/catch 自己吞掉错误,主线程照样不知情。所以还得在 Worker 内部主动把错误结构体 postMessage 回来:

// filter.worker.ts
self.onmessage = async (e) => {
  try {
    const { csv, query, taskId } = e.data
    const result = Papa.parse(csv, { header: true }).data
    const filtered = result.filter(row => row.title.includes(query))
    self.postMessage({ type: 'success', taskId, data: filtered })
  } catch (err) {
    self.postMessage({ type: 'error', taskId, message: (err as Error).message })
  }
}

主线程收到 type: 'error' 的消息后,把错误挂到对应任务的 promise 上。这样调用方就能用 .catch 处理,不用再猜 Worker 是死是活。

最头疼的是耗时不可控。用户选了 10 万行数据,过滤条件又复杂,Worker 跑了 30 秒还没返回。页面一直显示 loading,用户直接关标签页走人。这种情况必须有个“砍刀”: 超时后直接 terminate() Worker,然后降级到主线程同步执行。

设计上我用了 AbortController 包装超时逻辑:

// composables/useWorker.ts
export function useWorker<T>(workerPath: string, timeout = 10000) {
  const workerRef = shallowRef<Worker | null>(null)
  const abortController = new AbortController()

  function runWithTimeout(data: unknown): Promise<T> {
    return new Promise((resolve, reject) => {
      const worker = workerRef.value
      if (!worker) {
        reject(new Error('Worker 未初始化'))
        return
      }

      const timer = setTimeout(() => {
        abortController.abort()
        worker.terminate()
        workerRef.value = null
        reject(new Error(`任务超时 (${timeout}ms)`))
      }, timeout)

      worker.onmessage = (e) => {
        clearTimeout(timer)
        if (e.data.type === 'error') {
          reject(new Error(e.data.message))
        } else {
          resolve(e.data.data)
        }
      }

      worker.postMessage(data)
    })
  }

  // 如果超时,重新创建 Worker 实例
  function reset() {
    workerRef.value?.terminate()
    workerRef.value = new Worker(workerPath, { type: 'module' })
  }

  return { runWithTimeout, reset, error, isRunning }
}

注意 terminate() 后 Worker 实例就废了,必须重新 new Worker()。所以 reset 方法要暴露出去,或者在超时 reject 之后自动重建。我选了显式 reset,因为调用方可能需要知道“刚刚被砍了一个任务”,以便在 UI 上给出提示。

重试不是简单地把同一个请求再发一次。Worker 可能因为内存分配失败挂掉,重试前得先确认新 Worker 已经就绪。我用的策略是“最多重试 2 次,每次间隔指数退避”: 500ms、1000ms,第三次还失败就彻底降级。

// 调用示例
async function filterWithRetry(csv: string, query: string) {
  let lastError: Error | null = null

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const result = await worker.runWithTimeout({ csv, query })
      return result
    } catch (err) {
      lastError = err as Error
      if (attempt < 2) {
        worker.reset()
        await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt)))
      }
    }
  }

  // 三次失败,回退到主线程
  console.warn('Worker 三次失败,降级到主线程', lastError)
  return fallbackFilter(csv, query)
}

就是最朴素的 Array.filter,不依赖任何 Worker。虽然主线程会卡一下,但总比页面死掉强。用户只会觉得“这次查询慢了点儿”,不会看到报错弹窗。把 的执行时长也打一条 performance mark,后续可以调整超时阈值或者换更小的数据分片。

这套错误拦截加超时兜底再加自动重试的组合扔上线之后,客服群里再也没人喊“筛选又卡死了”。说实话,Worker 真不是什么银弹——它只是把那些不可控的东西从主线程挪到后台罢了。但用户之所以觉得稳了,是因为这些不可控终于被看见了,而且被接住了。

把 Web Worker 嵌进 Vue 3 项目,远不止“新开一个线程”就完事。真正让人舒服的封装,是让后台的脏活累活调用起来跟普通函数没啥两样,同时还能被全局状态共享、随时撤销、甚至追踪进度——而不是扔个 worker 文件就撒手不管。