Shadow DOM 隔离、多通道 WebRTC 联机与零依赖 MQTT 协议实现
技术摘要 —— 本文基于 OmniGame v1.2.0 源码,对MV3 浏览器游戏扩展的架构设计进行拆解分析,重点讨论在「离线优先」约束下实现实时P2P 对战的技术路径。涵盖 Shadow DOM 样式隔离机制、三形态承载架构、四通道联机发现与连接策略、803 行零依赖 MQTT 3.1.1 协议栈实现,以及传输层降级容错方案。
关键词:Chrome Manifest V3 · Shadow DOM · WebRTC · RTCDataChannel · MQTT 3.1.1 · 内容安全策略 · 离线优先架构
一、问题的提出:离线优先与实时联机的结构性矛盾
「小游戏合集」类扩展的典型架构是将全部游戏打包进扩展资源,实现零外部请求的离线体验。然而,当产品线延伸至多人实时对战时,两种技术哲学产生直接冲突:
- 离线优先要求资源本地化、切断外部依赖,运行时无任何网络交互;
- P2P 联机要求穿透 NAT,在互不连通的网络间建立实时数据通道。
OmniGame v1.2.0 采用的解法是按维度分离:160 款游戏本体完全离线(134 款单机 + 26 款联机的游戏逻辑均不依赖网络),而联机能力作为用户显式触发的可选动作,仅在主动发起对战时建立连接。
本文对该实现中的若干关键决策进行技术剖析,并如实指出其当前的工程局限。
二、规模与数据构成
在讨论技术之前,需要确认项目的真实规模。以下数据是通过在 Node 环境中实际执行 catalog.js 提取的,而非人工计数:
| 维度 | 数值 |
|---|---|
| 游戏总数 | 160 款(catalog 条目数 = 唯一 id 数 = 磁盘目录数,三者一致) |
| 街机 arcade | 47 |
| 解谜 puzzle | 42 |
| 联机 multiplayer | 26 |
| 物理 physics | 17 |
| 消除 match | 14 |
| 棋类 board | 14 |
| 接入联机引擎的 HTML | 26 个(与 multiplayer 分类严格吻合) |
| 联机引擎代码量 | omni-net.js 803 行 |
| Manifest 版本 | MV3 |
26 款联机游戏的具体清单:
重力方阵 · 方块死斗 · 暗箱轮盘 · 摇骰对决 · 猜拳巅峰赛
卡牌比大小 · 五子棋巅峰赛 · 大吃小井字棋 · 桌上空气曲棍球
点格棋领地战 · 疯狂拔河对决 · 打地鼠竞速死斗 · 啪啪打手反应王
足球点球大战 · 飞镖大师对决 · 霓虹乒乓死斗 · 拆弹心跳博弈
记忆翻牌竞速 · 桌面冰壶碰撞 · 双人扫雷死斗 · 双人贪吃蛇对决
泡泡龙攻防战 · 2048 竞速死斗 · 极速打字对决 · 眼力找不同对决
海战棋迷雾决战
这个分布本身就说明了产品取向:单机部分是绝对主体(134 款),联机是精选而非噱头(26 款)。
三、样式隔离机制:Shadow DOM 边界设计
扩展最核心的交互是那个悬浮在任意网页右下角的小球。技术难点不在于「画一个球」,而在于这个球要出现在用户浏览的每一个网站上,同时不能和宿主页面互相干扰。
OmniGame 采用 Shadow DOM 作为隔离层。该实现在 content.js 中虽仅一行,但选型依据需说明:
// content.js — 悬浮球宿主
var host = document.createElement('div');
host.id = 'omg-root';
host.style.cssText =
'position:fixed;top:0;left:0;width:100%;height:100%;' +
'pointer-events:none;z-index:2147483647;overflow:visible;display:block;';
var shadow = host.attachShadow({ mode: 'open' });
该实现包含三项关键设计决策:
1. attachShadow 而非普通 DOM 节点
Shadow DOM 会为样式创建独立的作用域边界。宿主页面所有 CSS 选择器都无法穿透进来,扩展的所有样式也不会泄漏出去。绝大多数网站的 reset 样式、全局 button { ... } 规则,对悬浮球完全无效。
这也是为什么全部样式都能写成这样简洁的形式,而不用担心被外部污染:
shadow.appendChild(style); // style.textContent 为纯 CSS 字符串,无类名冲突风险
2. pointer-events:none + 子元素 pointer-events:auto
宿主容器铺满全屏但完全透明于鼠标事件,只有悬浮球本身响应交互。这样即使容器遮挡了页面,也不影响用户正常点击网页内容——这是一个容易被忽略但对可用性至关重要的细节。
3. 自我清理机制
// 清理上一次注入残留(如扩展热重载后)
var prevHost = document.getElementById('omg-root');
if (prevHost && prevHost.parentNode) {
try { prevHost.parentNode.removeChild(prevHost); } catch (e) {}
}
扩展热重载时旧节点会残留在页面上,这段代码在初始化最前面清掉它。没有这行,每次 chrome://extensions 点刷新都会在页面上留一个孤儿节点。
四、多形态承载架构的一致性设计
OmniGame 提供了三种游戏承载方式,它们共享同一套游戏代码:
| 形态 | 实现 | 场景 |
|---|---|---|
| 网页悬浮球 | Content Script + Shadow DOM | 摸鱼场景,球常驻任意网页 |
| 独立小窗 | floatwin.js 注入的 iframe |
独立于浏览器 UI,桌面小窗 |
| 浏览器侧边栏 | side_panel → surface.html |
Chrome 原生侧边栏,不遮挡网页 |
三者的差异只在于外层容器的实现,游戏本体以 iframe 形式加载,同一套代码。
// floatwin.js — 独立小窗
var el = document.createElement('div');
el.className = 'omg-fw omg-fw-hidden';
el.innerHTML =
'<div class="omg-fw-head">' +
'<span class="omg-fw-title">🎮 ' + (opts.title || 'OmniGame') + '</span>' +
'<div class="omg-fw-actions">' +
'<button class="omg-fw-btn omg-fw-sidepanel" title="在侧边栏继续玩">📑</button>' +
'<button class="omg-fw-btn omg-fw-center" title="居中显示">🎯</button>' +
'<button class="omg-fw-btn omg-fw-max" title="最大化 / 还原">⤢</button>' +
'<button class="omg-fw-btn omg-fw-close" title="关闭小窗">×</button>' +
'</div></div>' +
'<div class="omg-fw-body">' +
'<iframe class="omg-fw-frame" src="' + iframeUrl + '" allow="clipboard-write"></iframe>' +
'<div class="omg-fw-resize"></div>' +
'</div>';
小窗支持拖拽、边缘吸附缩放、居中、最小化到侧边栏。三个动作按钮对应三种形态之间的迁移——这是同一局游戏的跨形态接力,而不是三个独立入口。
五、联机引擎:四通道发现与统一抽象层
这是整个项目技术含量最集中的部分。omni-net.js 803 行代码,实现了四种玩家发现与连接方式。
5.1 ICE 服务器配置:五条 STUN 与 TURN 缺失
// games/_shared/omni-net.js
var RTC_CONFIG = {
iceServers: [
{ urls: 'stun:stun.cloudflare.com:3478' },
{ urls: 'stun:stun.miwifi.com:3478' },
{ urls: 'stun:stun.qq.com:3478' },
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
这里有一个明确的缺失:没有配置 TURN 服务器。
这个决定的影响是双面的——省下了自建或采购中继服务器的持续成本,代价是对称型 NAT 环境下可能无法建立连接。对于「同一个 Wi-Fi 下的局域网对战」和「少数玩家跨网」的典型场景,STUN 已足够;但如果用户处于严格的企业网络或运营商 NAT 之后,连接会失败。
五条 STUN 服务器的选取反映了可用性与区域可达性的权衡:Cloudflare(全球节点)、小米与腾讯(境内节点)、Google ×2(境外备用)。
5.2 通道一:六位房间密钥配对
最直接的方式。生成一个六位数字码,双方输入即可配对:
OmniNet.prototype.createRoom = function (customCode) {
var code = customCode || Math.floor(100000 + Math.random() * 900000).toString();
this.roomCode = code;
this.isHost = true;
var topic = 'omnigame/room/' + this.gameId + '/' + code;
var self = this;
this._connectSignaling(topic, function () {
self.emit('roomCreated', code);
});
return code;
};
MQTT Topic 的设计是 omnigame/room/{游戏ID}/{房间码}——游戏 ID 的隔离是内建的。两个玩不同游戏的人即使输入相同的房间码,也不会收到彼此的信令消息。这个细节在多游戏场景下省去了大量冲突处理。
加入方的实现包含一个务实的细节:
OmniNet.prototype.joinRoom = function (code) {
this.roomCode = code;
this.isHost = false;
var topic = 'omnigame/room/' + this.gameId + '/' + code;
var self = this;
this._connectSignaling(topic, function () {
// 反复发送 ready,直到收到 offer 或超时
self._signalSend({ type: 'ready' });
var tries = 0;
self._readyInterval = setInterval(function () {
tries++;
if (self.state === 'connected' || tries > 8) {
clearInterval(self._readyInterval);
self._readyInterval = null;
} else {
self._signalSend({ type: 'ready' });
}
}, 700);
});
};
为什么需要重发8 次? 因为加入方不知道主机何时上线。如果主机已经创建房间,加入方一进去就能立刻收到 offer,无需重发;但如果主机还没进,等待中的加入方必须主动反复 announce 自己的存在。这个「每700ms 重试、最多 8 次」的策略解决了一个真实的时序竞态。
5.3 通道二:局域网雷达发现
同一 Wi-Fi 下的自动发现,靠周期性广播 presence:
OmniNet.prototype.startLanRadar = function (lanChannel) {
var ch = lanChannel || 'default';
var topic = 'omnigame/lan/' + this.gameId + '/' + ch;
var self = this;
this._connectSignaling(topic, function () {
self.broadcastPresence();
if (self._radarTimer) clearInterval(self._radarTimer);
self._radarTimer = setInterval(function () {
self.broadcastPresence();
}, 3000); // 每 3 秒广播一次在线状态
});
};
OmniNet.prototype.broadcastPresence = function () {
this._signalSend({ type: 'presence', profile: this.profile });
};
OmniNet.prototype.challengePlayer = function (opponentId) {
this.isHost = true;
this._signalSend({ type: 'challenge', targetId: opponentId });
};
3 秒的心跳周期是一个权衡:频率太高浪费带宽且增加 broker 压力,太低则影响「在线状态」的实时感。3 秒能保证列表刷新体验,同时把消息量控制在合理范围。
注意 challengePlayer 会把自己设为 host ——挑战者即主机,这是 P2P 中不需要额外协商的简化。
5.4 通道三:BroadcastChannel 同机进程间通信
// 构造函数中
this.bc = null;
if (typeof BroadcastChannel !== 'undefined') {
try {
var self = this;
this.bc = new BroadcastChannel('omnigame_local_bus');
this.bc.onmessage = function (e) {
if (e.data && e.data.topic === self.currentTopic &&
e.data.senderId !== self.myId) { // 排除自己发出的消息
self._handleSignalMsg(e.data);
}
};
} catch (err) {}
}
同一台机器上开多个标签页玩对战时,信令完全不需要经过网络——BroadcastChannel 是浏览器内的进程间通信,零延迟、零带宽。
而且它是发送与接收的双重通道:
OmniNet.prototype._signalSend = function (data) {
data.senderId = this.myId;
data.profile = this.profile;
data.topic = this.currentTopic;
if (this.peerId && !data.targetId) data.targetId = this.peerId;
// 1. 先走本地 BroadcastChannel(同机瞬时送达)
if (this.bc) {
try { this.bc.postMessage(data); } catch (e) {}
}
// 2. ...再走 MQTT broker
};
this.bc.postMessage 在 try/catch 中——BroadcastChannel 在某些隐私模式下会抛异常(比如文件被其他标签页独占时)。静默失败并继续走 broker,是正确的降级处理。
5.5 通道四:公共 MQTT Broker 信令中转
信令中转走公共 broker。三个节点:
var BROKER_LIST = [
'wss://broker-cn.emqx.io:8084/mqtt',
'wss://broker.emqx.io:8084/mqtt',
'wss://broker.hivemq.com:8884/mqtt'
];
这里有一个明确的工程决策:手写 MQTT 3.1.1 协议栈,不引入任何库。
MQTT 的剩余长度(Remaining Length)字段采用可变字节编码,1 字节最多表示 127 字节,2 字节最多 16383字节。项目自己实现了编解码:
// 编码:任意长度可变字节
function encodeMqttLength(len) {
var bytes = [];
do {
var digit = len % 128;
len = Math.floor(len / 128);
if (len > 0) digit = digit | 0x80;
bytes.push(digit);
} while (len > 0);
return bytes;
}
// 解码:带溢出保护
function decodeMqttLength(bytes, offset) {
var multiplier = 1;
var value = 0;
var i = offset;
var digit;
do {
if (i >= bytes.length) return null;
digit = bytes[i++];
value += (digit & 0x7f) * multiplier;
multiplier *= 128;
if (multiplier > 128 * 128 * 128) return null; // 溢出保护
} while ((digit & 0x80) !== 0);
return { length: value, bytesRead: i - offset };
};
握手包也是逐字节构造的:
OmniNet.prototype._sendMqttConnect = function () {
var rawId = [];
for (var i = 0; i < this.myId.length; i++) {
rawId.push(this.myId.charCodeAt(i));
}
// MQTT 3.1.1, clean session, 60s keepalive
var varHeader = [0, 4, 77, 81, 84, 84, 4, 2, 0, 60];
// 'M'=77 'Q'=81 'T'=84 'T'=84
var payload = [0, this.myId.length].concat(rawId);
var body = varHeader.concat(payload);
var lenBytes = encodeMqttLength(body.length);
var packet = [0x10].concat(lenBytes).concat(body); // 0x10 = CONNECT
if (this.ws && this.ws.readyState === 1) {
this.ws.send(new Uint8Array(packet).buffer);
}
};
为什么不用现成的 mqtt.js(约 200KB)?因为这是一个 Chrome 扩展。每一 KB 都会影响首次安装体积,而且 MV3 的 CSP 策略(script-src 'self')对动态代码执行有限制。803 行手写协议,换来的是零依赖。
Broker 自动故障转移:
OmniNet.prototype._connectSignaling = function (topic, onOpen) {
var brokerUrl = BROKER_LIST[this.brokerIdx % BROKER_LIST.length];
try {
var ws = new WebSocket(brokerUrl, ['mqtt']);
ws.binaryType = 'arraybuffer';
// 4 秒超时未就绪则切换下一个 broker
var openTimeout = setTimeout(function () {
if (ws.readyState !== 1) {
self._retryNextBroker(topic, onOpen);
}
}, 4000);
ws.onopen = function () {
clearTimeout(openTimeout);
self._sendMqttConnect();
setTimeout(function () {
self._sendMqttSubscribe(topic);
if (onOpen) onOpen();
}, 220); // 订阅比 CONNECT 延后 220ms
};
ws.onerror = function () {
clearTimeout(openTimeout);
self._retryNextBroker(topic, onOpen);
};
} catch (e) {
self._retryNextBroker(topic, onOpen);
}
};
OmniNet.prototype._retryNextBroker = function (topic, onOpen) {
this.brokerIdx++;
if (this.brokerIdx < BROKER_LIST.length) {
var self = this;
setTimeout(function () {
self._connectSignaling(topic, onOpen);
}, 500);
} else {
this.emit('signalError', '网关连接失败,请检查网络');
}
};
三个关键点:
- 4 秒连接超时——不无限等待,快速失败并转移
- 三个 broker 轮询 + 500ms 间隔——避免瞬时打垮某个节点
brokerIdx递增不回绕——全部失败后触发signalError,不会无限重试
该220ms 延迟同样具有必要性:CONNECT 与 SUBSCRIBE 分开发送,依赖 MQTT 服务端「先处理 CONNECT、后处理 SUBSCRIBE」的顺序保证,属手写协议栈必须处理的协议时序细节。
六、传输层降级容错:双传输机制设计
这是引擎里一个容易被忽略但相当务实的设计。
OmniNet.prototype.send = function (type, payload) {
var sent = false;
if (this.dc && this.dc.readyState === 'open') {
var msg = JSON.stringify({ t: type, d: payload, ts: Date.now() });
try {
this.dc.send(msg);
sent = true;
} catch (e) {}
}
// 高可靠双传输:DataChannel 未就绪时,降级走信令中继
if (!sent && this.currentTopic) {
this._signalSend({ type: 'relay_msg', t: type, d: payload, ts: Date.now() });
sent = true;
}
return sent;
};
逻辑很简单:优先走 P2P DataChannel,如果没就绪,就通过 MQTT broker 转发。
这意味着在 NAT 穿透失败的对称网络里,对局仍然可以继续——只是所有数据包多绕一圈服务器,延迟升高,但功能不中断。对于休闲棋类游戏,这个降级的可用性价值极高。
对应的接收端,引擎还准备了一个 fallback 定时器,在 DataChannel 迟迟不就绪时切换到中继模式继续对局。
七、离线优先的边界界定
回到文章开头那个矛盾。
OmniGame 的答案是按维度切分:
| 场景 | 网络行为 |
|---|---|
| 打开任意网页、拖动悬浮球 | 零网络请求 |
| 玩 134 款单机游戏 | 零网络请求 |
| 使用老板键、召唤小球等本地功能 | 零网络请求 |
| 主动发起联机对战 | 连接 STUN + MQTT broker |
换句话说,「离线」承诺覆盖的是单机体验的完整性,而不是否认联机功能的存在。这是一种诚实的产品表述——多数「小游戏合集」根本不做联机,所以宣称离线很安全;OmniGame 做了联机,就明确划出边界。
对应到 Manifest 权限声明:
{
"permissions": [
"storage", "sidePanel", "activeTab",
"scripting", "contextMenus", "tabs"
],
"host_permissions": ["<all_urls>"],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
}
权限列表里没有 unlimitedStorage、没有外部域名白名单、没有 <all_urls> 之外的额外 host 权限。<all_urls> 是 Content Script 注入所必需的(悬浮球要在任意网页工作)。
CSP 是最严格的 MV3 形式:script-src 'self' 意味着不能执行任何远程代码、不能用 eval、不能用 new Function。这也是为什么 803 行 MQTT 协议栈必须手写——引入一个需要动态代码生成的库会直接违反 CSP。
八、关键工程实现细节
8.1 连接状态机管理
this.state = 'idle'; // idle | connecting | connected | closed
四个状态,枚举式管理。所有异步回调都检查 this.state,避免竞态导致的重复连接。
8.2 事件驱动的游戏侧接口
引擎对游戏暴露的是事件接口,游戏代码不需要理解任何 WebRTC 概念:
OmniNet.prototype.on = function (evt, fn) {
if (!this.listeners[evt]) this.listeners[evt] = [];
this.listeners[evt].push(fn);
return this; // 链式调用
};
OmniNet.prototype.emit = function (evt, data) {
var list = this.listeners[evt] || [];
for (var i = 0; i < list.length; i++) {
try { list[i](data); } catch (e) { console.error(e); }
}
};
emit 里的 try/catch 逐个包裹——一个游戏的事件处理器抛异常,不会阻断其他订阅者。
消息按类型分发,游戏可以只关心自己关心的:
dc.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
if (msg && msg.t) {
self.emit('message', msg); // 全量消息
self.emit('msg:' + msg.t, msg.d); // 按类型分发,如msg:move
}
} catch (err) {}
};
msg:move、msg:score、msg:win——游戏只需 net.on('msg:move', handler) 即可。
8.3 资源生命周期管理
OmniNet.prototype.close = function () {
this.state = 'closed';
if (this._fallbackTimer) { clearTimeout(this._fallbackTimer); this._fallbackTimer = null; }
if (this._radarTimer) { clearInterval(this._radarTimer); this._radarTimer = null; }
if (this._readyInterval) { clearInterval(this._readyInterval); this._readyInterval = null; }
if (this.dc) { try { this.dc.close(); } catch (e) {} this.dc = null; }
if (this.pc) { try { this.pc.close(); } catch (e) {} this.pc = null; }
if (this.ws) { try { this.ws.close(); } catch (e) {} this.ws = null; }
if (this.bc) { try { this.bc.close(); } catch (e) {} this.bc = null; }
this.emit('disconnected');
};
四个定时器 + 四个连接对象全部清理。扩展场景下这一点尤其重要——用户可能在任何时刻切走标签页、关闭小窗或禁用扩展,任何一个泄漏的定时器都可能在后台空转。
九、工程局限与已知约束
当前实现存在以下客观约束,需在评估技术方案时纳入考量:
9.1 未配置 TURN 中继,对称型 NAT 下连接建立失败率较高
如前文所述,ICE 服务器仅配置 STUN。该限制已通过「DataChannel 不可用时降级至 MQTT 中继」部分缓解,但在 P2P 通道建立失败的情况下,连接降级的触发存在延迟,且业务数据全程经由 broker 中转,延迟显著上升。
9.2 公共 broker 构成外部单点依赖
三个公共 EMQX 节点的可用性、连接并发上限、以及服务条款变更均不受项目控制。一旦全部不可用,跨网联机能力将完全丧失。生产环境部署需自建 broker 以消除该风险。
9.3 信令元数据未加密
业务数据经 P2P DataChannel 传输,由 DTLS 保证机密性,安全属性完备。但信令消息采用明文 MQTT 传输,房间密钥、玩家标识、昵称与头像等profile 信息对 broker 节点可见。若攻击者具备 Topic 观测能力,可据此推断特定用户间的对局关系。
该风险在个人使用场景下影响有限;若产品向平台化或商业化演进,需通过自建 broker 或信令层端到端加密予以解决。
9.4 host_permissions 权限范围较宽
<all_urls> 为悬浮球注入所必需,属功能性需求。但该权限在 Chrome Web Store 审核中属敏感权限,需提供充分的技术说明以证明其使用的必要性。
十、结论与后续演进方向
上述技术决策的共同逻辑在于:每一项能力引入前,均已评估其在受限场景下的失效代价。
| 决策 | 获取的能力 | 相应代价 |
|---|---|---|
| 803 行手写 MQTT 协议栈 | 零第三方依赖、满足 CSP约束 | 实现与维护成本 |
| 仅配置 STUN、放弃 TURN | 零中继带宽成本、部署无外部依赖 | 对称型 NAT 下连接失败率上升 |
| 四通道冗余设计 | 覆盖同机 / 局域网 / 跨网多场景 | 架构复杂度增加 |
| DataChannel 失败降级中继 | P2P 不可用时功能不中断 | 延迟升高、带宽成本转移至broker |
相较之下,最优解为自建 TURN 中继、自建 MQTT broker 并采用成熟信令协议,但该路径需承担持续的运维投入。对浏览器游戏扩展这一形态而言,将有限工程资源集中于游戏本体的玩法实现与交互体验,而非自建基础设施,是符合产品定位的合理权衡。
后续演进的重点应聚焦于提升联机能力在复杂网络环境下的可靠性,而非单纯扩充游戏数量。
数据说明:本文所引游戏总数(160)、联机游戏数(26)、引擎代码行数(803)及分类分布,均通过在 Node 环境实际执行 catalog.js 提取核实,非人工估算。
浙公网安备 33010602011771号