| 项目 | 内容 |
|---|---|
| 课程名称 | 未提供 |
| 当前章节 | 基于 SharedWorker 封装可复用的消息通信 Composition API |
| 知识领域 | Vue Composition API / SharedWorker / 多标签页通信 / 工程化封装 |
| 学习目标 | 掌握如何把 SharedWorker 通信逻辑抽象成可复用的 useWorker(),并理解 emit、on、broadcast、connections 这套消息机制设计 |
| 整理时间 | 2026-03-29 |
| 资料校对 | 基于上一节 SharedWorker 模型,对消息协议、连接池结构和 Vue composable 封装思路做了工程化整理 |
一、为什么要继续封装一层 必须掌握
1.1 从“能通信”到“好复用”
概念说明
上一节里我们已经完成了 SharedWorker 的基本消息转发,也就是“页面 A 发消息,页面 B 能收到”。但如果每个页面都自己重复写:
- 注册端口
- 发送消息
- 监听消息
- 维护页面 ID
- 管理连接池
那么代码会很分散,也不利于后续在更多页面中复用。
课程里老师提到的核心目标就是:把这套逻辑统一收口到一个 Vue Composition API 里,例如 useWorker(),让每个页面都通过同一种方式使用消息机制。
语法/用法
理想的对外 API 形态通常类似:
const {
emit,
on,
broadcast,
message,
connections,
} = useWorker();注意事项
- 封装的目的不是“把所有逻辑藏起来”,而是统一输入输出。
- API 名称要尽量贴近事件系统的语义,例如
on、emit、broadcast,这样上层业务更容易理解。
二、这套封装到底想解决什么 必须掌握
2.1 三类通信诉求
概念说明
从课程原文来看,封装后的通信系统至少要覆盖三种能力:
emit:针对某类事件发送消息on:监听某类事件broadcast:广播给所有页面
此外,还会暴露两类状态:
message:当前最近一条收到的消息connections:当前有哪些连接页面或页面 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[]>;
};注意事项
message用ref暴露是为了配合 Vue 的响应式系统。on依然有存在价值,因为很多场景更适合事件订阅,而不是单纯依赖一个“最新消息”变量。
三、这不是“真正的点对点”,而是“广播 + 过滤” 必须掌握
3.1 课程里的关键设计思想
概念说明
这段课里最重要的一句话其实是:
“当页面发送 emit 消息时,SharedWorker 底层仍然是把消息广播出去,再由各个页面根据 eventName 判断自己要不要处理。”
也就是说,这套机制并不是网络层面的严格点对点,而是应用层的事件分发机制。
语法/用法
可以这样理解:
- 页面 A 调用
emit("route-a", data) - Worker 收到后,把消息转发给其他连接页面
- 页面 B、C、D 都会收到
- 只有注册了
on("route-a")的页面才会真正处理
注意事项
- 这种设计简单、好扩展,也符合课程当前阶段的目标。
- 如果后续你真的需要“只发给某一个确定页面”,那就不能只靠
eventName,而要引入明确的目标 ID 或路由表。
3.2 为什么这样设计也能满足很多业务
概念说明
如果课程里约定:
eventName等价于页面路由- 每个页面路由在当前系统中是唯一的
那么实际效果就很像“点对点”:
- 你发的是某个
eventName - 只有那个页面会监听这个
eventName - 其他页面虽然收到了底层消息,但会直接忽略
注意事项
- 这是一种“逻辑点对点”,不是“链路点对点”。
- 只要页面路由不是唯一,或者多个页面都监听同一个事件名,就会变成“点对多”。
四、SharedWorker 内部的数据结构要升级 必须掌握
4.1 为什么不能再只用数组
概念说明
课程这节开始把上一节里简单的 ports 数组升级成对象或映射表,这是因为后续要支持:
- 连接唯一 ID
- 连接列表同步
- 按连接做排除
- 后续可能的定向发送
语法/用法
课程里的思路大致是:
const connections: Record<string, MessagePort> = {};
let nextId = 0;
function generateUniqueId() {
return `id-${nextId++}`;
}然后每次有新连接时:
const id = generateUniqueId();
connections[id] = port;注意事项
- 用对象或
Map比数组更适合后续按 ID 处理连接。 - ID 设计不一定非得是递增数字,也可以使用页面路由、随机串或业务侧传入的标识。
4.2 connected 消息的意义
概念说明
课程里提到:当一个新的 port 初始化完成后,Worker 会主动发出一条 type: "connected" 的消息,告诉页面:
- 你自己的 ID 是什么
- 当前有哪些连接存在
这一步很关键,因为上层 useWorker() 只有拿到这些元信息,才能维护本地的 connections 状态。
代码示例
port.postMessage({
type: "connected",
currentId: id,
ids: Object.keys(connections),
});注意事项
- 如果连接池有变化,比如新页面加入或旧页面离开,也可以继续把最新连接列表同步给所有页面。
- 课程原文里还没讲到断开清理,但项目里这一步迟早要补。
五、Worker 端消息协议设计 必须掌握
5.1 至少要有哪几种消息类型
概念说明
按这段课程内容,Worker 端至少要区分三种消息:
emitbroadcastconnected
语法/用法
可以整理成这样的协议:
type WorkerEvent =
| {
type: "emit";
eventName: string;
data?: unknown;
fromId: string;
}
| {
type: "broadcast";
data?: unknown;
fromId: string;
}
| {
type: "connected";
currentId: string;
ids: string[];
};注意事项
- 课程原文中的
date、sender date,这里应统一为data。 - 消息协议一旦对外暴露,字段命名要尽量稳定,不要同一层里同时出现
message、data、payload三种混用命名。
5.2 Worker 里的广播函数
概念说明
课程里单独抽了一个 broadcast 方法,这个做法是对的,因为无论是 emit 还是 broadcast,底层都要复用“遍历连接并转发”的逻辑。
代码示例
function broadcast(
message: Record<string, unknown>,
excludeId?: string
) {
Object.entries(connections).forEach(([id, port]) => {
if (id !== excludeId) {
port.postMessage(message);
}
});
}注意事项
excludeId的作用是“不要把消息回发给发送者自己”。- 如果你想支持“发送给自己也同步收到”,只要去掉这个排除逻辑即可。
六、Worker 侧完整处理流程 重要
6.1 处理 emit
概念说明
当页面发来 type: "emit" 的消息时,Worker 会把带有 eventName 和 data 的消息广播给其他连接。
代码示例
if (event.data.type === "emit") {
broadcast(
{
type: "emit",
eventName: event.data.eventName,
data: event.data.data,
fromId: currentId,
},
currentId
);
}注意事项
- 上层页面是否真正消费这条消息,要由
eventName决定。
6.2 处理 broadcast
概念说明
当页面发来 type: "broadcast" 时,不需要事件名过滤,意味着“所有页面都可以收到并自行处理”。
代码示例
if (event.data.type === "broadcast") {
broadcast(
{
type: "broadcast",
data: event.data.data,
fromId: currentId,
},
currentId
);
}注意事项
- 如果你想让自己也接收到广播,可以根据业务决定是否排除
currentId。
七、Vue Composition API 的封装思路 必须掌握
7.1 useWorker() 应该暴露什么
概念说明
课程中老师已经给出了目标接口形态。把它整理成一个 composable 后,对外建议暴露:
emitonbroadcastmessageconnections- 可选的
currentId
代码示例
const {
emit,
on,
broadcast,
message,
connections,
currentId,
} = useWorker();注意事项
currentId虽然课程里没有明确强调,但它在调试和定向发送场景里通常很有用。
7.2 message 为什么适合用 ref
概念说明
课程里提到:虽然 on 可以通过回调拿到消息,但如果用的是响应式开发,完全可以用本地 ref 保存最近一次消息。
代码示例
const message = ref<unknown>(null);
const connections = ref<string[]>([]);注意事项
ref适合做“最新状态”的暴露。- 如果一个页面需要监听多个事件源,单一
message可能不够用,这时还是要保留on的事件订阅能力。
7.3 on 的本质是本地事件总线
概念说明
SharedWorker 负责跨页面转发,而 useWorker().on() 更像是当前页面内部的一层事件订阅封装。
代码示例
const listeners = new Map<string, Set<(data: unknown) => void>>();
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);
};
}注意事项
on()最好返回一个取消订阅函数,避免页面卸载后残留监听器。- 这层事件分发越清晰,业务页面越不需要直接感知 Worker 的细节。
八、推荐的 useWorker() 实现示例 重要
8.1 Worker 文件
代码示例
// 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();
};8.2 Vue composable 文件
代码示例
// 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.clear()这种写法在多组件同时使用时可能过于粗暴,生产环境更推荐按订阅粒度清理。
九、这套设计的优势与局限 必须掌握
9.1 优势
概念说明
这套设计的优势在于:
- 页面调用方式统一
- Worker 细节被隐藏
- 事件名可以自然映射业务语义
- 后续扩展成目标 ID、房间机制、路由机制都比较顺滑
9.2 局限
概念说明
它也有很明显的局限:
- 目前底层仍然是广播转发,不是真正链路级点对点
- 依赖页面自己正确注册监听事件
- 需要自己维护消息协议和监听器生命周期
- 如果事件名设计混乱,通信会很难维护
注意事项
- 课程里把
eventName等价为路由是一种可行方案,但要保证路由语义稳定。 - 如果后续真的要做到“只发给某一个连接”,建议新增
targetId字段而不是继续依赖eventName。
代码实战案例
需求描述
封装一个 useWorker(),满足以下能力:
- 页面 A 调用
emit("login", data)发送指定事件 - 页面 B 通过
on("login", handler)接收消息 - 任意页面可以用
broadcast(data)广播给所有其他页面 - 页面侧可以通过
connections获取当前已连接的页面 ID 列表
代码逐行解析
- Worker 侧先用
connections维护id -> port映射。 - 每次新页面连接时,生成唯一 ID 并发送
connected消息回页面。 - 页面侧
useWorker()收到connected后,更新本地currentId与connections。 - 当页面调用
emit(eventName, data)时,本质上是向 Worker 发送一条type: "emit"的消息。 - Worker 收到后调用
broadcast(),把该消息转发给除发送者外的其他页面。 - 各页面收到后,只会触发与
eventName对应的本地监听器。 - 当页面调用
broadcast(data)时,Worker 会把该消息转发给所有其他页面。
常见问题与解决方案
| 问题 | 原因分析 | 解决方案 |
|---|---|---|
| 明明想做点对点,为什么 Worker 里还是在广播? | 课程当前设计走的是“广播转发 + 页面按 eventName 过滤”模型 | 接受这是应用层定向机制;如果要真正定向,新增 targetId |
为什么所有页面都要有 emit、on、broadcast 这些方法? | 因为每个页面都可能既是发送方,也是接收方 | 用 useWorker() 统一封装,对外暴露一致 API |
为什么还需要 message 这个 ref,不是已经有 on() 了吗? | on() 适合事件订阅,message 适合响应式状态展示 | 两者并存,各自解决不同场景 |
| 为什么要给每个连接分配唯一 ID? | 后续需要同步连接列表、排除自己、支持定向转发 | 在 Worker 端维护 id -> port 映射,而不是只用数组 |
为什么用路由名当 eventName 能实现“像点对点一样”的效果? | 因为当前约定只有目标页面会监听这个事件名 | 保证事件名具有唯一业务语义,避免多个页面混听同名事件 |
这套 useWorker() 能直接支撑复杂业务吗? | 还缺少断开清理、目标 ID、错误处理、兼容性降级等能力 | 先用于课程演示和轻量业务,再逐步增强协议和生命周期管理 |
学习要点总结
SharedWorker负责跨页面转发,useWorker()负责统一页面侧使用方式。- 本节的“点对点”本质上是应用层的
eventName过滤,不是底层链路严格点对点。 - 要支撑连接同步和后续扩展,Worker 端的数据结构应从数组升级成
id -> port映射。 emit、on、broadcast是一套很自然的事件通信 API,适合做 Composition API 抽象。- 真正落地项目时,还要继续补上
targetId、断开清理、订阅解绑和浏览器兼容性处理。
术语纠正与内容优化
- 原文中的
ARM 方法,结合上下文应为on方法。 - 原文中的
protest 方法,结合上下文应为broadcast方法。 - 原文中的
conversation API,应为Composition API。 - 原文中的
1MIT,应为emit。 - 原文中的
vu,应为Vue。 - 原文中的
point、poch,结合上下文应为port。 - 原文中的
date、event date、sender date,结合上下文应为data。 - 原文中把“路由信息”等价于
eventName,这是一种课程阶段的简化设计,适合理解机制,但在复杂项目里更建议显式区分eventName和targetId。
延伸学习资源
- Vue 3 Composition API 文档:https://cn.vuejs.org/guide/extras/composition-api-faq.html
- Vue 3 响应式核心 API:https://cn.vuejs.org/api/reactivity-core.html
- MDN
SharedWorker文档:https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker - MDN
MessagePort文档:https://developer.mozilla.org/en-US/docs/Web/API/MessagePort - 学习建议:先实现课程里的
eventName过滤方案,再升级成targetId真正定向发送。 - 练习建议:给
useWorker()增加off()、once()和页面卸载后的连接清理逻辑。
参考说明
本文结合课程原文整理,并对其中的口语化表述、消息协议命名和 Composition API 封装思路做了规范化修正。文章中的 emit、on、broadcast、message、connections、connected 等命名,是基于课程意图进行的工程化整理;代码示例保留了“底层广播、上层按事件过滤”的核心思路,同时补充了更清晰的消息结构与 composable 组织方式。