Comlink 的核心目标是让不同线程之间的通信变得像调用本地函数一样简单。
Comlink 的介绍
基于 RPC(远程过程调用) 设计,通过 Proxy API 和 MessageChannel 隐藏了底层 postMessage 的复杂性。开发者可直接调用 Worker 中的函数,如同操作本地对象,支持异步 async/await 语法,代码更简洁。
这种设计使得跨线程通信透明化,尤其适合需要频繁交互的复杂场景。
在 React 中使用 Comlink
定义 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);
});
}
-
通过生成唯一的 UUID,将每个请求与其对应的 Promise 的 resolve 关联起来,使响应消息能够精确匹配到对应的请求。
-
请求通过
postMessage发送后,函数返回一个待改变状态的 Promise。 -
等待某一指定时刻(如: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 哦。