{T}
项目内容
课程名称未提供
当前章节基于 SharedWorker 封装可复用的消息通信 Composition API
知识领域Vue Composition API / SharedWorker / 多标签页通信 / 工程化封装
学习目标掌握如何把 SharedWorker 通信逻辑抽象成可复用的 useWorker(),并理解 emitonbroadcastconnections 这套消息机制设计
整理时间2026-03-29
资料校对基于上一节 SharedWorker 模型,对消息协议、连接池结构和 Vue composable 封装思路做了工程化整理

一、为什么要继续封装一层 必须掌握

1.1 从“能通信”到“好复用”

概念说明

上一节里我们已经完成了 SharedWorker 的基本消息转发,也就是“页面 A 发消息,页面 B 能收到”。但如果每个页面都自己重复写:

  • 注册端口
  • 发送消息
  • 监听消息
  • 维护页面 ID
  • 管理连接池

那么代码会很分散,也不利于后续在更多页面中复用。

课程里老师提到的核心目标就是:把这套逻辑统一收口到一个 Vue Composition API 里,例如 useWorker(),让每个页面都通过同一种方式使用消息机制。

语法/用法

理想的对外 API 形态通常类似:

ts
const {
  emit,
  on,
  broadcast,
  message,
  connections,
} = useWorker();

注意事项

  • 封装的目的不是“把所有逻辑藏起来”,而是统一输入输出。
  • API 名称要尽量贴近事件系统的语义,例如 onemitbroadcast,这样上层业务更容易理解。

二、这套封装到底想解决什么 必须掌握

2.1 三类通信诉求

概念说明

从课程原文来看,封装后的通信系统至少要覆盖三种能力:

  • emit:针对某类事件发送消息
  • on:监听某类事件
  • broadcast:广播给所有页面

此外,还会暴露两类状态:

  • message:当前最近一条收到的消息
  • connections:当前有哪些连接页面或页面 ID

代码示例

ts
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[]>;
};

注意事项

  • messageref 暴露是为了配合 Vue 的响应式系统。
  • on 依然有存在价值,因为很多场景更适合事件订阅,而不是单纯依赖一个“最新消息”变量。

三、这不是“真正的点对点”,而是“广播 + 过滤” 必须掌握

3.1 课程里的关键设计思想

概念说明

这段课里最重要的一句话其实是:

“当页面发送 emit 消息时,SharedWorker 底层仍然是把消息广播出去,再由各个页面根据 eventName 判断自己要不要处理。”

也就是说,这套机制并不是网络层面的严格点对点,而是应用层的事件分发机制。

语法/用法

可以这样理解:

  1. 页面 A 调用 emit("route-a", data)
  2. Worker 收到后,把消息转发给其他连接页面
  3. 页面 B、C、D 都会收到
  4. 只有注册了 on("route-a") 的页面才会真正处理

注意事项

  • 这种设计简单、好扩展,也符合课程当前阶段的目标。
  • 如果后续你真的需要“只发给某一个确定页面”,那就不能只靠 eventName,而要引入明确的目标 ID 或路由表。

3.2 为什么这样设计也能满足很多业务

概念说明

如果课程里约定:

  • eventName 等价于页面路由
  • 每个页面路由在当前系统中是唯一的

那么实际效果就很像“点对点”:

  • 你发的是某个 eventName
  • 只有那个页面会监听这个 eventName
  • 其他页面虽然收到了底层消息,但会直接忽略

注意事项

  • 这是一种“逻辑点对点”,不是“链路点对点”。
  • 只要页面路由不是唯一,或者多个页面都监听同一个事件名,就会变成“点对多”。

四、SharedWorker 内部的数据结构要升级 必须掌握

4.1 为什么不能再只用数组

概念说明

课程这节开始把上一节里简单的 ports 数组升级成对象或映射表,这是因为后续要支持:

  • 连接唯一 ID
  • 连接列表同步
  • 按连接做排除
  • 后续可能的定向发送

语法/用法

课程里的思路大致是:

ts
const connections: Record<string, MessagePort> = {};
let nextId = 0;

function generateUniqueId() {
  return `id-${nextId++}`;
}

然后每次有新连接时:

ts
const id = generateUniqueId();
connections[id] = port;

注意事项

  • 用对象或 Map 比数组更适合后续按 ID 处理连接。
  • ID 设计不一定非得是递增数字,也可以使用页面路由、随机串或业务侧传入的标识。

4.2 connected 消息的意义

概念说明

课程里提到:当一个新的 port 初始化完成后,Worker 会主动发出一条 type: "connected" 的消息,告诉页面:

  • 你自己的 ID 是什么
  • 当前有哪些连接存在

这一步很关键,因为上层 useWorker() 只有拿到这些元信息,才能维护本地的 connections 状态。

代码示例

ts
port.postMessage({
  type: "connected",
  currentId: id,
  ids: Object.keys(connections),
});

注意事项

  • 如果连接池有变化,比如新页面加入或旧页面离开,也可以继续把最新连接列表同步给所有页面。
  • 课程原文里还没讲到断开清理,但项目里这一步迟早要补。

五、Worker 端消息协议设计 必须掌握

5.1 至少要有哪几种消息类型

概念说明

按这段课程内容,Worker 端至少要区分三种消息:

  • emit
  • broadcast
  • connected

语法/用法

可以整理成这样的协议:

ts
type WorkerEvent =
  | {
      type: "emit";
      eventName: string;
      data?: unknown;
      fromId: string;
    }
  | {
      type: "broadcast";
      data?: unknown;
      fromId: string;
    }
  | {
      type: "connected";
      currentId: string;
      ids: string[];
    };

注意事项

  • 课程原文中的 datesender date,这里应统一为 data
  • 消息协议一旦对外暴露,字段命名要尽量稳定,不要同一层里同时出现 messagedatapayload 三种混用命名。

5.2 Worker 里的广播函数

概念说明

课程里单独抽了一个 broadcast 方法,这个做法是对的,因为无论是 emit 还是 broadcast,底层都要复用“遍历连接并转发”的逻辑。

代码示例

ts
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 会把带有 eventNamedata 的消息广播给其他连接。

代码示例

ts
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" 时,不需要事件名过滤,意味着“所有页面都可以收到并自行处理”。

代码示例

ts
if (event.data.type === "broadcast") {
  broadcast(
    {
      type: "broadcast",
      data: event.data.data,
      fromId: currentId,
    },
    currentId
  );
}

注意事项

  • 如果你想让自己也接收到广播,可以根据业务决定是否排除 currentId

七、Vue Composition API 的封装思路 必须掌握

7.1 useWorker() 应该暴露什么

概念说明

课程中老师已经给出了目标接口形态。把它整理成一个 composable 后,对外建议暴露:

  • emit
  • on
  • broadcast
  • message
  • connections
  • 可选的 currentId

代码示例

ts
const {
  emit,
  on,
  broadcast,
  message,
  connections,
  currentId,
} = useWorker();

注意事项

  • currentId 虽然课程里没有明确强调,但它在调试和定向发送场景里通常很有用。

7.2 message 为什么适合用 ref

概念说明

课程里提到:虽然 on 可以通过回调拿到消息,但如果用的是响应式开发,完全可以用本地 ref 保存最近一次消息。

代码示例

ts
const message = ref<unknown>(null);
const connections = ref<string[]>([]);

注意事项

  • ref 适合做“最新状态”的暴露。
  • 如果一个页面需要监听多个事件源,单一 message 可能不够用,这时还是要保留 on 的事件订阅能力。

7.3 on 的本质是本地事件总线

概念说明

SharedWorker 负责跨页面转发,而 useWorker().on() 更像是当前页面内部的一层事件订阅封装。

代码示例

ts
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 文件

代码示例

ts
// 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 文件

代码示例

ts
// 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 列表

代码逐行解析

  1. Worker 侧先用 connections 维护 id -> port 映射。
  2. 每次新页面连接时,生成唯一 ID 并发送 connected 消息回页面。
  3. 页面侧 useWorker() 收到 connected 后,更新本地 currentIdconnections
  4. 当页面调用 emit(eventName, data) 时,本质上是向 Worker 发送一条 type: "emit" 的消息。
  5. Worker 收到后调用 broadcast(),把该消息转发给除发送者外的其他页面。
  6. 各页面收到后,只会触发与 eventName 对应的本地监听器。
  7. 当页面调用 broadcast(data) 时,Worker 会把该消息转发给所有其他页面。

常见问题与解决方案

问题原因分析解决方案
明明想做点对点,为什么 Worker 里还是在广播?课程当前设计走的是“广播转发 + 页面按 eventName 过滤”模型接受这是应用层定向机制;如果要真正定向,新增 targetId
为什么所有页面都要有 emitonbroadcast 这些方法?因为每个页面都可能既是发送方,也是接收方useWorker() 统一封装,对外暴露一致 API
为什么还需要 message 这个 ref,不是已经有 on() 了吗?on() 适合事件订阅,message 适合响应式状态展示两者并存,各自解决不同场景
为什么要给每个连接分配唯一 ID?后续需要同步连接列表、排除自己、支持定向转发在 Worker 端维护 id -> port 映射,而不是只用数组
为什么用路由名当 eventName 能实现“像点对点一样”的效果?因为当前约定只有目标页面会监听这个事件名保证事件名具有唯一业务语义,避免多个页面混听同名事件
这套 useWorker() 能直接支撑复杂业务吗?还缺少断开清理、目标 ID、错误处理、兼容性降级等能力先用于课程演示和轻量业务,再逐步增强协议和生命周期管理

学习要点总结

  1. SharedWorker 负责跨页面转发,useWorker() 负责统一页面侧使用方式。
  2. 本节的“点对点”本质上是应用层的 eventName 过滤,不是底层链路严格点对点。
  3. 要支撑连接同步和后续扩展,Worker 端的数据结构应从数组升级成 id -> port 映射。
  4. emitonbroadcast 是一套很自然的事件通信 API,适合做 Composition API 抽象。
  5. 真正落地项目时,还要继续补上 targetId、断开清理、订阅解绑和浏览器兼容性处理。

术语纠正与内容优化

  • 原文中的 ARM 方法,结合上下文应为 on 方法。
  • 原文中的 protest 方法,结合上下文应为 broadcast 方法。
  • 原文中的 conversation API,应为 Composition API
  • 原文中的 1MIT,应为 emit
  • 原文中的 vu,应为 Vue
  • 原文中的 pointpoch,结合上下文应为 port
  • 原文中的 dateevent datesender date,结合上下文应为 data
  • 原文中把“路由信息”等价于 eventName,这是一种课程阶段的简化设计,适合理解机制,但在复杂项目里更建议显式区分 eventNametargetId

延伸学习资源

参考说明

本文结合课程原文整理,并对其中的口语化表述、消息协议命名和 Composition API 封装思路做了规范化修正。文章中的 emitonbroadcastmessageconnectionsconnected 等命名,是基于课程意图进行的工程化整理;代码示例保留了“底层广播、上层按事件过滤”的核心思路,同时补充了更清晰的消息结构与 composable 组织方式。