ARTICLE DETAIL

资讯详情

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

零依赖+WebRTC P2P:纯静态网页小游戏实时对战实战

零依赖+WebRTC P2P:纯静态网页小游戏实时对战实战 1. 为什么我要把零依赖和P2P联机塞进同一个网页小游戏项目先说结论网页小游戏这个赛道绝大多数人卡在同一个地方——联机。单机玩法再精致玩家玩两把就腻一旦能和朋友实时对战留存曲线立刻换一条。但传统联机方案要么需要一台常驻服务器要么依赖第三方实时通信服务前者有成本后者有延迟和配额限制。我这次做的 OmniGame核心目标就一句话让一个纯静态部署的网页小游戏具备真正的点对点实时对战能力同时把前端依赖压到接近于零。这个项目适合谁看如果你正在做 H5 小游戏、想给自己的个人项目加联机功能、或者单纯好奇 WebRTC 的数据通道到底能跑多快那这篇内容基本能覆盖你 80% 的疑问。它不要求你有后端经验但需要你对 JavaScript 和浏览器 API 有基本认知。我先把整个项目的技术骨架摆出来后面再逐层拆层面选型解决的核心问题应用框架Next.js路由、静态导出、开发体验样式方案Tailwind CSS快速构建游戏 UI避免手写大量 CSS组件隔离Shadow DOM游戏画布与宿主页面样式互不污染实时通信WebRTC DataChannel玩家之间直接传数据不经过业务服务器信令交换极简手动信令建立 P2P 连接前的自我介绍环节依赖策略零运行时依赖减小体积、降低供应链风险关键词里提到的 WebRTC、P2P、Shadow DOM、Next.js、Tailwind CSS正好对应上面这张表的每一行。而热搜词里那些关于 P2P 搜索、WebRTC 泄露的讨论其实反映了一个真实痛点大家对 P2P 既好奇又警惕。我在项目里对这两点都做了针对性处理后面会专门讲。需要提前说明的是WebRTC 的 P2P 并不是完全不需要任何中间环节。建立连接的第一步双方必须交换网络描述信息SDP和候选地址ICE Candidate这个交换过程叫信令。信令可以走任何通道——WebSocket、HTTP 轮询、甚至手动复制粘贴。OmniGame 为了做到零后端依赖选择了最朴素的方式手动信令 可选的极简信令服务。这个取舍背后的逻辑我在第 3 节会详细拆。2. 零依赖不是口号Next.js 静态导出与 Tailwind 的配合细节2.1 为什么零依赖值得单独拿出来讲很多人对零依赖有误解以为是指不用任何框架。不是的。OmniGame 里的零依赖指的是运行时零第三方依赖——打包产物里没有 lodash、没有 axios、没有各种 UI 库。框架本身Next.js、React是构建时依赖它们最终会被编译成浏览器能直接跑的代码。这么做的理由很实际。第一网页小游戏的加载速度直接决定玩家会不会留下来每多一个依赖就多一份体积和一次网络请求。第二依赖越少供应链攻击面越小这对一个要长期挂在公网上的项目很重要。第三也是最容易被忽略的一点依赖少了出问题时排查路径短。我踩过一次坑某个 UI 库的版本升级导致游戏画布的事件监听失效排查了整整一个下午最后发现是那个库在全局注册了pointerdown的捕获监听。从那以后我就坚定了能自己写就自己写的原则。2.2 Next.js 静态导出的关键配置Next.js 默认是服务端渲染的但网页小游戏根本不需要 SSR——它就是个纯客户端应用。所以第一步是把next.config.js配成静态导出模式// next.config.js /** type {import(next).NextConfig} */ const nextConfig { output: export, images: { unoptimized: true, }, trailingSlash: true, }; module.exports nextConfig;这里有两个细节值得说。output: export会让next build生成纯静态的out目录可以直接扔到任何静态托管上。images.unoptimized: true是必须的因为 Next.js 的图片优化依赖服务端静态导出模式下用不了不关掉会直接报错。trailingSlash: true则是为了避免某些静态托管对目录路由的处理差异加上它之后/game会生成/game/index.html兼容性最好。注意静态导出模式下getServerSideProps、API Routes、动态路由的fallback全部不可用。如果你打算做房间号路由比如/room/[id]必须用generateStaticParams预生成或者干脆用查询参数?roomxxx来绕开。我选的是后者因为房间号是运行时才确定的预生成没有意义。2.3 Tailwind 在游戏 UI 里的正确打开方式Tailwind CSS 用在游戏界面上和用在普通网页上思路不太一样。普通网页讲究响应式和排版游戏 UI 讲究的是状态驱动的样式切换和绝对定位。我举一个实际例子。游戏里的准备按钮有四种状态未连接、已连接未准备、已准备、房主。用 Tailwind 可以这样组织const btnClass [ px-6 py-3 rounded-lg font-bold transition-all duration-200, !connected bg-gray-600 text-gray-400 cursor-not-allowed, connected !ready bg-blue-500 hover:bg-blue-600 text-white, connected ready bg-green-500 text-white, isHost ring-2 ring-yellow-400, ].filter(Boolean).join( );这种数组 filter(Boolean) join的写法比在模板字符串里塞三元表达式可读性高得多而且 Tailwind 的类名是静态可分析的不会因为动态拼接而丢失样式。这一点很关键——Tailwind 的 JIT 编译器只会打包它能在源码里看到的完整类名如果你写bg-${color}-500那个类名根本不会被生成。还有一个游戏场景特有的技巧用 Tailwind 的aspect-square和max-w-[min(90vw,90vh)]来保证游戏画布在任何屏幕上都保持正方形且不溢出。这比写一堆媒体查询清爽太多。2.4 零依赖带来的一个意外收获把依赖压到最低之后我发现打包产物的体积小得惊人——整个游戏页面含 React 运行时gzip 后不到 90KB。这意味着在一般的网络环境下首屏几乎瞬间可见。对于小游戏来说首屏速度就是留存率这个收益比任何花哨的功能都实在。3. WebRTC P2P 联机的完整链路从信令到数据通道3.1 先搞清楚 P2P 到底P在哪很多人第一次接触 WebRTC 会困惑既然是对等连接为什么还需要服务器答案是服务器只负责介绍认识不负责传话。一旦两个浏览器通过信令交换完信息、建立了直连后续所有游戏数据都走它们之间的直接通道服务器完全不参与。这个介绍认识的过程分两步交换 SDPSession Description Protocol描述各自的媒体能力、编解码器、网络候选等信息。发起方生成 Offer接收方生成 Answer。交换 ICE Candidate双方各自收集自己可能的网络地址本地 IP、公网映射地址等互相告知然后尝试用这些地址建立连接。OmniGame 只用 DataChannel 传游戏状态不用音视频所以 SDP 里不涉及编解码器协商内容相对简单。但 ICE 收集这一步是绕不开的也是整个流程里最容易出问题的地方。3.2 手动信令一个反工程但极其有效的选择标准做法是搭一个 WebSocket 信令服务器。但 OmniGame 的定位是零后端依赖所以我做了一个看起来有点土的决定支持手动信令。具体操作是房主点击创建房间页面生成一段压缩后的 Offer 字符串房主把它复制发给朋友朋友粘贴进输入框生成 Answer 字符串回传房主再粘贴 Answer连接建立。整个过程像极了早期联机游戏用 IP 直连的感觉。你可能会问这也太麻烦了吧确实麻烦但它有三个不可替代的价值零部署成本不需要任何服务器静态托管即可适合个人项目。调试友好信令内容完全可见出问题时能直接看到 SDP 里到底写了什么。理解成本低手动走一遍流程你对 WebRTC 的理解会比调库深得多。当然实际使用中我也提供了可选的极简信令服务——一个不到 100 行的 WebSocket 转发脚本部署在任何支持 WebSocket 的环境上即可。它的逻辑简单到极致收到消息就广播给同房间的其他人不做任何解析。这样既保留了服务器不碰游戏数据的原则又省去了手动复制的麻烦。3.3 建立 DataChannel 的核心代码与参数取舍下面是 OmniGame 里创建数据通道的核心逻辑我加了详细注释// 发起方房主 const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.l.google.com:19302 }, ], }); // 创建数据通道ordered 和 maxRetransmits 是关键参数 const channel pc.createDataChannel(game, { ordered: false, // 游戏状态不需要严格顺序 maxRetransmits: 0, // 不重传宁可丢包也不要延迟 }); channel.onopen () console.log(P2P 通道已建立); channel.onmessage (e) handleRemoteState(JSON.parse(e.data)); // 生成 Offer const offer await pc.createOffer(); await pc.setLocalDescription(offer); // 等待 ICE 收集完成后再发送避免候选地址不全 await waitForIceGathering(pc); sendSignal(JSON.stringify(pc.localDescription));这里最值得讲的是ordered: false和maxRetransmits: 0这两个参数。默认情况下DataChannel 是可靠有序的类似 TCP。但对于实时对战游戏最新的状态永远比完整的历史状态重要。如果一帧数据丢了重传它反而会造成后续数据排队等待产生延迟累积。所以我把通道配成不可靠、不重传模式类似 UDP 的语义。代价是接收方可能收到乱序或丢失的数据包。解决办法是在每条消息里带上时间戳或帧号接收方只处理比当前更新的状态。这个逻辑我在第 4 节会展开。3.4 ICE 收集为什么等一等很重要上面代码里的waitForIceGathering是个容易被忽略的细节。setLocalDescription之后浏览器会异步收集 ICE 候选地址这个过程可能持续几百毫秒到几秒。如果你在收集完成前就把 SDP 发出去对方可能拿不到完整的候选地址导致连接失败或只能走较慢的中继路径。我的做法是监听icegatheringstatechange等到状态变成complete再发送同时设一个 3 秒的超时兜底function waitForIceGathering(pc, timeout 3000) { return new Promise((resolve) { if (pc.iceGatheringState complete) return resolve(); const timer setTimeout(resolve, timeout); pc.addEventListener(icegatheringstatechange, () { if (pc.iceGatheringState complete) { clearTimeout(timer); resolve(); } }); }); }提示STUN 服务器只用于发现公网地址不转发数据。如果你的玩家处于对称型 NAT 后面可能还需要 TURN 服务器做中继。但 TURN 会消耗带宽属于有成本的方案。OmniGame 默认只配 STUN因为小游戏的数据量极小且大部分家庭网络环境能直连成功。如果你的用户群体网络环境复杂建议自建 TURN。4. 游戏状态同步在不可靠通道上做可靠体验4.1 状态同步 vs 指令同步我为什么选前者联机游戏同步有两大流派状态同步发送现在世界是什么样和指令同步发送玩家按了什么键。前者简单直接后者省流量但需要确定性模拟。OmniGame 选的是状态同步原因很现实小游戏的实体数量少通常就几个玩家角色状态数据量小直接发全量状态最省心。指令同步需要保证两端物理模拟完全一致浮点数误差、帧率差异都会导致漂移调试成本极高。对于个人项目简单可靠比极致优化重要得多。具体做法是每个客户端以固定频率我设的是 20Hz即每 50ms 一次把自己的玩家状态打包发送包含位置、速度、朝向、动作状态。接收方收到后更新对应玩家的插值目标。4.2 丢包和乱序的处理帧号 插值因为通道配成了不可靠模式丢包是常态。我的处理策略分两层第一层帧号过滤。每条消息带一个自增帧号接收方维护每个玩家的最新帧号只处理比它大的消息。这样乱序到达的旧数据会被直接丢弃。function handleRemoteState(data) { const { playerId, frame, x, y, vx, vy } data; const last lastFrameMap.get(playerId) ?? -1; if (frame last) return; // 旧数据丢弃 lastFrameMap.set(playerId, frame); remoteStates.set(playerId, { x, y, vx, vy, timestamp: performance.now() }); }第二层渲染插值。如果直接把收到的位置赋给角色画面会一跳一跳的。我在渲染循环里做线性插值让角色平滑地追向目标位置function render(dt) { for (const [id, target] of remoteStates) { const current renderPositions.get(id) ?? { x: target.x, y: target.y }; const lerpFactor 1 - Math.pow(0.001, dt); // 帧率无关的插值系数 current.x (target.x - current.x) * lerpFactor; current.y (target.y - current.y) * lerpFactor; renderPositions.set(id, current); drawPlayer(id, current.x, current.y); } }这里的1 - Math.pow(0.001, dt)是个小技巧。它让插值速度与帧率无关——无论你是 60fps 还是 144fps角色追上目标位置所需的时间是一致的。如果写成固定的0.2高帧率设备上角色会追得飞快低帧率设备上又慢吞吞。4.3 本地预测让操作零延迟如果玩家按下方向键后要等自己的状态发出去、再等服务器这里是对方确认才移动手感会非常糟糕。解决办法是本地预测本地输入立即生效同时把状态发给对方。如果后续收到对方的状态与本地预测冲突再做平滑修正。OmniGame 里因为每个客户端只权威地控制自己的角色冲突很少发生所以本地预测实现得很简单本地角色直接响应输入不做任何等待。远端角色才走插值。这个各管各的模型是 P2P 对战游戏最省心的架构。4.4 实测数据延迟到底能压到多少我在同一城市的不同网络环境下做了几组测试数据如下网络场景平均往返延迟丢包率体验评价同一局域网2-5ms接近 0几乎无感同城不同运营商15-30ms 1%流畅跨省40-80ms1-3%可玩快速动作略有延迟感跨运营商高峰时段80-150ms3-8%休闲玩法可接受这个数据说明一个事实P2P 直连的延迟通常优于经过中心服务器的转发因为路径更短。但它的稳定性受双方网络质量影响任何一方网络抖动都会直接体现在体验上。这也是为什么我在游戏设计上倾向于休闲、非竞技的玩法——它对延迟的容忍度更高。5. Shadow DOM 隔离让游戏组件能嵌入任何页面5.1 为什么游戏画布需要独立王国网页小游戏经常需要嵌入到别人的页面里或者一个页面里同时存在多个游戏实例。这时候样式冲突就是灾难宿主页面的全局 CSS 可能把游戏里的按钮改成奇怪的样式游戏里的样式也可能污染宿主页面。Shadow DOM 解决的正是这个问题。它给元素创建一个独立的 DOM 子树子树内的样式默认不影响外部外部的样式也进不来除了继承属性。这就像给游戏组件盖了一间隔音房。5.2 在 React 里挂载 Shadow DOM 的实操React 本身不直接支持 Shadow DOM需要手动挂载。核心思路是创建一个宿主元素给它附加 shadow root然后在 shadow root 里渲染 React 树。import { useRef, useEffect, useState } from react; import { createRoot } from react-dom/client; function ShadowHost({ children }) { const hostRef useRef(null); const [shadowRoot, setShadowRoot] useState(null); useEffect(() { if (!hostRef.current) return; const root hostRef.current.shadowRoot || hostRef.current.attachShadow({ mode: open }); setShadowRoot(root); }, []); useEffect(() { if (!shadowRoot) return; const container document.createElement(div); shadowRoot.appendChild(container); const root createRoot(container); root.render(children); return () { root.unmount(); shadowRoot.removeChild(container); }; }, [shadowRoot, children]); return div ref{hostRef} /; }这段代码有两个坑我踩过。第一attachShadow只能调用一次重复调用会抛错所以要先检查shadowRoot是否存在。第二React 的createRoot和unmount必须成对出现否则在组件卸载时会内存泄漏。我在开发阶段用 React 的 StrictMode 时因为双调用机制一度出现了两个 root 同时渲染的诡异现象排查了很久才定位到是清理逻辑没写对。5.3 Tailwind 样式如何注入 Shadow DOM这是最容易被卡住的地方。Tailwind 生成的样式默认注入到document.head而 Shadow DOM 里的元素看不到外部样式表。解决办法是把 Tailwind 的产物作为字符串注入到 shadow root 内部。我的做法是在构建时把编译好的 CSS 读成字符串运行时创建一个style标签塞进 shadow rootimport gameStyles from ./game.css?inline; // Vite 的 inline 导入 function injectStyles(shadowRoot) { const style document.createElement(style); style.textContent gameStyles; shadowRoot.appendChild(style); }如果你用的是 Next.js?inline这种导入方式需要额外配置。更通用的做法是把 CSS 文件放在public目录运行时fetch回来再注入。虽然多一次请求但可以配合缓存实际影响很小。注意Shadow DOM 里的:root选择器不生效Tailwind 的一些依赖 CSS 变量的功能比如暗色模式需要改用:host或者直接在 shadow root 上定义变量。我在项目里干脆放弃了暗色模式切换用固定的配色方案省去了这层麻烦。5.4 事件穿透与焦点管理的细节Shadow DOM 有个特性内部元素触发的事件在外部监听时event.target会被重定向为宿主元素。这对游戏来说通常是好事外部不需要知道内部细节但如果你需要精确的事件坐标就得用event.composedPath()[0]来获取真实目标。另外键盘事件在 Shadow DOM 里需要特别注意焦点。如果 shadow root 内的元素没有获得焦点键盘事件会落到document上。我的处理是给游戏容器加tabindex0并在点击时主动focus()确保键盘输入能被正确捕获。6. 关于 WebRTC 泄露与 P2P 搜索的那些热搜话题6.1 WebRTC 泄露到底泄露了什么热搜里频繁出现WebRTC 泄露说的其实是这么一回事WebRTC 在建立连接时需要收集本机的网络地址包括内网 IP这些信息在 ICE 候选里是明文可见的。如果网页在用户不知情的情况下发起 WebRTC 连接理论上可以通过候选地址推断出用户的一些网络信息。需要澄清的是这不是 WebRTC 的漏洞而是它的设计使然。任何需要建立直连的技术都必须知道对方的地址。现代浏览器已经做了缓解——比如对本地 IP 做 mDNS 混淆用随机主机名代替真实内网 IP。而且只有在页面主动创建RTCPeerConnection时才会触发地址收集普通网页不会无缘无故这么做。OmniGame 作为游戏项目对这个问题的态度是透明。游戏在建立连接前会明确提示即将与其他玩家建立直连并且只在用户主动点击开始对战后才创建连接对象。用户始终知道连接在发生。6.2 P2P 搜索类工具为什么突然火了热搜里那些P2P 搜索相关的词反映的是另一类需求去中心化的资源发现。传统搜索依赖中心索引服务器而 P2P 搜索试图让节点之间互相查询、共享索引。这个思路和 OmniGame 的 P2P 联机在底层理念上是相通的——都是减少对中心节点的依赖。但我要泼一盆冷水P2P 不等于更快或更安全。它的优势在于抗单点故障和降低中心成本劣势在于发现效率低、连接建立慢、难以做全局排序。对于小游戏联机这种已知对手、只需直连的场景P2P 是完美匹配但对于在海量资源里找我要的东西中心化索引在效率上仍然碾压 P2P。技术选型要看场景不要被概念带跑。6.3 我在项目里做的安全边界处理虽然 OmniGame 只是个游戏但涉及 P2P 就必须考虑安全边界。我做了三件事第一数据通道只传游戏状态不传任何文件、不执行任何远程代码。消息格式是固定的 JSON 结构接收方严格校验字段类型非法数据直接丢弃。第二信令内容做长度限制。手动信令模式下用户粘贴的字符串如果超长直接拒绝。这能防止有人用超大 SDP 做内存消耗攻击。第三连接建立后校验对端身份。虽然 WebRTC 本身有 DTLS 加密但我在应用层加了一个简单的握手双方交换一个房间内约定的随机 token不匹配就断开。这能防止连接被意外串到别的房间。7. 从开发到部署几个让我少走弯路的实践7.1 本地调试 P2P 的笨办法调试 P2P 最头疼的是你需要在两个不同的浏览器环境里跑同一个页面。我的做法是开两个浏览器窗口一个用普通模式一个用隐私模式避免共享 localStorage 和缓存然后手动走信令流程。虽然笨但能真实复现用户的操作路径。如果想更高效可以用两个不同的浏览器比如一个 Chrome 一个 Firefox这样还能顺便测试跨浏览器兼容性。我实测下来Chrome 和 Firefox 之间的 DataChannel 互通没有问题但 Safari 在 ICE 收集速度上明显偏慢需要把超时时间调长一些。7.2 静态部署的注意事项因为用了output: export部署就是上传out目录的事。但有两个细节一是HTTPS 是必须的。WebRTC 的getUserMedia和部分 API 在非安全上下文下不可用虽然纯 DataChannel 在 HTTP 下也能工作但为了兼容性和未来扩展强烈建议上 HTTPS。大部分静态托管默认提供 HTTPS这点不用太操心。二是缓存策略。游戏的主 JS 包体积小可以设长缓存但index.html要设短缓存或不缓存否则用户会一直拿到旧版本。我在托管配置里给 HTML 设了no-cache给带哈希的静态资源设了max-age31536000。7.3 我踩过的最大的一个坑最后分享一个让我印象最深的坑。项目早期我在 DataChannel 的onmessage里直接做游戏逻辑更新结果发现高频率消息20Hz下页面偶尔会卡顿。排查后发现原因是每条消息都触发了一次 React 状态更新而 React 的批处理在异步回调里不生效导致 20 次/秒的渲染。解决办法是把网络消息先写入一个普通的可变对象ref然后在requestAnimationFrame的渲染循环里统一读取并更新画面。这样网络层和渲染层解耦无论消息多频繁渲染始终是每帧一次。这个改动之后CPU 占用直接降了一半。这个经验其实很通用任何高频的外部事件网络消息、传感器数据、鼠标移动都不应该直接驱动 UI 更新而应该先落到一个缓冲区由渲染循环统一消费。这是做实时应用的一条铁律早懂早省事。
返回列表