1. 项目缘起与整体架构思路
1.1 为什么要做 OmniGame 这样一个东西
网页小游戏这个赛道,看起来已经卷到不能再卷了。打开任何一个在线小游戏平台,清一色的 iframe 嵌套、清一色的广告弹窗、清一色的"加载中请稍候"。但如果你真的动手做过一个稍微复杂点的网页游戏,就会发现一个很尴尬的现实:大部分所谓的"网页游戏框架",本质上只是把 Canvas 渲染和资源加载做了个封装,真正涉及工程化、模块隔离、实时通信的部分,几乎是空白。
OmniGame 这个项目,最初就是我在做一个多人对战小游戏时被逼出来的。当时的需求很朴素:两个人打开同一个网页,就能实时对战,不需要服务器中转数据,不需要注册登录,最好连后端都不用写。听起来像是天方夜谭,但把 WebRTC 的 DataChannel 和 Shadow DOM 的样式隔离组合起来之后,这条路居然真的走得通。
所以 OmniGame 的定位很明确:一个零后端依赖、基于 WebRTC P2P 通信、用 Shadow DOM 做组件隔离、用 Next.js + Tailwind CSS 做工程底座的网页小游戏开发框架。它解决的核心问题是——让开发者能在不搭建任何服务端基础设施的前提下,快速做出可联机、可复用、样式不打架的网页小游戏。
适合谁来参考?如果你是有一定前端基础、想尝试 P2P 实时通信的开发者,或者你正在做小游戏但被服务器成本和样式污染折磨得够呛,再或者你只是对 WebRTC 和 Shadow DOM 这两个技术点的实战组合感兴趣,那这篇内容应该能给你不少可以直接抄的东西。
1.2 整体架构的分层设计
OmniGame 的架构我分成了四层,从下往上依次是:通信层、隔离层、渲染层、工程层。这个分层不是拍脑袋定的,而是根据实际开发中"哪一层出问题最频繁"来划分的。
通信层负责 WebRTC 的握手、信令交换、DataChannel 建立和数据收发。这一层是整个项目最脆弱也最核心的部分,因为 WebRTC 的连接建立涉及 SDP 交换、ICE 候选收集、NAT 穿透等一系列复杂流程,任何一个环节出问题都会导致连接失败。
隔离层基于 Shadow DOM 实现,负责把每个游戏组件(比如棋盘、计分板、聊天框)的样式和 DOM 结构完全封装起来。为什么要做这个?因为在一个页面里同时跑多个小游戏或者多个 UI 模块时,CSS 类名冲突是家常便饭。你给棋盘写了个.board类,结果聊天框里也有个.board,样式直接串了。Shadow DOM 的attachShadow({ mode: 'closed' })能从根上解决这个问题。
渲染层就是游戏本身的 Canvas 或 DOM 渲染逻辑,这一层相对独立,框架只提供生命周期钩子和状态同步接口,具体怎么画由开发者决定。
工程层用 Next.js 做 SSR 和路由,用 Tailwind CSS 做原子化样式。这里有个细节:Tailwind 的样式默认是全局的,但配合 Shadow DOM 使用时需要特殊处理,后面会详细讲。
1.3 为什么选 WebRTC 而不是 WebSocket
这个问题我被问过很多次。WebSocket 方案成熟、文档多、调试方便,为什么要折腾 WebRTC?
核心原因有三个。第一是成本。WebSocket 需要一台始终在线的服务器做中转,哪怕只是两个人对战,数据也要绕一圈服务器。WebRTC 的 DataChannel 建立之后,数据直接在两个浏览器之间传输,服务器只在握手阶段参与,握手完成后就可以完全退出。对于小规模对战场景,这意味着你可以把后端成本降到几乎为零。
第二是延迟。P2P 直连的延迟天然比中转低,尤其是在两个用户地理位置较近的情况下。我实测过同一个城市内的两台设备,WebRTC DataChannel 的往返延迟稳定在 10ms 以内,而经过服务器中转的 WebSocket 方案普遍在 30-50ms。
第三是隐私。数据不经过第三方服务器,对于某些对数据敏感的场景(比如本地双人对战记分),这是一个实实在在的优势。
当然,WebRTC 也不是没有代价。NAT 穿透失败的情况确实存在,这时候需要 TURN 服务器做中继,而 TURN 服务器是有带宽成本的。但在小游戏这个场景下,大部分用户处于家庭网络环境,STUN 直连的成功率相当高。我的经验是,在常规家庭宽带环境下,STUN 直连成功率大概在 85% 左右,剩下的 15% 才需要 TURN 兜底。
2. 核心细节解析与实操要点
2.1 WebRTC 信令交换的完整流程拆解
WebRTC 最让人头疼的地方就是信令。很多人第一次接触 WebRTC 时会困惑:为什么我建立了 RTCPeerConnection,却连不上?答案很简单——WebRTC 规范本身不定义信令协议,它只负责媒体和数据的传输,至于双方怎么交换 SDP 和 ICE 候选,完全由开发者自己决定。
在 OmniGame 里,我用了一个极简的信令方案:基于 URL Hash 的离线信令交换。具体做法是,房主创建 Offer 后,把 SDP 压缩编码成一个短字符串,拼接到分享链接的 hash 部分。加入方打开链接后,从 hash 里解析出 Offer,生成 Answer,再把 Answer 通过某种方式传回给房主。
这个方案听起来很土,但实际用起来非常顺手,因为它完全不需要服务器。当然,它的局限也很明显:只适合一对一的场景,而且 Answer 的回传需要手动操作(比如复制粘贴)。如果你要做三人以上的房间,还是得老老实实搭一个信令服务器。
信令交换的完整流程我整理成了下面这个表,方便对照排查:
| 步骤 | 操作 | 关键点 | 常见问题 |
|---|---|---|---|
| 1 | 房主创建 RTCPeerConnection | 配置 iceServers | 忘记配 STUN 导致候选收集失败 |
| 2 | 房主创建 DataChannel | 设置 ordered 和 maxRetransmits | 配置不当导致消息乱序或丢失 |
| 3 | 房主 createOffer 并 setLocalDescription | 等待 ICE 收集完成 | 过早导出 SDP 导致候选不全 |
| 4 | 房主导出 SDP 并分享 | 压缩编码 | 直接传原始 SDP 太长 |
| 5 | 加入方 setRemoteDescription | 解析 Offer | 编码格式不匹配 |
| 6 | 加入方 createAnswer 并 setLocalDescription | 同样等待 ICE 完成 | 同上 |
| 7 | 加入方导出 Answer 回传 | 手动或自动 | 回传链路断裂 |
| 8 | 房主 setRemoteDescription | 完成握手 | 顺序错误导致失败 |
| 9 | DataChannel onopen 触发 | 连接就绪 | 未监听导致误判 |
这里有个非常关键的细节:ICE 候选收集是异步的,而且可能持续好几秒。如果你在createOffer之后立刻导出 SDP,很可能只拿到了部分候选,导致对方无法连接。正确的做法是监听icegatheringstatechange事件,等到状态变成complete再导出。或者用一个超时兜底,比如等 3 秒后强制导出,避免某些网络环境下候选收集永远不完成。
2.2 Shadow DOM 样式隔离的实战坑点
Shadow DOM 的样式隔离能力很强,但用起来有几个坑必须提前知道。
第一个坑是Tailwind CSS 在 Shadow DOM 里默认不生效。因为 Tailwind 生成的样式表是挂在 document 的 head 里的,而 Shadow DOM 内部的元素不会继承外部样式。解决办法有两种:一种是把 Tailwind 编译后的 CSS 字符串注入到 Shadow Root 里,另一种是使用 Constructable Stylesheets。我推荐后者,因为性能更好,而且支持动态更新。
// 使用 Constructable Stylesheets 注入样式 const sheet = new CSSStyleSheet(); sheet.replaceSync(tailwindCSSString); shadowRoot.adoptedStyleSheets = [sheet];第二个坑是事件冒泡会被 Shadow 边界截断。Shadow DOM 内部的事件默认不会冒泡到外部,除非你设置composed: true。比如你在 Shadow 内部点击一个按钮,外部的监听器是收不到的。这在做全局快捷键或者点击外部关闭弹窗时特别容易踩坑。
第三个坑是mode: 'closed'之后外部完全无法访问内部节点。这在封装性上是好事,但调试时会很痛苦。我的建议是开发阶段用mode: 'open',上线前再改成closed。或者干脆一直用open,因为closed带来的安全性提升其实很有限,真正想访问的人通过attachShadow的返回值还是能拿到引用。
2.3 DataChannel 的配置参数怎么选
DataChannel 的配置直接决定了数据传输的可靠性和延迟,这里面的取舍很讲究。
ordered参数控制消息是否按顺序到达。设为true时,消息保证顺序,但可能会因为等待前一条消息而阻塞。设为false时,消息可能乱序到达,但延迟更低。对于游戏状态同步这种场景,我一般建议设为false,因为游戏状态是"最新覆盖旧值"的语义,乱序到达的旧状态直接丢弃就行。
maxRetransmits和maxPacketLifeTime控制重传策略。前者限制重传次数,后者限制重传时间窗口。两个参数只能设一个。对于实时性要求高的游戏操作(比如移动指令),我建议设maxRetransmits: 0,也就是不重传,丢了就丢了,反正下一帧会发新的。对于关键指令(比如"游戏开始"),则应该用可靠通道,确保送达。
下面是我在实际项目中总结的参数配置对照表:
| 场景 | ordered | maxRetransmits | 理由 |
|---|---|---|---|
| 实时位置同步 | false | 0 | 低延迟优先,旧数据无意义 |
| 游戏指令 | true | null(可靠) | 必须送达,顺序重要 |
| 聊天消息 | true | null(可靠) | 不能丢,顺序重要 |
| 心跳检测 | false | 0 | 丢了下一拍补上 |
3. 实操过程与核心环节实现
3.1 从零搭建 Next.js + Tailwind 工程底座
第一步是初始化工程。我用的是 Next.js 的 App Router 模式,因为它的布局系统和 Server Component 对游戏框架这种"壳 + 内容"的结构很友好。
npx create-next-app@latest omnigame --typescript --tailwind --app cd omnigame初始化完成后,目录结构大概是这样:
omnigame/ ├── app/ │ ├── layout.tsx │ ├── page.tsx │ └── game/[roomId]/page.tsx ├── components/ │ ├── GameShell.tsx │ └── ShadowHost.tsx ├── lib/ │ ├── webrtc.ts │ └── shadow.ts └── styles/ └── globals.css这里有个关键决策:游戏页面用动态路由[roomId],这样每个房间有独立的 URL,方便分享。房间 ID 我用了 nanoid 生成,比 UUID 短,而且 URL 友好。
Tailwind 的配置需要做一点小改动,主要是把 content 路径配全,避免动态生成的类名被 purge 掉:
// tailwind.config.js module.exports = { content: [ './app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}', ], // ... }3.2 WebRTC 连接模块的完整实现
通信层我封装成了一个PeerConnection类,核心方法有三个:createOffer、acceptOffer、send。
export class PeerConnection { private pc: RTCPeerConnection; private channel: RTCDataChannel | null = null; private onMessageCallback: ((data: any) => void) | null = null; constructor() { this.pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ], }); this.pc.oniceconnectionstatechange = () => { console.log('ICE state:', this.pc.iceConnectionState); }; } async createOffer(): Promise<string> { this.channel = this.pc.createDataChannel('game', { ordered: false, maxRetransmits: 0, }); this.setupChannel(this.channel); const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); // 等待 ICE 收集完成 await this.waitForIceGathering(); return this.encodeSDP(this.pc.localDescription!); } private waitForIceGathering(): Promise<void> { return new Promise((resolve) => { if (this.pc.iceGatheringState === 'complete') { resolve(); return; } const timeout = setTimeout(resolve, 3000); // 3秒兜底 this.pc.onicegatheringstatechange = () => { if (this.pc.iceGatheringState === 'complete') { clearTimeout(timeout); resolve(); } }; }); } private setupChannel(channel: RTCDataChannel) { channel.onopen = () => console.log('DataChannel open'); channel.onclose = () => console.log('DataChannel closed'); channel.onmessage = (e) => { this.onMessageCallback?.(JSON.parse(e.data)); }; } send(data: any) { if (this.channel?.readyState === 'open') { this.channel.send(JSON.stringify(data)); } } onMessage(cb: (data: any) => void) { this.onMessageCallback = cb; } private encodeSDP(desc: RTCSessionDescription): string { // 压缩 SDP,去掉不必要的行 const compressed = desc.sdp .split('\r\n') .filter((line) => !line.startsWith('a=extmap')) .join('\r\n'); return btoa(compressed); } }这段代码里有几个值得展开讲的点。
STUN 服务器的选择。我用了 Google 的公共 STUN 服务器,免费且稳定。但要注意,公共 STUN 服务器不保证 SLA,生产环境最好自建或者用商业服务。另外,STUN 只负责发现公网地址,如果双方都在对称 NAT 后面,STUN 是打不通的,这时候必须上 TURN。
ICE 收集的等待策略。我用了"事件监听 + 3 秒超时"的组合。为什么是 3 秒?因为实测下来,大部分网络环境下 ICE 收集在 1-2 秒内完成,3 秒足够覆盖绝大多数情况。设太长会让用户等得不耐烦,设太短又可能漏掉候选。
SDP 压缩。原始 SDP 通常有 2-3KB,直接放 URL 里太长。我做的压缩很简单,就是去掉a=extmap开头的行(这些是 RTP 扩展映射,DataChannel 用不到),然后 Base64 编码。实测能压到 1KB 左右,URL 长度可以接受。
3.3 Shadow DOM 游戏组件的封装
游戏组件的封装我写了一个ShadowHost组件,它接收一个渲染函数,把渲染结果挂到 Shadow Root 里。
// components/ShadowHost.tsx 'use client'; import { useEffect, useRef } from 'react'; interface Props { styles: string; render: (root: ShadowRoot) => void; } export function ShadowHost({ styles, render }: Props) { const hostRef = useRef<HTMLDivElement>(null); useEffect(() => { const host = hostRef.current; if (!host) return; const shadow = host.attachShadow({ mode: 'open' }); // 注入样式 const sheet = new CSSStyleSheet(); sheet.replaceSync(styles); shadow.adoptedStyleSheets = [sheet]; // 执行渲染 render(shadow); return () => { // 清理 shadow.innerHTML = ''; }; }, [styles, render]); return <div ref={hostRef} />; }用的时候大概是这样:
<ShadowHost styles={tailwindCSS} render={(root) => { const board = document.createElement('div'); board.className = 'grid grid-cols-3 gap-2 p-4'; // ... 构建棋盘 root.appendChild(board); }} />这里有个性能上的考量:adoptedStyleSheets比<style>标签性能更好,因为样式表只解析一次,多个 Shadow Root 可以共享同一个 CSSStyleSheet 实例。如果你的页面里有几十个游戏组件,这个差异会很明显。
3.4 游戏状态同步的实现细节
状态同步是多人游戏的核心。OmniGame 里我用的是状态快照 + 增量更新的混合策略。
具体来说,游戏状态用一个普通的 JS 对象表示,每次状态变化时,计算与上一帧的差异(diff),只发送差异部分。接收方收到 diff 后,应用到本地状态上。
function diff(prev: any, next: any): any { const changes: any = {}; for (const key in next) { if (JSON.stringify(prev[key]) !== JSON.stringify(next[key])) { changes[key] = next[key]; } } return changes; } function applyDiff(state: any, changes: any): any { return { ...state, ...changes }; }这个方案在状态结构简单时很好用,但如果状态嵌套很深,JSON.stringify的开销会很大。优化方案是用结构共享(structural sharing)或者引入 immutable 数据结构。不过对于小游戏来说,状态通常不会太复杂,简单方案够用了。
发送频率上,我建议限制在每秒 20-30 次。太频繁会占满带宽,太稀疏又会导致画面卡顿。可以用requestAnimationFrame做节流,每帧最多发一次。
4. 常见问题与排查技巧实录
4.1 WebRTC 连接失败的五种典型情况
WebRTC 连接失败是最常见的问题,而且报错信息往往很模糊。我把踩过的坑整理成了下面这个速查表:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| ICE 状态卡在 checking | NAT 穿透失败 | 查看候选类型 | 配置 TURN 服务器 |
| 连接建立后立即断开 | SDP 不完整 | 检查候选数量 | 等待 ICE 收集完成 |
| 只有一方能收到消息 | DataChannel 单向 | 检查双方 channel 状态 | 确认双方都监听了 onmessage |
| 消息乱序严重 | ordered 配置不当 | 检查 channel 配置 | 关键消息用可靠通道 |
| 移动网络下必失败 | 运营商 NAT 严格 | 测试不同网络 | 必须上 TURN |
这里重点说两个。
ICE 状态卡在 checking是最常见的。原因通常是双方都在对称 NAT 后面,STUN 发现的公网地址无法直接通信。解决办法是配置 TURN 服务器做中继。TURN 服务器可以用 coturn 自建,也可以用商业服务。自建的话,一台 1核1G 的云服务器就能撑不少并发,成本可控。
移动网络下必失败这个坑我踩了很久。后来发现是运营商的 NAT 策略特别严格,STUN 基本打不通。这种情况下 TURN 是唯一解。所以如果你的游戏要支持移动端,TURN 服务器是必须的,别想着省这个钱。
4.2 Shadow DOM 相关的疑难杂症
Shadow DOM 的问题主要集中在样式和事件上。
样式不生效是最常见的。除了前面说的 Tailwind 注入问题,还有一个容易忽略的点:CSS 变量(custom properties)是可以穿透 Shadow 边界的。这意味着你可以在 document 的:root上定义变量,Shadow 内部直接使用。这其实是个很有用的特性,可以用来做主题切换。
事件监听失效通常是因为没有设置composed: true。比如你在 Shadow 内部派发一个自定义事件,想让外部监听到,必须这样写:
const event = new CustomEvent('game-over', { detail: { score: 100 }, bubbles: true, composed: true, // 关键 }); shadowRoot.dispatchEvent(event);焦点管理也是个坑。Shadow DOM 内部的元素获取焦点时,document.activeElement会返回 Shadow Host 而不是内部元素。要获取真正的焦点元素,需要用shadowRoot.activeElement。
4.3 性能优化的几个实操心得
性能这块我总结了三条经验。
第一条:DataChannel 的消息不要太大。WebRTC 的 DataChannel 单条消息有大小限制(通常 64KB 左右,具体取决于实现),超过会分片,分片会带来额外的开销和延迟。我的建议是单条消息控制在 16KB 以内。如果状态数据很大,考虑用二进制格式(比如 ArrayBuffer)而不是 JSON。
第二条:Shadow DOM 的层级不要太深。每多一层 Shadow Root,样式计算和事件传播的开销都会增加。我一般控制在两层以内:外层是游戏容器,内层是具体的 UI 组件。
第三条:状态同步用二进制。JSON 序列化的开销在数据量大时很可观。如果状态结构固定,可以用DataView手动打包成二进制,体积能减少 50% 以上,序列化速度也快好几倍。当然,这会增加代码复杂度,小游戏不一定值得。
4.4 调试工具与技巧
WebRTC 的调试一直是个痛点,因为连接过程是黑盒。我常用的几个工具:
chrome://webrtc-internals是必看的,它能显示所有的 PeerConnection、ICE 候选、DataChannel 状态和实时统计。排查连接问题时,第一件事就是打开这个页面。
对于 DataChannel 的消息调试,我习惯在onmessage里加日志,把消息内容和时间戳打出来。配合performance.now()可以精确测量延迟。
Shadow DOM 的调试在 Chrome DevTools 里需要开启 "Show user agent shadow DOM" 选项,否则看不到内部结构。另外,Elements 面板里 Shadow Root 会显示为#shadow-root (open)或#shadow-root (closed),点击可以展开。
5. 工程化扩展与后续演进方向
5.1 从一对一扩展到多人房间
前面说的 URL Hash 信令方案只支持一对一。要支持多人,必须引入信令服务器。我的方案是用一个极简的 WebSocket 服务器做信令中转,只负责转发 SDP 和 ICE 候选,不碰游戏数据。
多人场景下,拓扑结构有两种选择:全连接(Mesh)和星型(Star)。全连接是每两个人之间都建立一条 P2P 连接,延迟最低但连接数随人数平方增长。星型是所有人连到房主,房主转发数据,连接数线性增长但房主带宽压力大。
对于 4 人以内的小游戏,我推荐全连接。超过 4 人,星型更实际。再大就得上 SFU(Selective Forwarding Unit),但那就超出小游戏的范畴了。
5.2 游戏组件的插件化设计
OmniGame 的长期目标是做成一个插件化的游戏框架。每个游戏是一个独立的插件,实现统一的接口:
interface GamePlugin { id: string; name: string; minPlayers: number; maxPlayers: number; init(container: ShadowRoot, peers: PeerConnection[]): void; onStateChange(state: any): void; destroy(): void; }这样框架只负责通信和隔离,具体游戏逻辑完全解耦。插件可以独立开发、独立测试、独立发布。这个设计参考了 VS Code 的插件体系,实际用下来扩展性很好。
5.3 离线优先与本地回放
P2P 的一个天然优势是数据都在本地。我利用这一点做了一个本地回放功能:把游戏过程中的所有消息按时间戳记录下来,存到 IndexedDB 里。回放时按时间轴重放这些消息,就能完整还原一局游戏。
这个功能对于调试和分享都很有用。调试时可以看到每一步的状态变化,分享时可以把精彩对局发给朋友看。实现上也不复杂,核心就是一个消息记录器和重放器。
5.4 安全性的几点考量
P2P 虽然省了服务器,但安全性上要自己兜底。几个关键点:
数据校验。P2P 意味着对方可以直接发数据给你,所以所有收到的数据都必须校验。不能假设对方是善意的。比如对方发来的坐标值,要检查是否在合法范围内。
连接认证。URL Hash 信令方案下,任何拿到链接的人都能加入。如果需要限制,可以在 SDP 里嵌入一个共享密钥,握手时校验。
速率限制。防止对方发送大量数据把你的浏览器卡死。可以在onmessage里做简单的速率统计,超过阈值就断开连接。
6. 一些踩坑之后的个人体会
做 OmniGame 这个项目最大的感受是,WebRTC 的复杂度被严重低估了。网上很多教程只讲怎么建立连接,但实际项目中,连接建立只是开始,后面的状态同步、错误处理、网络切换才是真正花时间的地方。
另一个体会是,Shadow DOM 和 Tailwind 的组合需要一些适配工作,但适配完之后开发体验非常好。样式隔离带来的心智负担降低是实实在在的,你再也不用担心改一个组件的样式会影响到另一个组件。
最后分享一个小技巧:在开发阶段,把 WebRTC 的连接过程可视化出来。我在页面上加了一个小的状态指示器,实时显示 ICE 状态、DataChannel 状态和消息收发计数。这个东西在调试时帮了大忙,一眼就能看出问题出在哪个环节。上线前把它隐藏掉就行,代码留着,下次调试还能用。
这个项目后续我打算把插件体系和回放功能再打磨一下,另外想试试用 WebCodecs 做游戏画面的录制和回放,应该能做出一些有意思的东西。