网页小游戏这个领域,表面上看是"写个 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 是"底盘和传动系统"。你可以用任何引擎,甚至裸写 Canvas,OmniGame 负责的是把发动机装进车里、接上轮子、让它能在各种路况(宿主页面、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并没有真正的安全意义(你依然可以通过原型链拿到),反而让你自己调试时拿不到 shadowRoot,DevTools 里排查问题极其痛苦。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'),因为容器尺寸变化不一定由窗口变化引起(比如侧边栏折叠)。正确做法是用ResizeObserver:
const 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()生成 SDP - A 把 SDP 通过信令通道发给 B
- B 收到后
setRemoteDescription(),调用createAnswer()生成应答 SDP - B 把应答 SDP 发回 A,A
setRemoteDescription() - 双方通过
onicecandidate收集网络候选地址,互相交换 - ICE 协商完成,DataChannel 的
onopen触发,可以发数据了
3.2 DataChannel 的配置参数怎么选
createDataChannel的第二个参数是配置对象,这里的选择直接影响游戏体验:
const channel = pc.createDataChannel('game', { ordered: false, // 是否保证顺序 maxRetransmits: 0, // 最大重传次数 // 或者用 maxPacketLifeTime: 100 });对于实时对战游戏,顺序和可靠性往往是可以牺牲的。想象一下格斗游戏,玩家 A 的第 10 帧操作如果丢了,你重传它,等它到达时游戏已经跑到第 20 帧了,这个操作补上去反而造成回滚抖动。所以 OmniGame 默认用ordered: false+maxRetransmits: 0,也就是"发了不管,丢了就丢了",靠游戏逻辑自己做状态同步和插值。
但这不是绝对的。如果你的游戏是回合制、棋牌类,那顺序和可靠性就很重要,应该用默认配置(ordered: true,可靠传输)。所以 OmniGame 把 DataChannel 配置暴露成参数,让游戏自己决定。
| 游戏类型 | ordered | maxRetransmits | 理由 |
|---|---|---|---|
| 实时对战(格斗、射击) | false | 0 | 低延迟优先,丢包靠逻辑补偿 |
| 回合制(棋牌、策略) | true | 默认 | 可靠性优先,延迟不敏感 |
| 状态同步(MOBA、MMO) | false | 3~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 框架里安全初始化浏览器 API
4.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 className="game-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-components | Shadow DOM |
| 网络请求 | axios | fetch |
| 动画循环 | 各种 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 往返延迟 |
|---|---|---|
| 单机(无 P2P) | 60 FPS | - |
| 局域网 P2P | 60 FPS | 2~5 ms |
| 同城 P2P | 60 FPS | 15~30 ms |
| 跨地域 P2P | 58~60 FPS | 40~80 ms |
| 走 TURN 中转 | 55~60 FPS | 60~120 ms |
可以看到,P2P 直连的延迟优势非常明显,同城能压到 30ms 以内,这是传统服务器中转很难做到的。走 TURN 时延迟上升,但依然可用。
6.2 三个真实踩过的坑
坑一:Shadow DOM 里的 canvas 拿不到焦点。游戏需要监听键盘事件,但 Shadow DOM 内的元素默认不参与焦点管理,keydown事件不会冒泡到宿主。解决办法是给宿主元素加tabindex="0",然后在宿主上监听键盘事件,或者用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 用熟之后,会发现它们的能力比想象中强得多,而且没有版本升级的焦虑。这套方案我前后迭代了几个版本,从最初的一坨代码到现在分层清晰的结构,最大的收获不是技术本身,而是对"浏览器到底能做什么"这件事有了更实在的认知。