裸用 SharedWorker 意味着每个页面都要重复编写端口注册、消息监听、ID 维护等逻辑。将其封装为 Vue Composition API(useWorker()),可以统一输入输出、隐藏 Worker 细节,让业务页面只关心 emit / on / broadcast 这套事件语义。本文详解封装目标、消息协议设计、"广播 + 过滤"分发模型与完整实现。
useWorker() 的对外 API(emit / on / broadcast / message / connections)如果每个页面都自己重复编写注册端口、发送消息、监听消息、维护页面 ID 的逻辑,代码分散且难以维护。封装的核心目标是:统一收口到一个 composable,让所有页面通过同一种方式使用消息机制。
const { emit, on, broadcast, message, connections, currentId } = useWorker();| API | 类型 | 说明 |
|---|---|---|
emit(eventName, data) | 方法 | 发送指定事件的消息 |
on(eventName, handler) | 方法 | 订阅事件,返回取消函数 |
broadcast(data) | 方法 | 广播给所有其他页面 |
message | Ref<unknown> | 最近一条收到的消息(响应式) |
connections | Ref<string[]> | 当前已连接的页面 ID 列表 |
currentId | Ref<string> | 当前页面的连接 ID |
type UseWorkerResult = {
emit: (eventName: string, data?: unknown) => void;
on: (eventName: string, handler: (data: unknown) => void) => () => void;
broadcast: (data?: unknown) => void;
message: Ref<unknown>;
connections: Ref<string[]>;
currentId: Ref<string>;
};设计要点:
message 用 ref 暴露,配合 Vue 响应式系统直接用于模板或 watchon 返回取消订阅函数,避免页面卸载后残留监听器这套机制最重要的设计决策:Worker 底层仍然是广播转发,由各页面根据 eventName 判断是否处理。
| 类型 | 实现方式 | 特点 |
|---|---|---|
| 逻辑点对点 | eventName 等价于页面路由,只有目标页面监听 | 简单、好扩展 |
| 链路点对点 | Worker 按 targetId 只发给目标端口 | 精确、无冗余传输 |
当前约定 eventName 等价于页面路由且路由唯一时,效果等同于点对点。如果多个页面监听同一事件名,则自然变为点对多——这是设计使然,不是 bug。
后续如需真正的链路级定向,新增 targetId 字段即可,不影响现有 API。
type WorkerEvent =
| {
type: "emit";
eventName: string;
data?: unknown;
fromId: string;
}
| {
type: "broadcast";
data?: unknown;
fromId: string;
}
| {
type: "connected";
currentId: string;
ids: string[];
};// public/shared-worker.js
const connections = {};
let nextId = 0;
function generateUniqueId() {
return `id-${nextId++}`;
}从数组升级为 id -> port 映射的原因:
function broadcast(message, excludeId) {
Object.entries(connections).forEach(([id, port]) => {
if (id !== excludeId) {
port.postMessage(message);
}
});
}excludeId 的作用:不把消息回发给发送者自己。如需"发送者也收到",去掉排除逻辑即可。
// public/shared-worker.js
const connections = {};
let nextId = 0;
function generateUniqueId() {
return `id-${nextId++}`;
}
function broadcast(message, excludeId) {
Object.entries(connections).forEach(([id, port]) => {
if (id !== excludeId) {
port.postMessage(message);
}
});
}
self.onconnect = (connectEvent) => {
const port = connectEvent.ports[0];
const currentId = generateUniqueId();
connections[currentId] = port;
port.onmessage = (event) => {
const payload = event.data || {};
if (payload.type === "emit") {
broadcast(
{
type: "emit",
eventName: payload.eventName,
data: payload.data,
fromId: currentId,
},
currentId
);
return;
}
if (payload.type === "broadcast") {
broadcast(
{
type: "broadcast",
data: payload.data,
fromId: currentId,
},
currentId
);
}
};
// 连接建立后主动通知页面
port.postMessage({
type: "connected",
currentId,
ids: Object.keys(connections),
});
port.start();
};connected 消息的意义:页面拿到自己的 ID 和当前连接列表后,才能维护本地 connections 状态,也才能支持后续的路由映射。
// composables/useWorker.ts
import { onUnmounted, ref } from "vue";
const worker = new SharedWorker("/shared-worker.js");
const listeners = new Map<string, Set<(data: unknown) => void>>();
const message = ref<unknown>(null);
const connections = ref<string[]>([]);
const currentId = ref("");
worker.port.addEventListener("message", (event) => {
const data = event.data || {};
message.value = data;
if (data.type === "connected") {
currentId.value = data.currentId;
connections.value = data.ids || [];
return;
}
if (data.type === "emit" && data.eventName) {
listeners.get(data.eventName)?.forEach((handler) => {
handler(data.data);
});
return;
}
if (data.type === "broadcast") {
listeners.get("message")?.forEach((handler) => {
handler(data.data);
});
}
});
worker.port.start();
export function useWorker() {
function emit(eventName: string, data?: unknown) {
worker.port.postMessage({ type: "emit", eventName, data });
}
function broadcast(data?: unknown) {
worker.port.postMessage({ type: "broadcast", data });
}
function on(eventName: string, handler: (data: unknown) => void) {
const bucket = listeners.get(eventName) ?? new Set();
bucket.add(handler);
listeners.set(eventName, bucket);
return () => {
bucket.delete(handler);
};
}
onUnmounted(() => {
listeners.clear();
});
return { emit, on, broadcast, message, connections, currentId };
}| 设计点 | 说明 |
|---|---|
| worker 放模块级 | 整个页面共享一个连接(单例策略) |
| listeners 用 Map + Set | 按事件名分桶,Set 天然去重 |
on() 返回取消函数 | 支持按订阅粒度清理 |
message ref | 暴露最新消息,配合 watch 调试 |
| broadcast 消息走 "message" 通道 | 订阅 on("message") 即可接收所有广播 |
const { emit, on, broadcast, message, connections } = useWorker();
// 订阅特定事件
const off = on("login-success", (data) => {
console.log("登录成功通知:", data);
});
// 发送事件
emit("login-success", { userId: "1001" });
// 广播
broadcast({ text: "全局通知" });
// 响应式消费最新消息
watch(message, (msg) => console.log("latest:", msg));useWorker() 兼具"Worker 封装"和"本地事件总线"双重职责| 问题 | 原因 | 解决方案 |
|---|---|---|
| 想做点对点为什么 Worker 还在广播 | 当前设计是"广播 + eventName 过滤"模型 | 接受应用层定向;真正定向需新增 targetId |
| 已有 on() 为什么还要 message ref | on 适合事件订阅,message 适合响应式展示 | 两者并存,服务不同场景 |
| 为什么给每个连接分配唯一 ID | 需要同步连接列表、排除自己、支持定向 | Worker 端维护 id -> port 映射 |
| 用路由名当 eventName 安全吗 | 只要保证事件名具有唯一业务语义即可 | 避免多个页面混听同名事件 |
listeners.clear() 会影响其他组件吗 | 如果多组件共用,整体清空过于粗暴 | 生产环境按订阅粒度清理(调用 off 函数) |
data 或 payload,不出现 message/data/payload 三种混写