深入 Comlink 源码细节——如何实现 Worker 的优雅通信

Comlink 的核心目标是让不同线程之间的通信变得像调用本地函数一样简单。

基于 RPC(远程过程调用) 设计,通过 Proxy API 和 MessageChannel 隐藏了底层 postMessage 的复杂性。开发者可直接调用 Worker 中的函数,如同操作本地对象,支持异步 async/await 语法,代码更简洁。

这种设计使得跨线程通信透明化,尤其适合需要频繁交互的复杂场景。

定义 Web Worker 逻辑并暴露类型接口:

worker.ts

ts 复制代码
import { expose } from 'comlink';

type WorkerAPI = {
  chestnutNumber: number;
  addChestnut: () => Promise<number>;
};

const obj: WorkerAPI = {
  chestnutNumber: 6,
  countChestnuts: () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        obj.chestnutNumber += 1;
        resolve(obj.chestnutNumber);
      }, 1000);
    });
  },
};

expose(obj);
export type { WorkerAPI };

在组件中调用 Worker :

ts 复制代码
import React, { useEffect } from 'react';
import { releaseProxy, wrap } from 'comlink';
import type { WorkerAPI } from './worker';

const APP: React.FC = () => {
  useEffect(() => {
    (async () => {
      const worker = new Worker(new URL('./worker.ts', import.meta.url), {
        type: 'module',
      });
      const workerApi = wrap<WorkerAPI>(worker);
      console.log(await workerApi.chestnutNumber); //6
      console.log(await workerApi.addChestnut()); //7
      console.log(await workerApi.chestnutNumber); //7
      // 释放资源
      worker[releaseProxy]();
    })();
  }, []);
  
  return <div>React组件内容</div>;
};
export default APP;

可以看到,只用到了 2 个 API,就实现了优雅调用 Worker,接下来让我们深入 Comlink 的源码。

核心 API 源码解析

巧妙的设计

要深入理解 Comlink 源码的精髓,必须要先掌握这段实现了------基于事件回调的 onmessage 跨线程通信的请求 ➡ Promise 链式调用------的巧妙代码。

ts 复制代码
function requestResponseMessage(
  ep: Endpoint,
  pendingListeners: PendingListenersMap,
  msg: Message,
  transfers?: Transferable[]
): Promise<WireValue> {
  return new Promise((resolve) => {
    // 生成唯一的 UUID
    const id = generateUUID();
    // 将 resolve 函数存储到 pendingListeners
    pendingListeners.set(id, resolve);
    if (ep.start) {
    // 启动端点(如 MessagePort)
      ep.start();
    }
    // 发送带 ID 的消息
    ep.postMessage({ id, ...msg }, transfers);
});
}
  1. 通过生成唯一的 UUID,将每个请求与其对应的 Promise 的 resolve 关联起来,使响应消息能够精确匹配到对应的请求。

  2. 请求通过 postMessage 发送后,函数返回一个待改变状态的 Promise。

  3. 等待某一指定时刻(如:Worker 消息返回后),触发暂存的 resolve 函数,更改 Promise 的状态,完成整个异步流程。

wrap():主线程与 Worker 通信的入口代理生成器

将 Web Worker、Service Worker 或 MessagePort 封装为可操作的代理对象,主线程可直接调用 Worker 中暴露的方法。

ts 复制代码
export function wrap<T>(ep: Endpoint, target?: any): Remote<T> {
  // 存储待处理的异步请求(请求ID → Promise 的 resolve函数)
  const pendingListeners : PendingListenersMap = new Map();
  // 监听来自 Worker 的消息
  ep.addEventListener("message", function handleMessage(ev: Event) {
    const { data } = ev as MessageEvent;
    // 过滤无效消息
    if (!data || !data.id) {  return; }
    // 获取该消息对应的 Promise 的 resolve 函数
    const resolver = pendingListeners.get(data.id);
    if (!resolver) { return; }
    try {
      // 触发对应 Promise 的 resolve,传递数据
      resolver(data);
    } finally {
      // 确保清理对应记录
      pendingListeners.delete(data.id);
    }
  });
  // 创建代理对象
  return createProxy<T>(ep, pendingListeners, [], target) as any;
}
createProxy 是实现远程调用的核心代码
ts 复制代码
function createProxy<T>(
  ep: Endpoint, 
  pendingListeners: PendingListenersMap, 
  path: (string | number | symbol)[] = [],
  target: object = function () {} 
): Remote<T> {
  // 标记代理是否已被释放
  let isProxyReleased = false;
  const proxy = new Proxy(target, {
    get(_target, prop) {/* 见下 */},
    set(_target, prop, rawValue) {/* 见下 */},
    apply(_target, _thisArg, rawArgumentList) {/* 见下 */},
    construct(_target, rawArgumentList) {/* 见下 */},
  });
  registerProxy(proxy, ep);
  return proxy as any;
}

下面把核心代码中的拦截方法分开一一讲解:

get() 拦截方法:
ts 复制代码
// 提供显式资源释放入口
const releaseProxy = Symbol("Comlink.releaseProxy");

function createProxy<T>(
  ep: Endpoint, 
  pendingListeners: PendingListenersMap, 
  path: (string | number | symbol)[] = [], // 记录属性访问路径
  target: object = function () {} 
): Remote<T> {
    /* ...省略代码 */
     get(_target, prop) {
      // 检查代理状态:若已被释放则抛出错误
      throwIfProxyReleased(isProxyReleased);
      // 当执行 proxy[Comlink.releaseProxy]()时,释放资源
      if (prop === releaseProxy) {
        return () => {
          unregisterProxy(proxy);   // 从全局注册表中移除代理
          releaseEndpoint(ep);      // 关闭通信端口
          pendingListeners.clear(); // 清空待处理请求队列
          isProxyReleased = true;   // 标记代理为已释放状态
        };
      }
      // 处理 Promise 的 then 方法
      if (prop === "then") {
        // 没有属性访问路径,返回代理自身
        if (path.length === 0) {
          return { then: () => proxy };
        }
        const r = requestResponseMessage(ep, pendingListeners, {
          type: MessageType.GET,
          path: path.map((p) => p.toString()),
        }).then(fromWireValue);
        // 返回绑定 then 方法的 Promise,实现兼容 await 
        return r.then.bind(r);
      }
      // 递归创建嵌套属性代理(如 proxy.a.b)
      return createProxy(ep, pendingListeners, [...path, prop]);
    }
     /* ...省略代码 */
}
  • 可以注意到,里面有段代码是 return r.then.bind(r); 为什么不是直接 return r.then;,更不是return r;呢?

首先,若直接返回 Promise 对象 r ,会破坏代理的透明性。其次若返回 r.then ,则当其他代码调用该方法时,this 可能指向错误(如全局对象)。

所以,通过 bind 显示绑定 this,确保后续如何调用 then 方法,this 始终指向原始 Promise 对象。

  • 上面代码中,path 属性的作用是什么?最后为什么又进行了一次 return createProxy(ep, pendingListeners, [...path, prop]);呢?

path 是一个数组,主要作用是记录 属性的访问路径 ,也就是通过 代理 访问 远程对象的属性路径 ,实现了 路径动态扩展机制。

然后是通过 createProxy 递归的形式,进行 路径累积,此时仅仅只是记录路径。

直到访问 then 属性时(即 await 触发),才发进行请求。

set(),apply(),construct() 拦截方法:

看懂上面的代码后,剩下的拦截方法就很简单了,下面放在一起看。

为了方便理解,先简单说一下里面涉及到函数的作用。

  • toWireValue 函数:序列化为跨线程可传输的格式。

  • fromWireValue 函数:反序列化接收到的传输数据。

  • processArguments 函数:传参序列化。

ts 复制代码
function createProxy<T>(
  ep: Endpoint, 
  pendingListeners: PendingListenersMap, 
  path: (string | number | symbol)[] = [],
  target: object = function () {} 
): Remote<T> {
    /* ...省略代码 */
    set(_target, prop, rawValue) {
      throwIfProxyReleased(isProxyReleased);
      // 序列化值
      const [value, transferables] = toWireValue(rawValue);
      return requestResponseMessage(
        ep,
        pendingListeners,
        { type: MessageType.SET, path: [...path, prop].map((p) => p.toString()), value },
        transferables
      ).then(fromWireValue) as any;
    },
    apply(_target, _thisArg, rawArgumentList) {
      throwIfProxyReleased(isProxyReleased);
      // 获取调用路径的最后一个属性
      const last = path[path.length - 1];
      if ((last as any) === createEndpoint) {
        return requestResponseMessage(ep, pendingListeners, {
          type: MessageType.ENDPOINT,
        }).then(fromWireValue);
      }
      if (last === "bind") {
        // 返回去掉 bind 路径的新代理
        return createProxy(ep, pendingListeners, path.slice(0, -1));
      }
      // 传参序列化
      const [argumentList, transferables] = processArguments(rawArgumentList);
      return requestResponseMessage(
        ep,
        pendingListeners,
        { type: MessageType.APPLY, path: path.map((p) => p.toString()), argumentList },
        transferables
      ).then(fromWireValue);
    },
    construct(_target, rawArgumentList) {
      throwIfProxyReleased(isProxyReleased);
      // 传参序列化
      const [argumentList, transferables] = processArguments(rawArgumentList);
      return requestResponseMessage(
        ep,
        pendingListeners,
        { type: MessageType.CONSTRUCT, path: path.map((p) => p.toString()), argumentList },
        transferables
      ).then(fromWireValue);
    }        
     /* ...省略代码 */
}
toWireValue ------ 无缝跨线程通信

proxyTransferHandler 和 throwTransferHandler 的代码相似,且比较简单。下面只展示关键的 proxyTransferHandler。

ts 复制代码
const transferHandlers = new Map<
  string,
  TransferHandler<unknown, unknown>
>([["proxy", proxyTransferHandler],["throw", throwTransferHandler]]);

function toWireValue(value: any): [WireValue, Transferable[]] {
  for (const [name, handler] of transferHandlers) {
    // 检查当前值是否可由该处理器处理
    if (handler.canHandle(value)) {
      // 执行序列化:返回序列化值,可转移对象列表
      const [serializedValue, transferables] = handler.serialize(value);
      return [{ type: WireValueType.HANDLER, name, value: serializedValue },transferables];
    }
  }
  return [{type: WireValueType.RAW, value}, transferCache.get(value) || []];
}

const proxyTransferHandler: TransferHandler<object, MessagePort> = {
  // 检测是否有被 proxyMarker 标记过
  canHandle: (val): val is ProxyMarked => isObject(val) && (val as ProxyMarked)[proxyMarker],
  serialize(obj) {
    // 创建消息通道
    const { port1, port2 } = new MessageChannel();
    // 将原始对象绑定到 port1
    expose(obj, port1);
    return [port2, [port2]];
  },
  deserialize(port) {
    // 启动消息监听
    port.start();
    // 创建代理对象
    return wrap(port);
  },
};
  • proxyTransferHandler 的重点在serialize,明明是序列化的意思,为什么通过 MessageChannel 创建通道呢?

本质上是为了解决 跨线程通信的核心限制 ------ postMessage 只能传输可序列化数据(基本数据类型或可结构化克隆的对象),函数、类实例等非序列化对象无法直接传输。

这里 expose 的作用是:将原始对象绑定到通道端口,使另一线程可通过该端口调用对象的原始方法。

ts 复制代码
export function expose(
  obj: any,
  ep: Endpoint = globalThis as any,
  allowedOrigins: (string | RegExp)[] = ["*"]
) {
  ep.addEventListener("message", function callback(ev: MessageEvent) {
    // 消息有效性校验
    if (!ev || !ev.data) { return; }
    // 消息安全性校验
    if (!isAllowedOrigin(allowedOrigins, ev.origin)) {
      console.warn(`Invalid origin '${ev.origin}' for comlink proxy`);
      return;
    }
    // 解析消息内容
    const { id, type, path } = {path: [] as string[], ...(ev.data as Message)};
    // 反序列化参数
    const argumentList = (ev.data.argumentList || []).map(fromWireValue);
    
    let returnValue;
    try {
      // 获取目标属性的父级对象
      const parent = path.slice(0, -1).reduce((obj, prop) => obj[prop], obj);
      // 获取路径指向的原始值
      const rawValue = path.reduce((obj, prop) => obj[prop], obj);
      // 根据操作类型执行相应逻辑
      switch (type) {
        case MessageType.GET:
        {returnValue = rawValue}
          break;
        case MessageType.SET:
          {parent[path.slice(-1)[0]] = fromWireValue(ev.data.value); returnValue = true}
          break;
        case MessageType.APPLY:
          {returnValue = rawValue.apply(parent, argumentList)}
          break;
        case MessageType.CONSTRUCT:
          {const value = new rawValue(...argumentList); returnValue = proxy(value)}
          break;
        case MessageType.ENDPOINT:
          {const { port1, port2 } = new MessageChannel();expose(obj, port2); returnValue = transfer(port1, [port1])}
          break;
        case MessageType.RELEASE:
          {returnValue = undefined}
          break;
        default:
          return;
      }
    } catch (value) {
      returnValue = { value, [throwMarker]: 0 };
    }
    Promise.resolve(returnValue)
      .catch((value) => {return { value, [throwMarker]: 0 }})
      .then((returnValue) => {
        // 序列化返回值
        const [wireValue, transferables] = toWireValue(returnValue);
        // 发送响应
        ep.postMessage({ ...wireValue, id }, transferables);
        // 若为释放操作,清理资源
        if (type === MessageType.RELEASE) {
          ep.removeEventListener("message", callback as any);
          closeEndPoint(ep);
          if (finalizer in obj && typeof obj[finalizer] === "function") {
            obj[finalizer]();
          }
        }
      })
      .catch((error) => {
        // 发送序列化错误
        const [wireValue, transferables] = toWireValue({
          value: new TypeError("Unserializable return value"),
          [throwMarker]: 0,
        });
        ep.postMessage({ ...wireValue, id }, transferables);
      });
  } as any);
  if (ep.start) {ep.start()}
}

可能有点绕,看下这副流程图:

总结下,toWireValue 通过 serialize 函数,通过创建 MessageChannel 并调用 expose 将对象绑定到通道端口 。通过建立跨线程通信代理 ,使另一线程能间接调用原始对象的方法,突破 JavaScript 的线程隔离限制。

与 useWorker 的对比

之前写过 useWorker 的源码分析,那么 Comlink 与 useWorker 之间有什么区别呢?

Comlink useWorker
与框架无关,可灵活与 Vue、React 等结合 为 React 量身定制,深度集成 Hooks API,简化状态管理与 Worker 交互
无主线程中转。Web Worker ↔ Service Worker/iframe 内的 Worker (通过 MessageChannel 绕过主线程) 需通过主线程作为中转。 Worker → 主线程 → Service Worker/iframe 内的 Worker

若项目基于 React 且仅需简单任务卸载,追求快速集成,可选择 useWorke。

若需复杂跨线程通信 、多环境支持 或深度性能优化,可选 Comlink。

Worker 的一些 Q&A

为什么直接 new Worker('./worker.ts') 时报错呢?

首先,不管后缀是 ts 还是 js,直接这么写都会导致这个错误 Uncaught SyntaxError: Unexpected token '<'。并且点击对应的文件,是 HTML 页面。

因为构建工具(如 Webpack、Vite)需在编译时明确识别 Worker 文件路径。

但 new Worker('./worker.ts') 中的路径是以 静态字符串 传递,属于运行时动态加载。因此无法被构建工具正确识别和追踪,则无法在编译阶段处理依赖,导致构建产物中 Worker 文件未被正确编译打包。

所以需要这么写 :

new URL 语法(将 Worker 识别为动态资源引用,自动处理路径转换和文件加载)

+

import.meta.url(获取当前模块的绝对路径,确保加载正确的文件)

=

new Worker(new URL('./worker.ts', import.meta.url)) 动态生成绝对路径,让构建工具能正确追踪依赖关系。

Worker 构造函数是将参数 URL 为将执行的脚本的 URL,并且使用 Blob URL 作为参数亦可。(关于 Blob 动态加载,绕过路径限制,可以看看这篇文章)

能调用多少 Worker 数量呢?

可以通过 navigator.hardwareConcurrency API 获取当前设备的逻辑 CPU 核心数,这是浏览器并行处理任务的物理上限。

性能拐点:当 Worker 数量超过 CPU 核心数时,计算耗时不再减少,线程上下文切换开销增加,导致性能下降。

如何查看在使用的 Worker 数量呢?

打开 Chrome 开发者工具,就可以看到除了主线程外,还有几个 Web Worker 线程了。

使用完毕,为了节省系统资源,记得关闭 Worker 哦。

相关推荐
Qlly3 分钟前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝7 分钟前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦8 分钟前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡29 分钟前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI35 分钟前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95271 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人1 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载1 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队1 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端
派小心.1 小时前
A/B实验分层随机种子的稳定性验证
大数据·服务器·前端·数据分析