
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完成握手顺序错误导致失败9DataChannel 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也就是不重传丢了就丢了反正下一帧会发新的。对于关键指令比如游戏开始则应该用可靠通道确保送达。下面是我在实际项目中总结的参数配置对照表场景orderedmaxRetransmits理由实时位置同步false0低延迟优先旧数据无意义游戏指令truenull可靠必须送达顺序重要聊天消息truenull可靠不能丢顺序重要心跳检测false0丢了下一拍补上3. 实操过程与核心环节实现3.1 从零搭建 Next.js Tailwind 工程底座第一步是初始化工程。我用的是 Next.js 的 App Router 模式因为它的布局系统和 Server Component 对游戏框架这种壳 内容的结构很友好。npx create-next-applatest 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(): Promisestring { 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(): Promisevoid { 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(aextmap)) .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 里太长。我做的压缩很简单就是去掉aextmap开头的行这些是 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 useRefHTMLDivElement(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 状态卡在 checkingNAT 穿透失败查看候选类型配置 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 人星型更实际。再大就得上 SFUSelective 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 做游戏画面的录制和回放应该能做出一些有意思的东西。