1. 为什么我要折腾一个“零依赖”的网页小游戏框架
先说结论:OmniGame 是我在过去几个月里反复推倒重来三次之后,才勉强敢拿出来讲的一个网页小游戏工程方案。它的核心目标很朴素——让一个网页小游戏从“能跑”变成“跑得稳、传得快、嵌得进、拆得开”。听起来像四个词,但每一个词背后都是一堆真实的坑。
我做这个项目的起点其实很私人。去年帮朋友做一个活动页,需求是“打开网页就能玩,两个人能对战,不要服务器,最好能嵌到任何页面里”。当时我第一反应是:这不就是个小游戏吗,Canvas 画一画,键盘监听一下,完事。结果真动手才发现,光是“两个人能对战”这一条,就把我拖进了一个完全不同的工程维度。你要考虑信令怎么交换、NAT 怎么穿透、数据通道怎么保活、断线怎么重连;你要考虑游戏循环和渲染帧率怎么解耦;你还要考虑这个游戏被嵌到别人页面里的时候,CSS 会不会打架、全局变量会不会污染、Shadow DOM 到底能不能兜住。
OmniGame 就是在这个背景下长出来的。它不是某一个具体游戏,而是一套工程骨架:用 Next.js 做壳,用 WebRTC 的 DataChannel 做 P2P 传输,用 Shadow DOM 做样式隔离,用零运行时依赖的原则约束整个技术选型。说白了,它想回答一个问题——在不依赖任何后端房间服务、不引入重型游戏引擎的前提下,网页小游戏的工程上限到底能推到哪。
这篇文章适合谁看?如果你写过 Canvas 小游戏,但一遇到“联机”就头疼,那这篇对你有用。如果你做前端工程,想了解 WebRTC 在非视频场景下怎么用,那也有用。如果你只是好奇“零依赖”到底能零到什么程度,那我们可以一起拆。我不会只讲概念,我会把参数、代码、踩过的坑都摊开讲,你能直接抄作业的部分我会标出来。
2. 整体架构设计与技术选型背后的取舍
2.1 为什么是 Next.js,而不是纯静态 HTML
很多人一听“网页小游戏”,第一反应是纯静态 HTML 加一个 JS 文件,扔到 CDN 上就完事了。我一开始也是这么干的。但很快遇到两个问题:第一,我需要一个地方做“信令交换”的轻量入口,哪怕只是交换 SDP 和 ICE candidate,也得有个能跑服务端逻辑的地方;第二,我需要按路由拆分不同的游戏模块,同时保证首屏加载足够快。
Next.js 在这里的价值不是“框架光环”,而是它同时给了我三样东西:API Routes 可以做极轻量的信令中转,App Router 可以做按路由的代码分割,静态导出又能让我在不需要服务端的时候直接产出纯静态资源。我实测下来,一个简单的信令接口用 Next.js 的 Route Handler 写,冷启动在本地环境大概几十毫秒,部署到边缘函数之后基本感知不到。这个量级对于“交换几段文本”来说完全够用。
注意:信令服务只负责交换连接元数据,不承载任何游戏数据。游戏数据全部走 WebRTC DataChannel,这是整个架构能“去中心化”的关键。
2.2 WebRTC P2P 到底解决了什么问题
传统的网页联机方案,要么走 WebSocket 中转(所有数据过服务器),要么走轮询(延迟高得离谱)。WebRTC 的 DataChannel 提供的是浏览器之间的直接数据通道,一旦连接建立,数据不再经过我的服务器。这意味着两件事:延迟更低,因为路径更短;成本更低,因为我不需要为每一局游戏的数据流量买单。
但 WebRTC 不是银弹。它的连接建立过程需要信令,它的 NAT 穿透不是百分之百成功,它的 DataChannel 有自己的一套流控和保活机制。我在项目里把连接过程拆成了三个阶段:信令交换、ICE 收集与连通性检查、DataChannel 打开。每个阶段都有对应的超时和降级策略。实测下来,在双方都是普通家庭网络的情况下,连接建立成功率大概在八成到九成之间,剩下的部分需要靠 TURN 中继兜底。这里我不展开中继的具体部署,只说明架构上必须预留这个降级路径。
2.3 Shadow DOM 做样式隔离的真实收益
网页小游戏被嵌入到别人页面里,最大的噩梦是样式污染。你的*选择器、你的全局字体设置、你的body样式,都可能把宿主页面搞乱,反过来宿主页面的样式也可能把你的游戏 UI 搞崩。Shadow DOM 提供的是真正的样式边界:影子树内部的样式不会泄漏出去,外部的样式也不会轻易进来(除了继承属性)。
我在 OmniGame 里把整个游戏容器挂在一个 Shadow Root 下面,游戏内部的所有 CSS 都通过构造样式表注入。这样做的直接好处是,我把这个游戏嵌到一个用了 Bootstrap 的页面里,和嵌到一个纯空白页面里,视觉效果完全一致。代价是,Shadow DOM 内部无法直接使用外部的 CSS 变量(除非显式传递),字体图标之类的资源需要重新处理。但这些是一次性成本,换来的是可预测的渲染结果。
2.4 “零依赖”的边界在哪里
标题里说“零依赖”,我得诚实一点:零依赖指的是运行时零第三方依赖,不是开发时零工具。Next.js 本身是框架依赖,React 是 UI 依赖,这些我认。但在游戏核心逻辑、P2P 传输、样式隔离这三块,我没有引入任何第三方库。没有 Socket.IO,没有 PeerJS,没有游戏引擎,没有状态管理库。
为什么这么苛刻?因为我想验证一件事:当你不依赖抽象层的时候,你对整个链路的掌控力到底有多强。PeerJS 很好用,但它把信令和连接管理封装得太深,出问题的时候你很难定位是信令的问题还是 ICE 的问题。我自己写 WebRTC 的连接管理,虽然代码量多了几百行,但每一个状态转换我都能打日志、能断点、能改。对于一个小游戏框架来说,这种可控性比开发效率更重要。
3. 核心模块拆解与关键实现细节
3.1 游戏循环与渲染解耦:requestAnimationFrame 的正确用法
网页小游戏最容易写错的地方就是游戏循环。很多人会把逻辑更新和渲染画在同一个requestAnimationFrame回调里,然后假设每帧间隔是 16.67 毫秒。这个假设在 60Hz 屏幕上勉强成立,在 120Hz 屏幕上就崩了,在后台标签页里更是完全不可控。
OmniGame 的做法是固定时间步长更新加可变时间步长渲染。逻辑更新以固定的 60Hz 频率推进,渲染则跟随浏览器的requestAnimationFrame。具体实现上,我用一个累加器记录距离上一次逻辑更新过去了多少时间,当累加器超过固定步长时,执行一次逻辑更新并减去步长。这样即使渲染帧率波动,游戏逻辑的推进速度也是一致的。
const FIXED_STEP = 1000 / 60; let accumulator = 0; let lastTime = performance.now(); function loop(currentTime) { const deltaTime = currentTime - lastTime; lastTime = currentTime; accumulator += deltaTime; while (accumulator >= FIXED_STEP) { updateGame(FIXED_STEP); accumulator -= FIXED_STEP; } renderGame(accumulator / FIXED_STEP); requestAnimationFrame(loop); }这里的renderGame接收一个插值因子,用于在两次逻辑更新之间做平滑渲染。这个细节在快速移动的物体上差别很明显,不做插值的话会有肉眼可见的抖动。
实操心得:
performance.now()比Date.now()精度更高,而且在大多数浏览器里不受系统时间调整的影响。游戏循环里一律用前者。
3.2 WebRTC DataChannel 的建立与保活
DataChannel 的建立不是一蹴而就的。完整的流程是:创建 RTCPeerConnection,创建 DataChannel,生成 Offer,通过信令交换 SDP,收集 ICE candidate 并交换,最后等待onopen事件。我在项目里把这套流程封装成了一个状态机,状态包括idle、signaling、connecting、open、closed、failed。
保活方面,DataChannel 本身有bufferedAmount和bufferedAmountLowThreshold两个属性可以用来做流控。我在发送游戏状态时,会先检查bufferedAmount,如果超过阈值就跳过这一帧的发送,避免缓冲区堆积导致延迟越来越大。这个策略在快速对战的场景下特别重要,因为游戏状态是“最新覆盖旧”的语义,丢几帧中间状态完全没问题。
function sendState(channel, state) { if (channel.bufferedAmount > 65536) { return; // 缓冲区太满,跳过这一帧 } channel.send(JSON.stringify(state)); }另外,DataChannel 的onclose和onerror事件必须处理。我遇到过一种情况:一方关闭浏览器标签页,另一方的 DataChannel 不会立刻触发onclose,而是过一段时间才超时。为了更快感知断线,我在应用层加了一个心跳机制,每两秒发送一个轻量心跳包,超过五秒没收到就判定断线并触发重连流程。
3.3 Shadow DOM 的挂载与样式注入
Shadow DOM 的挂载本身不复杂,但有几个细节容易踩坑。第一,attachShadow的mode参数建议用open,方便调试;第二,样式注入推荐用CSSStyleSheet加adoptedStyleSheets,比创建<style>标签更高效,而且支持复用;第三,事件在 Shadow DOM 边界上的传播需要留意composed属性。
const host = document.getElementById('game-host'); const shadowRoot = host.attachShadow({ mode: 'open' }); const sheet = new CSSStyleSheet(); sheet.replaceSync(` :host { display: block; width: 100%; height: 100%; } canvas { display: block; width: 100%; height: 100%; } `); shadowRoot.adoptedStyleSheets = [sheet];adoptedStyleSheets在主流浏览器里的支持已经比较好了,但如果你的目标环境包含较老的浏览器,需要准备一个降级方案,比如动态创建<style>元素并插入到 Shadow Root 里。
注意:Shadow DOM 内部的元素无法通过外部的
document.querySelector直接选中,调试时需要在开发者工具里手动展开 shadow root。这一点在排查渲染问题时要有心理准备。
3.4 状态同步策略:谁说了算
P2P 联机小游戏绕不开的一个问题是:两个玩家的状态以谁为准?常见的方案有三种:主机权威、锁步、状态同步。OmniGame 默认采用主机权威模式,也就是一方作为 host,另一方作为 guest,guest 把输入发给 host,host 计算完游戏状态后广播给 guest。
这个选择的原因是实现简单、逻辑集中、不容易出现状态分歧。代价是 host 的延迟会直接影响 guest 的体验。为了缓解这个问题,我在 guest 端做了输入预测和状态插值:guest 本地先按自己的输入推进一份预测状态,收到 host 的权威状态后再做平滑校正。这套机制在延迟低于 100 毫秒的时候效果不错,超过 200 毫秒就会有明显的“回拉”感。
| 同步方案 | 实现复杂度 | 延迟敏感度 | 适用场景 |
|---|---|---|---|
| 主机权威 | 低 | 中 | 双人对战、回合制 |
| 锁步 | 中 | 高 | 即时战略、确定性模拟 |
| 状态同步 | 高 | 低 | 大规模多人在线 |
4. 从零搭建一个可运行的 OmniGame 实例
4.1 项目初始化与目录结构
我习惯用create-next-app起手,然后手动裁剪掉不需要的部分。目录结构上,我把游戏核心逻辑放在lib/game下,把 WebRTC 封装放在lib/rtc下,把 React 组件放在components下,把信令接口放在app/api/signal下。这个划分的原则是:游戏逻辑不依赖 React,WebRTC 封装不依赖游戏逻辑,两者通过一个薄薄的事件总线通信。
npx create-next-app@latest omnigame --typescript --app cd omnigame mkdir -p lib/game lib/rtc components app/api/signal初始化之后,我做的第一件事是关掉不必要的默认配置,比如图片优化(小游戏用不上)、字体优化(Shadow DOM 里用不了)。这些配置在next.config.js里改,改完之后构建产物体积会小一圈。
4.2 信令接口的最小实现
信令接口的职责只有一个:让两个浏览器能交换 SDP 和 ICE candidate。我用一个内存 Map 做临时存储,房间号作为 key,消息作为 value。生产环境当然要用持久化存储加过期清理,但对于验证阶段来说,内存足够。
// app/api/signal/route.ts const rooms = new Map<string, any[]>(); export async function POST(request: Request) { const { roomId, role, payload } = await request.json(); if (!rooms.has(roomId)) rooms.set(roomId, []); const messages = rooms.get(roomId)!; messages.push({ role, payload, time: Date.now() }); return Response.json({ ok: true }); } export async function GET(request: Request) { const roomId = new URL(request.url).searchParams.get('roomId'); const messages = rooms.get(roomId) || []; return Response.json({ messages }); }这个实现很粗糙,但它能跑。两个浏览器通过轮询这个接口交换消息,直到 WebRTC 连接建立。连接建立之后,信令接口就不再被调用了。
实操心得:轮询间隔建议从 500 毫秒开始,连接建立后立刻停止轮询。我试过 200 毫秒的间隔,在本地开发时没问题,但部署到线上之后请求量会明显上升,没必要。
4.3 连接建立与游戏启动的完整流程
完整的启动流程是这样的:页面加载后,玩家点击“创建房间”或“加入房间”。创建房间的一方生成房间号,初始化 RTCPeerConnection,创建 DataChannel,生成 Offer,把 Offer 发到信令接口。加入房间的一方轮询到 Offer,设置远程描述,生成 Answer,发回信令接口。双方交换 ICE candidate,连接建立,DataChannel 打开,游戏开始。
这个流程里最容易出问题的是 ICE candidate 的交换时机。如果一方在设置远程描述之前就收到了 candidate,需要先缓存起来,等远程描述设置完成后再添加。我在代码里用一个数组做候选缓存,setRemoteDescription完成后再批量addIceCandidate。
let pendingCandidates = []; pc.onicecandidate = (event) => { if (event.candidate) { sendSignal({ type: 'candidate', candidate: event.candidate }); } }; async function handleRemoteDescription(desc) { await pc.setRemoteDescription(desc); for (const candidate of pendingCandidates) { await pc.addIceCandidate(candidate); } pendingCandidates = []; }4.4 游戏状态序列化与传输格式
游戏状态在传输前需要序列化。JSON 是最省事的选择,但它的体积和解析开销都比二进制大。我实测过,一个包含十几个实体的游戏状态,JSON 序列化后大概几百字节,在 DataChannel 上传输完全没问题。如果状态更大,可以考虑用ArrayBuffer加自定义二进制格式,但那是优化阶段的事,不要一上来就做。
传输频率上,我默认是每帧发送一次,也就是 60Hz。实测下来,在双方网络状况良好的情况下,这个频率是稳定的。如果网络状况差,bufferedAmount会升高,我的跳过策略会自动降低实际发送频率。这个自适应机制比固定降频更平滑。
5. 实际踩坑记录与问题排查手册
5.1 连接建立失败的五种常见原因
第一种,信令消息丢失或乱序。轮询方案下,消息可能重复或乱序,需要在应用层做去重和排序。第二种,ICE candidate 收集不全。有些网络环境下,host candidate 和 srflx candidate 都收集不到,需要 TURN 兜底。第三种,SDP 格式不兼容。不同浏览器生成的 SDP 有细微差异,设置远程描述时可能报错。第四种,防火墙拦截。企业网络环境下,UDP 可能被完全阻断。第五种,房间号冲突。内存存储没有做隔离,不同房间的消息可能串。
排查的时候,我习惯先看pc.iceConnectionState和pc.connectionState的变化,再看pc.iceGatheringState。如果iceGatheringState一直是gathering,说明 candidate 收集有问题。如果connectionState到了failed,说明连通性检查没通过。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 一直停在 connecting | ICE 未完成 | 检查 candidate 交换 |
| 连接后立刻断开 | SDP 不兼容 | 对比双方 SDP |
| 数据发送无响应 | DataChannel 未打开 | 检查 onopen 事件 |
| 延迟持续升高 | 缓冲区堆积 | 检查 bufferedAmount |
| 一方收不到数据 | 心跳超时 | 检查心跳逻辑 |
5.2 Shadow DOM 里的字体与图标问题
Shadow DOM 内部默认不继承外部字体,除非字体是通过@font-face定义的全局字体。图标字体在 Shadow DOM 里经常显示成方块,原因是字体文件没有在影子树内部加载。解决办法是在 Shadow Root 内部重新声明@font-face,或者改用 SVG 图标。我后来统一换成了内联 SVG,省去了字体加载的麻烦,而且渲染更可控。
5.3 移动端浏览器的特殊处理
移动端浏览器对 WebRTC 的支持比桌面端更挑剔。iOS Safari 需要用户手势才能创建 RTCPeerConnection,Android 上的某些浏览器对 DataChannel 的支持不完整。我在移动端做了一层能力检测,如果不支持 DataChannel,就降级到 WebSocket 中转模式。这个降级路径虽然增加了代码复杂度,但保证了可用性。
另外,移动端的requestAnimationFrame在页面不可见时会暂停,这会导致游戏逻辑停止推进。我的处理方式是在visibilitychange事件里记录暂停时间,恢复时补上逻辑更新。但补帧不能无限补,超过一定阈值就直接重置状态,避免长时间暂停后出现异常。
5.4 性能优化的几个实测有效的手段
第一个手段是减少 Shadow DOM 内部的重排。游戏 UI 尽量用 Canvas 绘制,DOM 元素只用于菜单和按钮。第二个手段是复用对象,避免在游戏循环里频繁创建和销毁对象,减少 GC 压力。第三个手段是限制状态同步的频率,不是所有状态都需要每帧同步,比如玩家的昵称、颜色这些不变的状态只需要同步一次。
我实测过一个简单的双人对战游戏,在优化前,中端手机上的帧率大概在 40 到 50 之间波动;优化后,稳定在 55 到 60。差别主要来自对象复用和减少 DOM 操作。
6. 这个方案还能往哪些方向扩展
OmniGame 目前的形态是一个双人 P2P 小游戏骨架,但它的架构留了不少扩展口。第一个方向是多人联机,可以通过网状连接或者选一个主机做中转来实现,但复杂度会上升一个量级。第二个方向是观战模式,把游戏状态额外广播给观战者,观战者不需要发送输入。第三个方向是录像回放,把状态序列按时间戳存下来,回放时按时间轴重放。
我个人最感兴趣的是第三个方向。因为 P2P 游戏的状态是确定性的,只要记录初始状态和输入序列,就能完整复现整局游戏。这个特性可以用来做分享回放,也可以用来做异常复现。我在项目里已经留了一个状态记录的钩子,但还没有做成完整的功能。
最后分享一个我在调试 WebRTC 时常用的小技巧:在 Chrome 地址栏输入chrome://webrtc-internals,能看到当前所有 RTCPeerConnection 的详细状态,包括 ICE candidate 对、数据通道的收发字节数、丢包统计。这个工具比打日志直观得多,排查连接问题时我基本第一时间就打开它。