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', '网关连接失败,请检查网络');
  }
};

三个关键点:

  1. 4 秒连接超时——不无限等待,快速失败并转移
  2. 三个 broker 轮询 + 500ms 间隔——避免瞬时打垮某个节点
  3. 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 提取核实,非人工估算。

posted @ 2026-10-12 09:51  SuperEden  阅读(5)  评论(0)    收藏  举报