
网页小游戏这个领域表面上看是写个 Canvas 循环、画几个精灵图的事但真要把工程上限往上顶一顶绕不开三个硬骨头怎么让游戏代码不污染宿主页面、怎么让两个玩家在浏览器里直接对话而不经过服务器中转、怎么让这套东西在 Next.js 这种带 SSR 的框架里跑得干净利落。OmniGame 这套技术方案就是围绕这三个问题展开的一次完整工程实践。它做的事情可以一句话概括用 Shadow DOM 做样式与 DOM 的强隔离用 WebRTC 的 DataChannel 做 P2P 直连用 Next.js 做壳与路由把网页小游戏从一个页面里塞个 canvas升级成一个可嵌入、可联机、可维护的独立运行时。这篇文章适合三类人看正在做 H5 小游戏但被样式冲突折磨的前端、想搞明白 WebRTC P2P 到底怎么落地的手游/页游开发者、以及任何对零依赖 浏览器原生能力这套组合拳感兴趣的人。下面我会把 OmniGame 的每一层拆开讲包括为什么这么选、怎么落地、踩过哪些坑。1. 为什么网页小游戏需要一次工程上限的重定义1.1 传统 H5 小游戏的三重困境先说清楚问题不然谈方案就是空中楼阁。绝大多数网页小游戏的起点都很朴素一个 HTML 文件一个canvas一段requestAnimationFrame循环。能跑但一旦要往产品化方向走立刻撞墙。第一重困境是样式与 DOM 污染。你的游戏里写了个.btn类宿主页面也有个.btn两边样式互相打架。更糟的是全局样式表里的* { box-sizing: border-box }或者body { margin: 0 }会悄悄改变你游戏内的布局。你可能会说我用 scoped CSS 或者 CSS Modules 不就行了但那是构建期的隔离运行时如果游戏要动态注入样式、要挂载到第三方页面里构建期方案就失效了。第二重困境是联机必须过服务器。传统做法是玩家 A 的操作发给服务器服务器转发给玩家 B。这套架构在页游时代没问题但它意味着你必须维护一台长连接服务器要处理房间管理、断线重连、消息广播。对于一个小体量对战游戏来说服务器成本可能比游戏本身还贵。而 WebRTC 的 DataChannel 允许两个浏览器直接建立 UDP 通道数据不经过你的服务器这在延迟和成本上都是质变。第三重困境是框架集成别扭。Next.js 有 SSR服务端没有window、没有document、没有RTCPeerConnection。你把游戏代码直接写进组件构建时可能不报错运行时一刷新就白屏。很多人被这个问题卡住最后只能退回到纯静态页面放弃了路由、SSR、API 路由这些现代框架能力。1.2 OmniGame 的定位不是引擎是运行时外壳这里要澄清一个容易误解的点。OmniGame 不是 Phaser、不是 PixiJS 那种渲染引擎它不负责帮你画精灵、做物理、管场景。它解决的是引擎之外的那一层游戏怎么被隔离地挂载、怎么建立 P2P 连接、怎么在框架里安全地初始化。打个比方渲染引擎是发动机OmniGame 是底盘和传动系统。你可以用任何引擎甚至裸写 CanvasOmniGame 负责的是把发动机装进车里、接上轮子、让它能在各种路况宿主页面、SSR 框架、P2P 网络下跑起来。这个定位决定了它的技术选型零运行时依赖是底线因为任何依赖都可能和宿主的依赖冲突浏览器原生 API 优先因为原生 API 不需要打包、不需要版本管理。1.3 关键词背后的技术栈全景把标题里的几个词摊开看其实是一条完整的技术链路。Shadow DOM负责隔离层WebRTC P2P负责通信层Next.js负责宿主层零依赖是贯穿始终的约束。这四者不是简单堆叠而是互相制约的因为要零依赖所以隔离必须用原生 Shadow DOM 而不是 styled-components因为要 P2P所以初始化必须发生在客户端这就和 Next.js 的 SSR 产生了张力需要用动态导入和useEffect来化解。理解了这个约束网络后面每一层的设计决策就都能自洽了。下面逐层拆。2. Shadow DOM 隔离层让游戏代码和宿主页面互不干扰2.1 为什么是 Shadow DOM 而不是 iframe隔离方案其实有好几种最彻底的是 iframe。iframe 有独立的 document、独立的样式、独立的 JS 执行环境隔离性拉满。但它的问题也很致命通信要走postMessage有序列化开销iframe 内的requestAnimationFrame在某些浏览器里会被降频最要命的是iframe 无法直接参与宿主的布局流尺寸自适应很麻烦。Shadow DOM 是折中方案里的最优解。它提供样式隔离外部样式进不来内部样式出不去除了继承属性和DOM 隔离内部节点不会被document.querySelector命中但共享同一个 JS 执行环境和同一个渲染管线。这意味着游戏循环的帧率不受影响游戏可以直接读取宿主的尺寸性能开销几乎为零。选 Shadow DOM 的另一个理由是它天然适合可嵌入组件这个场景。你想想如果 OmniGame 要做成一个能被任何网站引入的游戏容器那它本质上就是一个 Web Component。而 Web Component 的标准隔离机制就是 Shadow DOM。这不是为了用而用是场景倒逼的选择。2.2 attachShadow 的 mode 选择与样式注入策略创建 Shadow Root 的时候有个关键参数mode。它有两个值open和closed。// open 模式外部可以通过 element.shadowRoot 访问 const shadow host.attachShadow({ mode: open }); // closed 模式外部拿到的是 null const shadow host.attachShadow({ mode: closed });很多人第一反应是选closed觉得隔离更彻底。但实测下来open才是正确选择。原因是closed并没有真正的安全意义你依然可以通过原型链拿到反而让你自己调试时拿不到 shadowRootDevTools 里排查问题极其痛苦。OmniGame 选open隔离靠的是样式作用域而不是访问控制。样式注入有个坑要注意。Shadow DOM 内部的样式必须通过style标签或者CSSStyleSheet对象注入外部的link是进不去的。有两种写法// 写法一直接塞 style 标签 const style document.createElement(style); style.textContent :host { display: block; position: relative; } canvas { display: block; width: 100%; height: 100%; } ; shadow.appendChild(style); // 写法二构造 CSSStyleSheet可复用性能更好 const sheet new CSSStyleSheet(); sheet.replaceSync(:host { display: block; }); shadow.adoptedStyleSheets [sheet];写法二的优势是CSSStyleSheet对象可以被多个 Shadow Root 共享如果你同时挂载多个游戏实例用写法二能省下重复解析样式的开销。但要注意adoptedStyleSheets在旧版浏览器支持不全OmniGame 里做了特性检测不支持就回退到写法一。2.3 :host 选择器与尺寸自适应的实战细节:host是 Shadow DOM 里最容易被低估的选择器。它指向宿主元素本身是内外沟通的桥梁。默认情况下自定义元素是display: inline的这会导致你的游戏容器高度塌陷。所以第一件事就是:host { display: block; position: relative; contain: layout style paint; }contain属性值得单独说。它告诉浏览器这个元素的布局、样式、绘制不会影响外部浏览器据此可以做渲染优化。对于游戏这种高频重绘的场景contain: layout style paint能明显减少重排范围。实测在一个复杂宿主页面里加上contain后游戏区域的帧率稳定性提升了一截。尺寸自适应是另一个实战痛点。游戏 canvas 需要跟随容器大小变化但你不能用window.addEventListener(resize)因为容器尺寸变化不一定由窗口变化引起比如侧边栏折叠。正确做法是用ResizeObserverconst ro new ResizeObserver((entries) { for (const entry of entries) { const { width, height } entry.contentRect; // 处理 DPR避免高分屏模糊 const dpr window.devicePixelRatio || 1; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; ctx.scale(dpr, dpr); } }); ro.observe(host);这里有个细节canvas.width和canvas.style.width是两回事。前者是绘制缓冲区尺寸后者是显示尺寸。高分屏下如果只设 style 不设缓冲区画面会糊。乘上devicePixelRatio再ctx.scale才能既清晰又坐标正确。这个坑我见过太多人踩画面糊了还以为是引擎问题。注意ResizeObserver的回调里不要做重活它可能在每一帧都触发。把尺寸计算和实际渲染解耦回调里只更新尺寸变量渲染循环里再读取。3. WebRTC P2P 通信层两个浏览器怎么直接对话3.1 从信令到 DataChannel 的完整链路WebRTC 最容易被误解的地方是它不负责帮你找到对方。两个浏览器要建立直连必须先交换一堆元信息SDP offer/answer、ICE candidate这个过程叫信令signaling。信令通道 WebRTC 不管得你自己搭。这是很多人第一次接触 WebRTC 时最困惑的点——我都用 WebRTC 了为什么还要服务器。答案是服务器只用于建立连接阶段的信令交换一旦连接建立数据就走 P2P 了。信令服务器可以很轻一个 WebSocket 广播就够甚至可以用最土的办法手动复制粘贴 SDP来演示。OmniGame 里信令层是可插拔的你可以接 WebSocket、可以接 HTTP 轮询、也可以接任何你顺手的长连接方案。完整链路是这样的玩家 A 创建RTCPeerConnection创建 DataChannel调用createOffer()生成 SDPA 把 SDP 通过信令通道发给 BB 收到后setRemoteDescription()调用createAnswer()生成应答 SDPB 把应答 SDP 发回 AAsetRemoteDescription()双方通过onicecandidate收集网络候选地址互相交换ICE 协商完成DataChannel 的onopen触发可以发数据了3.2 DataChannel 的配置参数怎么选createDataChannel的第二个参数是配置对象这里的选择直接影响游戏体验const channel pc.createDataChannel(game, { ordered: false, // 是否保证顺序 maxRetransmits: 0, // 最大重传次数 // 或者用 maxPacketLifeTime: 100 });对于实时对战游戏顺序和可靠性往往是可以牺牲的。想象一下格斗游戏玩家 A 的第 10 帧操作如果丢了你重传它等它到达时游戏已经跑到第 20 帧了这个操作补上去反而造成回滚抖动。所以 OmniGame 默认用ordered: falsemaxRetransmits: 0也就是发了不管丢了就丢了靠游戏逻辑自己做状态同步和插值。但这不是绝对的。如果你的游戏是回合制、棋牌类那顺序和可靠性就很重要应该用默认配置ordered: true可靠传输。所以 OmniGame 把 DataChannel 配置暴露成参数让游戏自己决定。游戏类型orderedmaxRetransmits理由实时对战格斗、射击false0低延迟优先丢包靠逻辑补偿回合制棋牌、策略true默认可靠性优先延迟不敏感状态同步MOBA、MMOfalse3~5折中允许少量重传文件/资源传输true默认必须完整3.3 NAT 穿透的现实STUN 与 TURN 的取舍P2P 直连听起来很美但现实是大部分用户都在 NAT 后面。两个都在 NAT 后的浏览器能不能直连取决于 NAT 类型。这就是 STUN 和 TURN 出场的地方。STUN服务器的作用是告诉客户端你在公网看到的地址是什么帮助双方发现彼此的可达地址。它很轻量只处理少量请求成本极低。TURN服务器是兜底方案当双方无法直连时所有数据通过 TURN 中转。它要转发全部流量带宽成本高。OmniGame 的策略是必配 STUN按需配 TURN。公共 STUN 服务器比如各大机构提供的免费 STUN能满足大部分场景但企业网络、对称 NAT 环境下会失败。如果你的游戏面向公网用户TURN 是必须准备的兜底否则会有一部分用户永远连不上。const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.example.com:3478 }, { urls: turn:turn.example.com:3478, username: user, credential: pass } ], iceTransportPolicy: all // relay 则强制走 TURN });iceTransportPolicy设为relay可以强制走 TURN调试时有用但生产环境别这么干会白白增加带宽成本。3.4 连接状态机与断线处理RTCPeerConnection有两个状态要盯iceConnectionState和connectionState。前者反映 ICE 协商状态后者反映整体连接状态。实践中更推荐监听connectionState它的取值更直观new、connecting、connected、disconnected、failed、closed。断线处理是 P2P 游戏最容易被忽略的部分。网络抖动会导致disconnected但通常几秒内会自动恢复。真正需要处理的是failed这时候要触发重连逻辑重新走一遍信令流程。OmniGame 里封装了一个重连状态机disconnected时先等待 3 秒如果没恢复成connected就主动restartIce()再不行就重建整个 PeerConnection。pc.onconnectionstatechange () { switch (pc.connectionState) { case disconnected: // 给 3 秒自动恢复窗口 reconnectTimer setTimeout(() { if (pc.connectionState ! connected) { pc.restartIce(); } }, 3000); break; case failed: clearTimeout(reconnectTimer); rebuildConnection(); break; case connected: clearTimeout(reconnectTimer); break; } };提示restartIce()会触发新的 ICE 协商但不会重建 DataChannel是比重建整个连接更轻量的恢复手段。优先用它。4. Next.js 宿主层SSR 框架里安全初始化浏览器 API4.1 为什么游戏代码不能直接写在组件里Next.js 默认对页面做服务端渲染。服务端执行你的组件代码时window、document、RTCPeerConnection全都不存在。如果你在组件顶层写了const pc new RTCPeerConnection()构建时可能不报错因为 Next.js 的构建环境有时会 polyfill 一部分但运行时服务端渲染阶段直接抛ReferenceError页面白屏。更隐蔽的坑是模块顶层副作用。比如你 import 了一个游戏模块这个模块在顶层执行了document.createElement(canvas)。哪怕你只在客户端组件里用它只要这个 import 语句出现在服务端也会执行的代码路径里就会炸。这就是为什么 OmniGame 的所有浏览器相关代码都必须延迟到客户端执行。4.2 动态导入 useEffect 的标准姿势Next.js 提供了next/dynamic来做客户端专属加载import dynamic from next/dynamic; const GameContainer dynamic( () import(../components/GameContainer), { ssr: false, loading: () div classNamegame-placeholder加载中.../div } );ssr: false是关键它保证这个组件只在客户端渲染。但光这样还不够因为组件内部如果直接访问window在 hydration 之前依然可能出问题。所以组件内部还要用useEffect包一层use client; import { useEffect, useRef } from react; export default function GameContainer() { const hostRef useRef(null); const gameRef useRef(null); useEffect(() { // 这里才安全window 一定存在 let disposed false; import(../game/omni).then(({ createGame }) { if (disposed) return; gameRef.current createGame(hostRef.current); }); return () { disposed true; gameRef.current?.destroy(); }; }, []); return div ref{hostRef} /; }这里有个细节值得说disposed标志位。因为动态 import 是异步的如果组件在 import 完成前就卸载了比如用户快速切路由回调里再去操作已经卸载的 DOM 就会报错。加个标志位判断是防御性编程的基本功。4.3 清理逻辑destroy 里到底该做什么游戏实例的destroy方法经常被写得很敷衍但它是内存泄漏的重灾区。一个完整的清理应该包括取消requestAnimationFrame循环断开ResizeObserver关闭RTCPeerConnection和所有 DataChannel移除所有事件监听器清空 Shadow Root 内容释放 WebGL 上下文如果有function destroy() { cancelAnimationFrame(rafId); resizeObserver.disconnect(); dataChannel?.close(); peerConnection?.close(); host.removeEventListener(keydown, onKeyDown); shadowRoot.innerHTML ; gl?.getExtension(WEBGL_lose_context)?.loseContext(); }WebGL 上下文释放这条特别容易被漏。浏览器对同时存在的 WebGL 上下文数量有限制通常 16 个如果反复创建销毁游戏实例而不释放上下文很快就会耗尽新实例创建失败。loseContext()是显式释放的手段。4.4 路由切换时的实例生命周期管理Next.js 的 App Router 里路由切换默认不会卸载页面组件如果用了 layout 缓存。这意味着用户从游戏页切到设置页再切回来游戏实例可能还活着也可能被销毁了取决于你的 layout 结构。这个行为如果不搞清楚会出现切回来游戏卡死或者切回来有两个游戏实例的诡异现象。OmniGame 的做法是把游戏实例的生命周期和路由显式绑定在useEffect的清理函数里销毁实例同时用usePathname监听路由变化路由一变就主动销毁。宁可多销毁一次也不要留下僵尸实例。5. 零依赖约束下的工程取舍5.1 零依赖到底意味着什么零依赖这个词容易被滥用。严格来说OmniGame 的运行时没有任何 npm 依赖所有功能都用浏览器原生 API 实现。但构建期是有依赖的Next.js、TypeScript、打包工具这是两回事。运行时零依赖的价值在于不会和宿主的依赖冲突。你想想如果 OmniGame 依赖了某个版本的库宿主页面也依赖了另一个版本打包到一起就可能出问题。而原生 API 不存在版本冲突浏览器提供什么就用什么。代价是你得自己处理兼容性不能指望库帮你抹平差异。5.2 用原生 API 替代常见库的对照常见需求常用库OmniGame 的原生方案状态管理Redux/Zustand简单的发布订阅 闭包事件总线mitt/EventEmitter原生 EventTarget样式隔离styled-componentsShadow DOM网络请求axiosfetch动画循环各种 tween 库requestAnimationFrame数学运算lodash手写工具函数用EventTarget替代事件总线是个很妙的点。浏览器原生就有EventTarget类你可以直接继承它class GameBus extends EventTarget { emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); } on(type, handler) { this.addEventListener(type, handler); return () this.removeEventListener(type, handler); } }这样连事件库都省了而且CustomEvent的detail字段可以携带任意数据。5.3 兼容性兜底与特性检测零依赖不等于无视兼容性。OmniGame 里所有新 API 都做了特性检测const supportsAdoptedStyleSheets adoptedStyleSheets in Document.prototype replaceSync in CSSStyleSheet.prototype; const supportsResizeObserver typeof ResizeObserver ! undefined;不支持adoptedStyleSheets就回退到style标签不支持ResizeObserver就回退到window.resize监听虽然精度差些。这种渐进增强的思路比直接引入 polyfill 库更轻量。注意特性检测要检测能力而不是浏览器版本。UA 检测早就过时了能力检测才是正道。6. 实测中的性能表现与踩坑记录6.1 帧率与延迟的实测数据在一台普通笔记本集成显卡上用 OmniGame 跑一个 200 个精灵的 2D 场景实测数据大致如下场景平均帧率P2P 往返延迟单机无 P2P60 FPS-局域网 P2P60 FPS2~5 ms同城 P2P60 FPS15~30 ms跨地域 P2P58~60 FPS40~80 ms走 TURN 中转55~60 FPS60~120 ms可以看到P2P 直连的延迟优势非常明显同城能压到 30ms 以内这是传统服务器中转很难做到的。走 TURN 时延迟上升但依然可用。6.2 三个真实踩过的坑坑一Shadow DOM 里的 canvas 拿不到焦点。游戏需要监听键盘事件但 Shadow DOM 内的元素默认不参与焦点管理keydown事件不会冒泡到宿主。解决办法是给宿主元素加tabindex0然后在宿主上监听键盘事件或者用shadowRoot.activeElement手动管理焦点。坑二DataChannel 的bufferedAmount不控制会爆内存。如果你疯狂往 DataChannel 里塞数据而网络发送速度跟不上数据会堆积在缓冲区里bufferedAmount持续增长最后吃光内存。正确做法是每次发送前检查bufferedAmount超过阈值就跳过这一帧的发送if (channel.bufferedAmount 64 * 1024) { channel.send(payload); }坑三Next.js 的 Fast Refresh 导致游戏实例重复创建。开发时改代码热更新会重新执行useEffect如果清理逻辑不完善就会创建多个游戏实例叠加在一起。这个坑在开发阶段特别烦解决办法是确保destroy被正确调用或者在useEffect里用一个全局标志位防止重复初始化。6.3 调试 P2P 连接的实用技巧调试 WebRTC 最有效的工具是chrome://webrtc-internals其他浏览器也有类似页面。它能实时显示 ICE 候选、连接状态、DataChannel 的收发字节数、丢包率等。当你遇到连不上的问题时先打开这个页面看 ICE 状态能快速定位是信令问题还是 NAT 穿透问题。另一个技巧是在信令阶段打印完整的 SDP。SDP 里包含了媒体类型、编解码、ICE 候选等信息虽然格式晦涩但对比两个 SDP 的差异往往能发现配置错误。7. 从这套方案能延伸出什么OmniGame 这套架构的价值不止于跑个小游戏。把 Shadow DOM 隔离 P2P 通信 框架集成这三块拆开看每一块都能独立复用到别的场景。Shadow DOM 隔离这套可以直接用来做微前端沙箱。现在很多微前端方案用 iframe 或者 Proxy 拦截但 Shadow DOM 提供的是浏览器原生的样式隔离比 JS 层面的拦截更可靠。你只需要把子应用的根节点挂到一个 Shadow Root 里样式冲突问题就解决了一大半。P2P 通信这套可以延伸到去中心化的协作工具。比如多人同时编辑一个文档、多人白板数据直接在浏览器之间同步服务器只负责信令和持久化。这种架构在隐私敏感场景下特别有价值因为数据不经过中心服务器。Next.js 集成这套可以推广到任何需要嵌入浏览器 API 的组件。地图、视频播放器、3D 查看器这些组件都有只能在客户端跑的特性处理思路和 OmniGame 完全一致动态导入 useEffect 完善的清理逻辑。我个人在实际项目里最大的体会是约束越强设计越清晰。零依赖这个约束逼着你把每个功能都想透不能靠库来兜底。一开始会觉得麻烦但当你真的把原生 API 用熟之后会发现它们的能力比想象中强得多而且没有版本升级的焦虑。这套方案我前后迭代了几个版本从最初的一坨代码到现在分层清晰的结构最大的收获不是技术本身而是对浏览器到底能做什么这件事有了更实在的认知。