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

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

基于 RPC(远程过程调用) 设计,通过 Proxy APIMessageChannel 隐藏了底层 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 ------ 无缝跨线程通信

proxyTransferHandlerthrowTransferHandler 的代码相似,且比较简单。下面只展示关键的 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 哦。

相关推荐
ganshenml19 小时前
sed 流编辑器在前端部署中的作用
前端·编辑器
0***K89220 小时前
Vue数据挖掘开发
前端·javascript·vue.js
蓝胖子的多啦A梦20 小时前
ElementUI表格错位修复技巧
前端·css·vue.js·el-table表格错位
_OP_CHEN20 小时前
前端开发实战深度解析:(一)认识前端和 HTML 与开发环境的搭建
前端·vscode·html·web开发·前端开发
xiAo_Ju20 小时前
iOS一个Fancy UI的Tricky实现
前端·ios
H***997620 小时前
Vue深度学习实战
前端·javascript·vue.js
toooooop821 小时前
Vuex 中 state、mutations 和 actions 的原理和写法
前端·javascript·uni-app
y***866921 小时前
前端CSS-in-JS方案
前端·javascript·css
暖木生晖21 小时前
APIs之WEB API的基本认知是什么?
前端·dom·dom树·web apis
华仔啊21 小时前
你真的懂递归吗?没那么复杂,但也没那么简单
前端·javascript