1. 三个看似无关的技术,为什么被塞进同一个项目
第一次看到这个组合标题的时候,我的反应是:这三样东西怎么会凑到一起?Shadow DOM 是浏览器渲染层的东西,WebRTC 是实时通信协议栈,MQTT 是物联网领域的老牌消息协议。它们分别属于前端架构、网络传输、消息中间件三个完全不同的技术域。
但仔细一想,这个组合其实非常合理。如果你正在做一个去中心化的多人实时协作工具——比如一个不需要中心服务器就能互相发现、互相通信的浏览器端应用——那你恰好需要这三样东西:Shadow DOM 解决 UI 组件之间的样式污染和事件隔离问题,WebRTC 的多通道能力解决点对点数据传输问题,而 MQTT 协议则提供了一个轻量级的消息语义层,让多个节点之间的通信有章可循。
我最近花了两周时间,完整地实现了一个这样的系统。整个项目没有引入任何第三方运行时依赖,所有协议栈都是手写的。这篇文章就是把这期间踩过的坑、想通的原理、以及最终跑通的方案完整地记录下来。
这篇文章适合谁看?如果你已经有一定的前端基础,了解过 Web Components 的基本概念,对 WebRTC 有过至少一次 Demo 级别的实践,同时对网络协议有好奇心,那这篇内容会非常适合你。如果你完全没接触过这些,也没关系,我会在关键节点补充必要的背景知识,用生活化的类比把复杂概念讲清楚。
整个项目最终实现的效果是:多个浏览器标签页(甚至跨设备)之间可以自动发现彼此,建立点对点连接,通过多个数据通道分别传输不同类型的消息,并且所有消息都遵循一套自定义的轻量级 MQTT 风格协议。UI 层面,每个功能模块都被封装在独立的 Shadow DOM 中,互不干扰。
下面我从整体设计思路开始,一步步拆解这个系统的每一个关键环节。
2. 整体架构设计与技术选型逻辑
2.1 为什么是 Shadow DOM 而不是 iframe 或 CSS Modules
组件隔离这件事,前端领域有好几种方案。最常见的是 CSS Modules,它通过编译时生成唯一的类名来避免样式冲突。还有 iframe,它提供了最彻底的隔离——独立的文档环境、独立的样式作用域、独立的 JavaScript 执行上下文。
但这两个方案在这个项目里都不太合适。CSS Modules 只解决了样式命名冲突,解决不了事件冒泡的边界问题,也解决不了组件内部 DOM 结构被外部脚本意外修改的问题。iframe 虽然隔离彻底,但它的通信成本太高——每次跨 iframe 通信都要经过 postMessage 序列化,而且 iframe 内部的渲染性能开销也明显更大。
Shadow DOM 恰好处于两者之间。它提供了真正的 DOM 子树隔离——外部选择器无法穿透 Shadow Boundary,内部样式也不会泄漏出去。同时它又和主文档共享同一个 JavaScript 执行上下文,通信成本几乎为零。对于这个项目来说,每个功能模块(连接管理面板、消息列表、通道状态指示器)都封装成独立的 Custom Element,内部使用 Shadow DOM 做隔离,模块之间通过自定义事件通信。
注意:Shadow DOM 的样式隔离是单向的——外部样式进不来,但内部可以通过
:host选择器接受外部传入的 CSS 自定义属性。这个特性在做主题化的时候非常有用。
2.2 WebRTC 多通道的设计考量
WebRTC 的RTCPeerConnection支持创建多个RTCDataChannel。每个 DataChannel 可以独立配置传输模式:可靠有序(类似 TCP)、不可靠无序(类似 UDP)、或者部分可靠(设置最大重传次数和最大生存时间)。
在这个项目里,我创建了三条通道:
- 控制通道:可靠有序模式,用于传输连接协商信息、节点状态同步、协议握手等关键信令。
- 消息通道:可靠有序模式,用于传输用户的实际业务消息,保证不丢不重。
- 状态通道:不可靠无序模式,用于高频的状态心跳和延迟探测,偶尔丢一两个包完全不影响功能。
为什么要分开?因为不同数据的传输需求完全不同。控制信令丢了会导致连接异常,必须可靠;状态心跳如果也走可靠通道,一旦网络抖动就会造成队头阻塞,把后面的业务消息也堵住。分开之后,每条通道各司其职,整体传输效率明显提升。
2.3 零依赖 MQTT 协议的实现动机
MQTT 是物联网领域广泛使用的发布/订阅协议,它的核心概念非常简洁:主题(Topic)、发布(Publish)、订阅(Subscribe)、服务质量等级(QoS)。标准 MQTT 运行在 TCP 之上,需要 Broker 做消息中转。
但在点对点场景下,没有中心 Broker。所以我做的是:借用 MQTT 的消息语义模型,但把传输层换成 WebRTC DataChannel,把 Broker 的角色去中心化——每个节点既是发布者也是订阅者,通过控制通道交换订阅关系,消息直接点对点推送。
这样做的好处是,整个系统的消息路由逻辑非常清晰。每个节点维护一棵订阅树,收到消息后按照主题匹配规则决定是本地消费还是转发给其他订阅了该主题的节点。QoS 等级也做了简化实现:QoS 0 就是发出去不管,QoS 1 是至少送达一次(带确认重传),QoS 2 简化为恰好送达一次(通过消息 ID 去重实现)。
3. Shadow DOM 隔离的实操细节与避坑指南
3.1 Custom Element 的生命周期与 Shadow Root 挂载
创建一个带 Shadow DOM 的自定义元素,核心代码结构是这样的:
class ConnectionPanel extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <style> :host { display: block; padding: 12px; } .status { color: var(--status-color, #888); } </style> <div class="status">未连接</div> `; } connectedCallback() { this._render(); } disconnectedCallback() { this._cleanup(); } } customElements.define('connection-panel', ConnectionPanel);这里有几个关键决策点。mode我选了'open'而不是'closed'。原因很简单:调试方便。'closed'模式下外部完全无法访问 shadowRoot,虽然隔离更彻底,但排查问题的时候会非常痛苦。在实际项目中,除非有极强的安全需求,否则建议用'open'。
connectedCallback是元素被插入文档时触发的,适合做初始渲染和事件绑定。disconnectedCallback是元素被移除时触发的,必须在这里清理定时器、取消网络监听、断开 MutationObserver,否则会造成内存泄漏。
实操心得:
constructor里不要访问子元素或属性,因为此时元素还没有被解析。所有需要读取 attribute 的逻辑都应该放在connectedCallback里,或者用attributeChangedCallback配合observedAttributes静态属性。
3.2 样式穿透的三种正确姿势
Shadow DOM 的样式隔离虽然好用,但有时候确实需要从外部控制内部样式。我总结了三种可靠的方式:
第一种:CSS 自定义属性(Custom Properties)。自定义属性是可以穿透 Shadow Boundary 的。外部定义--status-color: green,内部用var(--status-color)引用。这是最推荐的方式,因为它保持了隔离性,同时提供了可控的定制接口。
第二种:::part()伪元素。在 Shadow DOM 内部给元素加上part="label"属性,外部就可以用connection-panel::part(label)来选中它。这种方式适合需要精细控制某个特定元素样式的场景。
第三种:::slotted()选择器。用于控制 slot 分发进来的外部内容的样式。注意::slotted()只能选中顶层节点,不能选中嵌套的后代节点。
/* 外部样式 */ connection-panel { --status-color: #2ecc71; } connection-panel::part(label) { font-weight: bold; }3.3 事件重定向与 composed 属性的坑
Shadow DOM 里的事件传播有一个非常容易踩的坑:默认情况下,事件在穿过 Shadow Boundary 时会被重定向,event.target会变成宿主元素,而不是实际触发事件的内部元素。
如果你希望事件能穿透 Shadow Boundary 并且保留真实的 target,需要在派发事件时设置composed: true:
this.dispatchEvent(new CustomEvent('message-received', { bubbles: true, composed: true, detail: { topic, payload } }));bubbles: true让事件向上冒泡,composed: true让它能穿过 Shadow Boundary。两个缺一不可。我一开始只设了bubbles没设composed,结果事件死活传不到外层,排查了快一个小时才发现问题。
另外要注意,composed只影响事件能否穿过边界,不影响event.target的重定向行为。即使设了composed: true,外部拿到的event.target仍然是宿主元素。如果需要传递内部元素的信息,应该通过detail属性携带。
4. 多通道 WebRTC 联机的完整实现路径
4.1 信令交换的最小化设计
WebRTC 建立连接需要交换 SDP Offer/Answer 和 ICE Candidate。在去中心化场景下,没有信令服务器,我采用的方案是:通过一个共享的本地存储(比如BroadcastChannel或者localStorage事件)来交换信令。同一台设备上的多个标签页可以通过BroadcastChannel直接通信;跨设备场景下,则需要一个极简的信令中转(可以是一个静态页面上的手动复制粘贴,或者一个 WebSocket 中继)。
信令交换的流程如下:
- 节点 A 创建
RTCPeerConnection,创建三条 DataChannel。 - 节点 A 生成 Offer,通过信令通道广播。
- 节点 B 收到 Offer,创建
RTCPeerConnection,设置 Remote Description,生成 Answer 回传。 - 双方交换 ICE Candidate,建立连接。
- DataChannel 的
onopen事件触发,连接就绪。
// 创建 PeerConnection 并配置 ICE const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.example.com:3478' }] }); // 创建三条数据通道 const controlChannel = pc.createDataChannel('control', { ordered: true }); const messageChannel = pc.createDataChannel('message', { ordered: true }); const stateChannel = pc.createDataChannel('state', { ordered: false, maxRetransmits: 0 });注意:
maxRetransmits: 0表示不重传,配合ordered: false就是纯 UDP 语义。状态通道用这个配置,延迟最低。
4.2 通道优先级与带宽分配
WebRTC 的 DataChannel 底层共用同一个 SCTP 关联,带宽是共享的。当多条通道同时发送数据时,需要合理设置优先级。RTCPeerConnection提供了priority选项,可以设为'very-low'、'low'、'medium'、'high'。
我的配置是:控制通道high,消息通道medium,状态通道low。这样在网络拥塞时,控制信令优先传输,保证连接稳定性;状态心跳可以适当延迟或丢弃,不影响核心功能。
另外,每条通道的bufferedAmount需要监控。如果某条通道的缓冲数据量持续增长,说明发送速率超过了网络承载能力,需要做背压处理。我的做法是设置一个阈值(比如 64KB),超过阈值就暂停发送,等bufferedAmountLowThreshold事件触发后再继续。
messageChannel.bufferedAmountLowThreshold = 16384; function sendWithBackpressure(channel, data) { if (channel.bufferedAmount > 65536) { channel.onbufferedamountlow = () => { channel.onbufferedamountlow = null; channel.send(data); }; } else { channel.send(data); } }4.3 连接状态监控与自动重连
WebRTC 的连接状态会经历new、connecting、connected、disconnected、failed、closed几个阶段。其中disconnected状态可能是暂时的网络抖动,不一定要立即重连。我的策略是:进入disconnected后启动一个 5 秒的定时器,如果 5 秒内恢复到connected就取消定时器;如果超时仍处于disconnected或进入failed,则触发重连流程。
重连时不需要完全重建 PeerConnection,可以尝试restartIce()。如果restartIce()也失败,才销毁旧的 PeerConnection 重新走一遍信令交换。
pc.onconnectionstatechange = () => { if (pc.connectionState === 'disconnected') { this._disconnectTimer = setTimeout(() => { if (pc.connectionState !== 'connected') { pc.restartIce(); } }, 5000); } else if (pc.connectionState === 'connected') { clearTimeout(this._disconnectTimer); } };5. 零依赖 MQTT 协议栈的手写实现
5.1 主题匹配与订阅树的构建
MQTT 的主题匹配规则支持两种通配符:+匹配单层,#匹配多层。比如订阅sensor/+/temperature可以匹配sensor/room1/temperature和sensor/room2/temperature,但不匹配sensor/room1/floor2/temperature。订阅sensor/#则匹配所有以sensor/开头的主题。
实现主题匹配最直接的方式是用正则,但每次匹配都动态生成正则性能不好。我的做法是在订阅时就把主题模式编译成正则缓存起来:
function topicToRegex(topic) { const escaped = topic .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\+/g, '[^/]+') .replace(/#$/, '.*') .replace(/\/#$/, '(/.*)?'); return new RegExp(`^${escaped}$`); }订阅树的结构是一个嵌套的 Map:第一层 key 是主题的第一段,第二层 key 是第二段,以此类推。叶子节点存储订阅者列表。发布消息时,从根节点开始逐层匹配,遇到+就遍历当前层的所有子节点,遇到#就收集该节点下所有订阅者。
实操心得:订阅树在节点数量少的时候(几十个以内)性能差异不明显,但如果节点数上百,树结构比线性遍历快一个数量级。我实测下来,100 个订阅规则下,树匹配耗时约 0.02ms,线性遍历约 0.3ms。
5.2 QoS 等级的实现与取舍
MQTT 定义了三个 QoS 等级:
| QoS 等级 | 语义 | 实现方式 | 开销 |
|---|---|---|---|
| 0 | 最多一次 | 发出去不管 | 最低 |
| 1 | 至少一次 | PUBLISH + PUBACK 确认重传 | 中等 |
| 2 | 恰好一次 | PUBLISH + PUBREC + PUBREL + PUBCOMP 四步握手 | 最高 |
在点对点场景下,QoS 2 的四步握手开销太大,而且 WebRTC 的可靠通道本身已经保证了不丢不重。所以我的实现是:QoS 0 和 QoS 1 完整实现,QoS 2 降级为 QoS 1 加消息 ID 去重。
QoS 1 的重传逻辑需要维护一个待确认消息表。每条消息分配一个自增的消息 ID,发送后放入待确认表,启动重传定时器(我设的是 3 秒)。收到 PUBACK 后从表中移除并取消定时器。如果重传次数超过上限(比如 5 次),则放弃并触发错误回调。
class QoS1Handler { constructor(channel) { this.channel = channel; this.pending = new Map(); this.msgId = 0; } publish(topic, payload) { const id = ++this.msgId; const packet = this._encodePublish(topic, payload, id, 1); this.channel.send(packet); this._startRetry(id, packet); } _startRetry(id, packet) { let retries = 0; const timer = setInterval(() => { if (retries++ >= 5) { clearInterval(timer); this.pending.delete(id); return; } this.channel.send(packet); }, 3000); this.pending.set(id, { timer, packet }); } handlePuback(id) { const entry = this.pending.get(id); if (entry) { clearInterval(entry.timer); this.pending.delete(id); } } }5.3 报文编解码的二进制设计
MQTT 标准报文使用二进制格式,固定头第一个字节包含报文类型和标志位,第二个字节开始是剩余长度(可变长编码)。我基本沿用了这个设计,但做了一些简化。
固定头第一个字节:高 4 位是报文类型(1=CONNECT, 3=PUBLISH, 4=PUBACK, 8=SUBSCRIBE, 9=SUBACK 等),低 4 位是标志位。PUBLISH 报文的标志位中,bit 3 是 DUP(重复投递),bit 2-1 是 QoS 等级,bit 0 是 RETAIN。
剩余长度采用可变长编码,每个字节的低 7 位有效,最高位是延续标志。这种编码方式可以用 1-4 个字节表示最大 256MB 的长度,非常紧凑。
function encodeRemainingLength(length) { const bytes = []; do { let byte = length % 128; length = Math.floor(length / 128); if (length > 0) byte |= 0x80; bytes.push(byte); } while (length > 0); return new Uint8Array(bytes); } function decodeRemainingLength(buffer, offset) { let multiplier = 1; let value = 0; let byte; do { byte = buffer[offset++]; value += (byte & 0x7f) * multiplier; multiplier *= 128; } while (byte & 0x80); return { value, bytesRead: offset }; }主题字符串的编码是:2 字节的长度前缀(大端序)+ UTF-8 编码的字符串。Payload 直接跟在主题后面,长度由剩余长度减去主题部分得出。
注意:JavaScript 的
TextEncoder和TextDecoder是处理 UTF-8 编解码的标准 API,不需要手写。但要注意TextEncoder只支持 UTF-8,不支持其他编码。
6. 三套技术的集成与联调实录
6.1 消息从 UI 到网络的完整链路
一条消息从用户点击发送到对方收到,经历了这样的链路:
- 用户在 Shadow DOM 内部的输入框输入内容,点击发送按钮。
- 组件内部触发
CustomEvent,携带消息内容和主题,composed: true穿透 Shadow Boundary。 - 外层控制器接收到事件,调用 MQTT 协议栈的
publish方法。 - 协议栈根据主题查找匹配的订阅者,决定是本地消费还是远程发送。
- 远程发送时,将 MQTT 报文编码为二进制,通过对应的 DataChannel 发送。
- 对方节点收到二进制数据,解码为 MQTT 报文,根据主题分发给对应的订阅者。
- 订阅者(可能是另一个 Shadow DOM 组件)更新 UI。
这条链路上最容易出问题的是第 2 步和第 5 步。第 2 步的事件穿透如果忘了设composed: true,事件就卡在 Shadow Boundary 里面了。第 5 步的二进制编码如果字节序搞错了,对方解码出来就是乱码。
6.2 多标签页联调的实操记录
同一台设备上开多个标签页联调是最方便的测试方式。我用BroadcastChannel做信令交换,每个标签页启动时生成一个随机节点 ID,通过BroadcastChannel广播自己的存在。其他标签页收到后,根据 ID 大小决定谁发起 Offer(ID 小的发起,避免双方同时发起造成冲突)。
const bc = new BroadcastChannel('webrtc-signaling'); const myId = Math.random().toString(36).slice(2); bc.onmessage = (event) => { const { type, from, to, data } = event.data; if (to && to !== myId) return; switch (type) { case 'announce': if (from < myId) { // 对方 ID 小,等待对方发起 } else { // 我方 ID 小,主动发起 createOffer(from); } break; case 'offer': handleOffer(from, data); break; case 'answer': handleAnswer(from, data); break; case 'ice': handleIceCandidate(from, data); break; } };这个 ID 大小比较的策略避免了双方同时发起 Offer 的冲突。我一开始没做这个判断,结果两个标签页同时发 Offer,连接状态直接卡在connecting不动了。后来加了 ID 比较,问题解决。
6.3 跨设备联调的注意事项
跨设备联调时,BroadcastChannel就不管用了,需要一个真正的信令通道。我用的是一个极简的方案:一个静态 HTML 页面,通过 WebSocket 连接到一个公共的 WebSocket 回显服务,手动交换 SDP 和 ICE Candidate。虽然原始,但足够可靠。
跨设备场景下,STUN 服务器是必须的。如果双方都在 NAT 后面,还需要 TURN 服务器做中继。我测试的时候,同一局域网内不需要 STUN 也能连通,但跨网络就必须配置 STUN。TURN 因为成本较高,测试阶段没有部署,实际生产环境建议至少准备一个备用 TURN。
实操心得:ICE Candidate 的收集是异步的,可能在 Offer/Answer 交换完成之前就开始,也可能之后才开始。所以需要在
onicecandidate事件中实时转发,而不是等所有 Candidate 收集完再一起发。另外,onicecandidate事件中event.candidate为null表示收集完成,这个信号可以用来判断是否所有 Candidate 都已发送。
7. 常见问题与排查技巧实录
7.1 连接建立失败排查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 卡在 connecting | 信令未正确交换 | 检查 SDP 和 ICE 是否都送达 | 确认信令通道双向可达 |
| 卡在 connecting | NAT 穿透失败 | 查看 ICE Candidate 类型 | 配置 STUN/TURN 服务器 |
| 连接后立即断开 | 通道配置不兼容 | 检查双方 DataChannel 参数 | 确保 ordered/maxRetransmits 一致 |
| 消息发送失败 | bufferedAmount 溢出 | 监控 bufferedAmount 值 | 实现背压处理 |
| 消息乱码 | 编解码字节序错误 | 打印原始 ArrayBuffer | 统一使用大端序 |
| 事件不穿透 | 缺少 composed 标志 | 检查 CustomEvent 配置 | 添加 composed: true |
7.2 Shadow DOM 相关的典型问题
问题一:样式不生效。最常见的原因是样式写在了外部文档里,但选择器无法穿透 Shadow Boundary。解决方法是用 CSS 自定义属性或者::part()。
问题二:查询不到内部元素。document.querySelector无法穿透 Shadow DOM。需要先获取宿主元素,再通过shadowRoot.querySelector查询。如果有多层嵌套的 Shadow DOM,需要逐层穿透。
问题三:事件监听器重复绑定。connectedCallback可能在元素被移动时多次触发。如果在这里绑定事件监听器,需要先移除旧的再绑定新的,或者用标志位控制只绑定一次。
connectedCallback() { if (this._initialized) return; this._initialized = true; this._bindEvents(); }7.3 MQTT 协议实现的踩坑记录
坑一:剩余长度的可变长编码。我一开始用固定 4 字节表示长度,简单是简单,但每个报文多浪费 3 个字节。后来改成可变长编码,代码复杂了一点,但报文体积明显减小。对于高频小消息场景,这个优化很值得。
坑二:QoS 1 的重传定时器泄漏。如果消息在重传过程中连接断开了,定时器还在跑,会不断尝试向已关闭的通道发送数据。需要在连接关闭时清理所有待确认消息的定时器。
坑三:主题匹配的大小写敏感。MQTT 规范规定主题是大小写敏感的。我一开始用了toLowerCase()做归一化,结果Sensor/Data和sensor/data被当成同一个主题,导致消息路由错误。后来去掉了归一化,严格按原始字符串匹配。
坑四:订阅通配符的边界情况。sport/#应该匹配sport本身吗?根据 MQTT 规范,sport/#匹配sport、sport/、sport/tennis等。但sport/+不匹配sport,只匹配sport/xxx。这个边界情况在实现正则时要特别注意。
// 正确处理 # 通配符的边界 function topicToRegex(topic) { if (topic === '#') return /^.*$/; let regex = topic .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\+/g, '[^/]+'); if (regex.endsWith('/#')) { regex = regex.slice(0, -2) + '(/.*)?'; } else if (regex.endsWith('#')) { regex = regex.slice(0, -1) + '.*'; } return new RegExp(`^${regex}$`); }8. 性能优化与扩展方向
8.1 消息批处理与压缩
高频小消息场景下,每条消息单独编码发送的开销很大。我实现了一个简单的批处理机制:在 50ms 的时间窗口内,将发往同一通道的多条消息合并为一个批次,一次性编码发送。接收方解码后拆分为多条消息分发。
批处理的报文格式是:批次头(2 字节消息数量)+ 每条消息的长度前缀(2 字节)+ 消息内容。这样一次发送可以减少大量的固定头开销。
对于文本消息,还可以在编码前做一次简单的压缩。我试过用CompressionStreamAPI 做 gzip 压缩,但发现对于小于 200 字节的消息,压缩后的体积反而更大(压缩头开销)。所以最终策略是:小于 200 字节不压缩,大于 200 字节用 gzip 压缩。
8.2 订阅树的增量更新
当订阅关系频繁变化时,每次重建整棵订阅树开销很大。我改成了增量更新:订阅时沿着主题路径逐层创建节点,取消订阅时逐层清理空节点。这样单次订阅/取消订阅的时间复杂度从 O(n) 降到了 O(depth),depth 是主题层数,通常不超过 5。
subscribe(topic, callback) { const parts = topic.split('/'); let node = this.root; for (const part of parts) { if (!node.children.has(part)) { node.children.set(part, { children: new Map(), subscribers: [] }); } node = node.children.get(part); } node.subscribers.push(callback); }8.3 后续可以扩展的方向
这个项目目前是一个可运行的原型,还有很多可以打磨的地方。比如可以加入消息持久化——把未确认的消息写入 IndexedDB,页面刷新后恢复重传。还可以加入端到端加密——用 Web Crypto API 对消息内容做 AES-GCM 加密,密钥通过控制通道协商。另外,多节点组网目前只支持两两直连,如果要支持三个以上节点,需要实现消息转发和路由表维护。
我在实际使用中发现,Shadow DOM 的隔离虽然好用,但调试成本确实比普通 DOM 高。Chrome DevTools 对 Shadow DOM 的支持已经不错了,可以在 Elements 面板里展开 shadow root 查看内部结构,但断点调试和性能分析还是不如普通 DOM 直观。所以我的建议是:只在真正需要隔离的组件上用 Shadow DOM,不要为了用而用。