ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

零依赖+P2P架构:用WebRTC和Shadow DOM打造轻量网页小游戏

零依赖+P2P架构:用WebRTC和Shadow DOM打造轻量网页小游戏 1. 为什么我要把网页小游戏做成“零依赖 P2P”这套架构网页小游戏这个领域表面上看已经被各种引擎和框架填满了随便打开一个在线游戏平台背后不是 Phaser 就是 Cocos再不然就是 PixiJS 套一层。但我自己做了几年小游戏之后越来越觉得这套“重框架”路线对某些场景是过度设计。你只是想做一个双人对战的井字棋、一个实时协作的涂鸦板、或者一个轻量的卡牌对战结果引入的引擎体积比游戏本身还大加载时间全耗在解析框架代码上了。OmniGame 这个项目就是从这个痛点出发的。它的核心目标很明确用最少的运行时依赖把网页小游戏的加载速度、联机能力和嵌入体验同时拉到工程上限。具体来说它做了三件在传统方案里很少同时出现的事第一游戏逻辑层做到零第三方运行时依赖纯原生 API 驱动第二联机层用 WebRTC 的 P2P 数据通道不经过中心服务器转发游戏数据第三嵌入层用 Shadow DOM 做样式隔离让游戏能像 Web Component 一样被任意宿主页面引用而不打架。这套组合解决的是什么问题简单说就是“小游戏不小”的尴尬。传统方案里一个小游戏要联机就得搭 WebSocket 服务器要嵌入就得担心 CSS 污染要快就得忍受框架体积。OmniGame 的思路是把这三件事分别用浏览器原生能力解决联机用 WebRTC DataChannel嵌入用 Shadow DOM速度靠零依赖。适合谁来参考我觉得是那些已经写过一点前端、想深入理解浏览器底层能力、又不想被框架绑架的开发者。哪怕你只是想搞清楚 WebRTC 的 P2P 到底怎么落地这篇内容也能给你一条能跑通的路径。需要先说明一点标题里提到的 Next.js 在这个架构里扮演的是“宿主与信令层”的角色不是游戏运行时的一部分。游戏运行时是纯原生的Next.js 负责页面托管、房间信令交换和静态资源分发。这个边界一定要划清楚否则很容易把框架的复杂度又带回游戏核心里。2. 整体架构设计与技术选型背后的取舍逻辑2.1 三层解耦运行时、联机层、宿主层各管各的OmniGame 的架构我把它拆成三层每层职责单一层与层之间通过明确的接口通信。这种解耦不是为了好看而是为了每一层都能独立替换和测试。运行时层Runtime是游戏的核心包含游戏循环、状态管理、渲染和输入处理。这一层完全不依赖任何第三方库用的是requestAnimationFrame做循环、Canvas 2D或WebGL做渲染、原生事件监听做输入。状态管理我用的是一种极简的“单一状态树 差异广播”模式没有引入 Redux 那套东西因为小游戏的状态复杂度根本用不上。联机层Networking负责玩家之间的数据同步核心是 WebRTC 的RTCDataChannel。它不关心游戏逻辑只负责把运行时产生的状态差异可靠或不可靠地传给对端。这里有个关键设计联机层对运行时暴露的接口只有send(diff)和onMessage(callback)两个方法运行时完全不知道底层是 P2P 还是别的什么。宿主层Host是游戏被嵌入的环境用 Next.js 搭建。它负责三件事提供页面路由、充当 WebRTC 信令的中转站、通过 Shadow DOM 把游戏挂载到宿主页面里。宿主层和运行时层之间通过postMessage或自定义事件通信物理隔离。为什么要这么分因为我踩过一个坑早期我把信令逻辑和游戏逻辑写在一起结果想换一个信令方案时整个游戏代码都要动。解耦之后信令层换成任何方案游戏核心一行都不用改。2.2 为什么是 WebRTC P2P 而不是 WebSocket 中转这是整个项目最核心的选型决策我详细说说背后的计算和考量。WebSocket 方案下两个玩家对战的数据流是玩家 A → 服务器 → 玩家 B。假设服务器部署在离两个玩家都适中的区域单程延迟大概 30 到 80 毫秒往返就是 60 到 160 毫秒。对于实时对战游戏这个延迟在操作反馈上已经能感觉到迟滞了。而且服务器要承担所有房间的数据转发玩家越多带宽和并发压力越大成本线性上升。WebRTC P2P 方案下数据流是玩家 A → 玩家 B直连。理想情况下延迟就是两个玩家之间的物理网络延迟同城可能只有 5 到 20 毫秒。更重要的是服务器只负责最初的信令交换交换 SDP 和 ICE 候选一旦连接建立服务器就完全不参与数据传输了。这意味着服务器成本与游戏数据量脱钩只与房间建立次数相关。但 P2P 不是没有代价的。最大的问题是 NAT 穿透。两个玩家如果都在复杂的 NAT 后面直连可能建立不起来这时候需要 STUN 服务器帮忙发现公网地址极端情况下还需要 TURN 服务器做中继。我的处理策略是优先尝试直连失败后降级到 TURN 中继再失败才提示用户网络环境不支持。这个降级链路后面会详细讲。还有一个容易被忽略的点WebRTC 的 DataChannel 支持两种模式可靠有序类似 TCP和不可靠无序类似 UDP。对于游戏状态同步我大部分时候用的是不可靠模式因为丢一帧旧状态没关系下一帧新状态会覆盖它追求的是低延迟而不是完整性。只有关键操作比如“游戏结束”“确认出牌”才用可靠模式。2.3 Shadow DOM 做嵌入隔离的真实收益把游戏嵌入到别人的页面里最大的噩梦是样式冲突。你的游戏 CSS 里写了个.card { display: flex }结果宿主页面也有个.card两边互相覆盖页面直接崩。传统解法是给所有类名加前缀比如omni-card但这治标不治本而且写起来很烦。Shadow DOM 提供的是真正的样式隔离。游戏挂载在一个 shadow root 里里面的样式出不去外面的样式进不来除了继承的字体、颜色等可继承属性。我用的是attachShadow({ mode: open })这样调试的时候还能通过element.shadowRoot访问生产环境如果想更封闭可以改成closed。实测下来的收益很明显同一个游戏组件可以同时嵌入到三个风格完全不同的页面里样式互不干扰。而且因为 Shadow DOM 是浏览器原生能力没有任何运行时开销比 CSS-in-JS 那套方案轻太多了。唯一要注意的是事件冒泡shadow root 内的事件默认不会冒泡到宿主需要composed: true才能穿透这个后面实操部分会讲。2.4 Next.js 在架构里的准确定位很多人看到 Next.js 会以为它是游戏框架其实不是。在这个项目里Next.js 只做三件事第一用它的路由系统托管游戏页面和房间页面第二用 API Routes 实现信令交换的接口第三用它的静态资源优化能力分发游戏运行时的 JS 文件。我选 Next.js 而不是纯静态 HTML主要是看中它的 API Routes 能让我在一个项目里同时写前端和信令后端不用单独起一个服务。信令接口很简单就是房间的创建、加入和消息转发用内存存储就够了不需要数据库。如果要做大规模可以把信令层单独抽出来用别的方案但游戏运行时完全不受影响这就是分层的好处。3. 核心细节拆解零依赖运行时到底怎么写3.1 游戏循环requestAnimationFrame 的正确用法游戏循环是所有实时游戏的骨架。我见过太多人用setInterval做循环然后被它的时间漂移坑得死去活来。setInterval(fn, 16)名义上是 60 帧实际执行间隔会因为主线程繁忙而抖动导致游戏速度忽快忽慢。正确的做法是用requestAnimationFrame它由浏览器在每次重绘前调用天然与显示刷新率同步。但直接用还不够因为不同设备的刷新率不一样60Hz 和 120Hz 设备上如果每帧都按固定步长更新游戏速度会差一倍。所以要做固定时间步长 插值渲染。我的实现是这样的逻辑更新用固定的时间步长比如 1/60 秒累加器记录距离上次更新过了多少时间够一个步长就更新一次逻辑可能一帧更新多次也可能不更新。渲染则用两次逻辑状态之间的插值保证画面平滑。这样无论设备刷新率多少游戏逻辑速度都是一致的。const STEP 1 / 60; let accumulator 0; let lastTime performance.now(); function loop(now) { const delta Math.min((now - lastTime) / 1000, 0.25); lastTime now; accumulator delta; while (accumulator STEP) { update(STEP); accumulator - STEP; } const alpha accumulator / STEP; render(alpha); requestAnimationFrame(loop); } requestAnimationFrame(loop);这里有个细节delta我做了 0.25 秒的上限截断。为什么因为如果用户切到别的标签页再切回来delta可能是几十秒累加器会疯狂执行几千次更新直接卡死。截断之后最多补 15 帧游戏会“快进”一下但不会崩。3.2 状态管理与差异广播小游戏的状态管理不需要 Redux 那套 action/reducer 的仪式感。我用的是一个极简模式一个普通对象存状态每次修改后标记脏字段循环结束时把脏字段打包成差异包广播出去。const state { players: {}, entities: [], score: 0 }; const dirty new Set(); function set(path, value) { // 省略路径解析实际用 lodash.set 类似的逻辑 state[path] value; dirty.add(path); } function flushDiff() { if (dirty.size 0) return null; const diff {}; for (const key of dirty) diff[key] state[key]; dirty.clear(); return diff; }这个设计的精髓在于只传变化的部分。如果每帧都把整个状态序列化发过去数据量会大得离谱。差异广播把每帧的数据量压到最小通常只有几十字节。对端收到差异包后用同样的路径解析逻辑合并到本地状态然后渲染。注意差异广播要求两端的状态结构完全一致否则路径解析会出错。我的做法是在连接建立时先同步一次完整状态作为基准之后才走差异。3.3 WebRTC DataChannel 的建立流程WebRTC 连接的建立是整个项目里最复杂的一环我把它拆成清晰的步骤。第一步是创建RTCPeerConnection配置 ICE 服务器。STUN 服务器用公开的就行TURN 服务器如果要做生产环境得自己搭或者用商业服务。配置大概长这样const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.example.com:3478 }, { urls: turn:turn.example.com:3478, username: user, credential: pass } ] });第二步是创建 DataChannel。发起方调用pc.createDataChannel(game)接收方监听pc.ondatachannel事件。DataChannel 的配置里ordered和maxRetransmits决定了可靠性模式游戏状态同步用{ ordered: false, maxRetransmits: 0 }追求最低延迟。第三步是交换 SDP。发起方createOffer生成 offer通过信令服务器发给接收方接收方setRemoteDescription后createAnswer生成 answer 发回去。这一步就是所谓的“信令交换”必须经过服务器因为此时两端还没有直连通道。第四步是 ICE 候选交换。两端在onicecandidate事件里把候选地址通过信令服务器互发浏览器会自动尝试各种路径建立连接。全部交换完成后onconnectionstatechange会变成connectedDataChannel 的onopen触发就可以开始发数据了。整个流程听起来步骤多但实际代码量不大关键是信令服务器的转发逻辑要写对不能丢消息也不能乱序。3.4 Shadow DOM 挂载与事件穿透把游戏挂到 Shadow DOM 里的代码很直接class OmniGame extends HTMLElement { connectedCallback() { const shadow this.attachShadow({ mode: open }); const canvas document.createElement(canvas); const style document.createElement(style); style.textContent :host { display: block; } canvas { width: 100%; }; shadow.appendChild(style); shadow.appendChild(canvas); this.game new GameRuntime(canvas); } } customElements.define(omni-game, OmniGame);这里的关键是:host选择器它指向自定义元素本身用来控制宿主元素的样式。shadow root 内部的样式完全隔离不会泄漏到外面。事件穿透是个坑。shadow root 内元素触发的事件默认在 shadow 边界就停了不会冒泡到宿主文档。如果宿主想监听游戏里的点击需要在事件上设置composed: true。但更推荐的做法是通过自定义事件显式通信比如游戏内部触发this.dispatchEvent(new CustomEvent(score-change, { detail: score, bubbles: true, composed: true }))宿主监听这个事件。这样接口清晰不依赖事件穿透的隐式行为。4. 实操过程从零搭一个可联机的双人对战 Demo4.1 项目初始化与目录结构我用 Next.js 的 App Router 起项目目录结构这样组织omni-game/ ├── app/ │ ├── page.tsx # 首页创建/加入房间 │ ├── room/[id]/page.tsx # 房间页挂载游戏 │ └── api/signal/route.ts # 信令接口 ├── runtime/ │ ├── loop.js # 游戏循环 │ ├── state.js # 状态管理 │ └── net.js # 联机层 └── components/ └── OmniGame.js # Web Component 封装runtime目录下的文件是纯原生 JS不经过任何打包器的转换Next.js 会处理但代码本身零依赖。components里的 Web Component 负责把 runtime 挂到 Shadow DOM 里。初始化命令就是标准的npx create-next-applatest选 TypeScript 和 App Router。然后手动建上面这些目录。不需要装任何游戏相关的依赖这是这个项目的核心卖点。4.2 信令接口的实现信令接口用 Next.js 的 Route Handler 实现逻辑是房间的创建和消息转发。我用内存 Map 存房间每个房间存两个玩家的连接信息和待转发的消息队列。// app/api/signal/route.ts const rooms new Map(); export async function POST(req) { const { roomId, playerId, type, payload } await req.json(); if (type join) { if (!rooms.has(roomId)) rooms.set(roomId, { players: [], messages: [] }); const room rooms.get(roomId); room.players.push(playerId); return Response.json({ ok: true, peerCount: room.players.length }); } if (type send) { const room rooms.get(roomId); room.messages.push({ from: playerId, payload }); return Response.json({ ok: true }); } if (type poll) { const room rooms.get(roomId); const msgs room.messages.filter(m m.from ! playerId); room.messages room.messages.filter(m m.from playerId); return Response.json({ messages: msgs }); } }这个实现很粗糙用轮询拉消息生产环境应该换成 SSE 或 WebSocket。但作为 Demo 足够跑通流程而且它清楚地展示了信令层的职责边界只转发不处理游戏逻辑。提示轮询间隔建议 500 毫秒太短浪费请求太长连接建立慢。信令只在连接建立阶段用连上之后就不需要了。4.3 连接建立与状态同步的完整时序我把整个连接建立过程按时间顺序理一遍这是最容易出错的地方。玩家 A 进入房间页调用join接口拿到peerCount为 1等待。玩家 B 进入同一房间join后peerCount为 2。玩家 A 轮询到 B 加入的消息作为发起方创建RTCPeerConnection和 DataChannelcreateOffer后把 offer 通过send接口发出。玩家 B 轮询到 offer创建RTCPeerConnectionsetRemoteDescriptioncreateAnswer把 answer 发回。双方交换 ICE 候选连接建立onopen触发。发起方A发送一次完整状态作为基准。之后双方每帧发送差异包收到后合并渲染。这里有个细节谁做发起方我的规则是先进入房间的做发起方。因为后进入的玩家需要先收到 offer 才能创建连接如果反过来先进入的玩家不知道什么时候该等 offer。用peerCount判断从 1 变 2 的那一刻原本在房间里的玩家就是发起方。4.4 参数计算DataChannel 缓冲与背压处理DataChannel 有个bufferedAmount属性表示还没发出去的数据字节数。如果发得太快缓冲区会堆积延迟飙升。我设了一个阈值超过 64KB 就跳过本帧的状态发送等缓冲区降下来再发。function sendDiff(diff) { if (channel.bufferedAmount 65536) return; // 背压跳过本帧 channel.send(JSON.stringify(diff)); }为什么是 64KB这是实测出来的经验值。太小会导致频繁跳帧太大则延迟累积明显。64KB 在普通网络下大约对应几十毫秒的缓冲是延迟和流畅度的平衡点。当然这个值可以根据游戏类型调整快节奏动作游戏可以调到 16KB回合制游戏可以放宽到 256KB。还有一个优化差异包用 JSON 序列化其实有开销如果追求极致可以用二进制协议比如把状态字段映射成固定偏移量的 ArrayBuffer。但 JSON 的可读性和调试便利性对小项目更重要我建议先用 JSON性能瓶颈出现了再换。5. 常见问题与排查技巧实录5.1 连接建立失败的各种情况P2P 连接建立失败是最常见的问题原因五花八门。我整理了一个排查表按出现频率排序。现象可能原因排查方法解决方案一直停在 connectingICE 候选没交换完打印双方 onicecandidate 事件检查信令转发是否丢消息连接建立后立即断开双方网络环境不兼容看 oniceconnectionstatechange配置 TURN 服务器降级只有一方能收到数据DataChannel 单向建立检查 ondatachannel 是否触发确认双方都监听了事件延迟忽高忽低走了 TURN 中继看 candidate 类型是 relay优化网络或接受中继延迟移动网络下必失败运营商 NAT 严格换 WiFi 测试对比必须配 TURN我踩过最深的一个坑是信令消息用轮询拉取时如果两条消息在同一轮询周期内到达处理顺序可能颠倒导致setRemoteDescription在createOffer之前执行直接报错。解决办法是给每条信令消息加序号接收方按序号排序后再处理。5.2 Shadow DOM 里的样式与事件陷阱Shadow DOM 虽然隔离性好但有几个反直觉的地方。第一document.querySelector查不到 shadow root 里的元素必须通过宿主元素的shadowRoot属性访问。第二CSS 的继承属性如font-family、color会穿透 shadow 边界如果宿主页面设了奇怪的字体游戏里也会受影响需要在:host里显式重置。第三focus和blur事件在 shadow 边界的行为比较特殊表单元素要小心处理。还有一个性能相关的点shadow root 里的元素如果频繁增删会触发样式重算。我的做法是游戏渲染全部走 Canvasshadow root 里只有一个 canvas 元素几乎不涉及 DOM 操作这样就绕开了大部分 Shadow DOM 的性能问题。5.3 零依赖带来的“重复造轮子”问题零依赖不是没有代价的。没有工具库很多基础功能要自己写比如深拷贝、路径解析、事件总线。我的原则是只写游戏真正用到的部分不追求通用性。比如路径解析我只支持a.b.c这种简单路径不支持数组索引和复杂表达式代码量从几百行降到几十行。另一个问题是调试。没有框架的开发者工具出错了只能靠console.log和断点。我的应对是给运行时加一个可选的调试模式开启后每帧打印状态摘要和网络统计用localStorage的一个标志控制开关。这个调试面板后来成了我排查问题的主要工具比任何框架的 DevTools 都直接。5.4 移动端适配的坑移动端浏览器对 WebRTC 的支持参差不齐尤其是国内某些定制浏览器。我遇到过的典型问题DataChannel 在后台标签页会被节流甚至暂停导致游戏卡住。解决办法是监听visibilitychange事件页面隐藏时暂停游戏循环并通知对端恢复时重新同步状态。还有触摸事件的处理。移动端的touchstart有 300 毫秒延迟为了区分双击缩放游戏操作会感觉迟钝。解决办法是在 canvas 上加touch-action: none的 CSS并用pointerdown事件代替touchstart现代浏览器对 pointer 事件的支持已经很好延迟问题基本消失。6. 这套架构还能往哪些方向扩展OmniGame 目前的形态是一个双人对战的 Demo但它的分层设计留了很多扩展口。联机层如果要做多人可以把 P2P 的网状结构改成星型由一个玩家做主机转发或者引入一个轻量的 SFU 做选择性转发。运行时层如果要支持更复杂的游戏可以引入实体组件系统ECS但要注意别把零依赖的初衷丢了ECS 也可以手写得很轻。宿主层这边Next.js 的信令接口可以换成 Serverless 函数配合 Redis 做房间状态存储就能水平扩展。Shadow DOM 的封装可以进一步做成标准的 Web Component 发布到 npm让任何人都能一行标签引入游戏。我个人最看好的扩展方向是把游戏运行时做成可序列化的状态机。如果整个游戏状态能被完整序列化那就可以实现录像回放、断线重连、甚至服务器端校验。这个能力在竞技类小游戏里价值很大而零依赖的架构恰好让状态变得可控没有框架的隐藏状态干扰。最后分享一个我在实际项目里验证过的小技巧差异广播的字段名可以用短字符串代替比如players写成pentities写成e。在每帧都发数据的情况下这个改动能把带宽降低 30% 以上。代价是可读性变差所以我在开发环境用长名生产构建时用工具自动替换成短名两边都不耽误。
返回列表