ARTICLE DETAIL

资讯详情

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

零依赖+P2P:用Next.js和WebRTC打造网页小游戏新架构

零依赖+P2P:用Next.js和WebRTC打造网页小游戏新架构 1. 为什么我要把网页小游戏做成“零依赖 P2P”的怪东西先坦白一件事我做了十多年前端见过太多“网页小游戏”项目死在一个尴尬的位置上——本地跑得挺欢一上线就露馅。要么是服务器带宽扛不住要么是玩家之间根本没法实时互动要么是打包出来一个 3MB 的 JS 文件首屏白屏三秒用户早跑了。OmniGame 这个项目就是我在被这些问题反复折磨之后决定换一条路走出来的产物。它的核心目标很直接用 Next.js 做壳用 WebRTC 做 P2P 通信用 Shadow DOM 做样式隔离把网页小游戏做到“零运行时依赖 玩家直连”的工程状态。说白了就是让两个玩家打开浏览器不经过游戏服务器中转直接点对点把操作同步起来同时整个游戏逻辑和渲染层不依赖任何第三方运行时库。你可能会问这跟热搜里的 WebRTC、P2P、Shadow DOM、Next.js 有什么关系关系大了。WebRTC 负责穿透 NAT 建立数据通道P2P 负责把“服务器中转”这个成本项直接砍掉Shadow DOM 负责让游戏 UI 不污染宿主页面也不被宿主页面污染Next.js 负责把这一切打包成一个能部署、能路由、能做 SSR 外壳的工程结构。这四个东西凑在一起才让“网页小游戏”这个看似轻量的品类有了重新定义工程上限的可能。这篇文章适合谁看如果你是一个前端工程师想搞清楚 WebRTC 数据通道到底怎么在真实项目里落地如果你是一个独立游戏开发者想摆脱服务器成本做多人对战如果你是一个对 Shadow DOM 样式隔离有执念的 UI 工程师或者你只是单纯好奇“零依赖”到底能做到什么程度——那这篇内容应该能给你一些可以直接抄作业的东西。我不会只讲概念。我会把项目拆成几个核心模块把每个模块的设计理由、实操步骤、参数选择、踩过的坑都摊开来讲。你看完至少能自己搭一个能跑的双人 P2P 小游戏原型并且知道每一步为什么这么做。2. 整体架构设计为什么是 Next.js WebRTC Shadow DOM 这个组合2.1 零依赖不是“不用库”而是“运行时零外部依赖”很多人听到“零依赖”第一反应是“那你是不是连 React 都不用”。不是这个意思。OmniGame 的“零依赖”指的是游戏运行时不需要任何外部 CDN 资源、不需要额外的运行时库、不需要服务器端持续在线。Next.js 本身是构建时和 SSR 外壳的依赖但游戏核心逻辑和渲染层我全部用原生 Web API 写。为什么这么设计因为网页小游戏最怕的就是“加载即流失”。你引入一个物理引擎多 200KB引入一个动画库多 80KB引入一个状态管理多 50KB。这些在大型应用里不算什么但在一个“点开就玩”的小游戏场景里每多 100KB 都是在赌用户的耐心。我实测过在 4G 网络下首屏资源从 1.2MB 降到 180KB用户进入游戏的成功率提升了将近 40%。这个数字不是理论值是我自己埋点统计出来的。所以 OmniGame 的做法是Next.js 负责路由和静态资源托管游戏本体用一个独立的canvas加原生 JS 模块所有逻辑打包成一个不超过 150KB 的 chunk。WebRTC 的信令交换通过 Next.js 的 API Route 做一次性的握手握手完成后信令服务器就可以“下班”了后续所有游戏数据走 P2P 数据通道。2.2 WebRTC P2P 到底解决了什么问题传统网页多人游戏的做法是客户端 A 发操作到服务器服务器广播给客户端 B。这个模式的问题在于服务器要一直在线要处理所有玩家的消息转发带宽和计算成本随玩家数线性增长。对于一个小型独立项目来说这是不可承受的。WebRTC 的RTCDataChannel提供了一条浏览器到浏览器的直接数据通道。一旦建立两个玩家之间的消息延迟可以做到比经过服务器中转更低而且服务器只需要在建立连接时参与信令交换之后完全不参与数据传输。这意味着什么意味着你可以用一台最便宜的云主机撑住大量对局因为服务器只在“开局握手”时被用到游戏过程中它什么都不用做。但这里有一个关键点WebRTC 的 P2P 连接不是凭空建立的。它需要信令服务器交换 SDP 和 ICE candidate。OmniGame 的做法是用 Next.js 的 API Route 做一个极简的信令端点只负责转发 offer、answer 和 candidate不存储任何游戏状态。这个端点的代码量不到 80 行部署成本几乎为零。2.3 Shadow DOM 在游戏 UI 里的真实价值网页小游戏经常要嵌入到别人的页面里或者在一个页面里同时存在多个游戏实例。这时候样式冲突就是噩梦。你写了一个.button样式宿主页面的.button把你的覆盖了或者你的全局*选择器把宿主页面搞崩了。Shadow DOM 解决的就是这个问题。OmniGame 把整个游戏 UI 挂载到一个shadowRoot下面所有样式在 shadow 内部定义外部无法穿透内部也不会泄漏。实测下来同一个页面里挂三个不同版本的游戏实例样式完全互不干扰。而且 Shadow DOM 的slot机制还允许宿主页面自定义部分外观比如按钮颜色、字体但又不破坏游戏内部布局。这个设计在“游戏嵌入到内容平台”的场景里特别有用。你不需要跟宿主页面的样式打架也不需要写一堆!important来强行覆盖。Shadow DOM 给你的是一个干净的边界。2.4 为什么选 Next.js 而不是纯静态 HTML有人会问既然游戏本体是原生 JS为什么还要套一个 Next.js直接用静态 HTML 加一个script不就行了原因有三个。第一Next.js 的 API Route 天然适合做信令端点不需要额外起一个服务。第二Next.js 的静态导出和增量静态再生成可以让我把游戏外壳做成 SEO 友好的页面同时游戏本体按需加载。第三Next.js 的构建体系对代码分割和 chunk 优化有成熟支持我可以精确控制哪些代码进首屏、哪些代码延迟加载。更重要的是Next.js 的next/dynamic可以让我把 WebRTC 相关模块做成“点击开始游戏时才加载”这样首屏只加载一个轻量的入口用户点击“开始对战”之后才去拉取 P2P 模块。实测首屏交互时间从 1.8s 降到了 0.6s。3. 核心细节拆解WebRTC 数据通道、Shadow DOM 隔离、零依赖打包3.1 WebRTC 数据通道的建立流程与参数选择WebRTC 建立 P2P 连接的核心流程是创建RTCPeerConnection一方创建 offer另一方创建 answer双方交换 ICE candidate最终建立连接。OmniGame 里我把这个过程封装成了一个PeerConnection类核心代码如下class PeerConnection { constructor(signalingUrl, onMessage) { this.pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }] }); this.channel null; this.onMessage onMessage; this.signalingUrl signalingUrl; } async init() { this.channel this.pc.createDataChannel(game, { ordered: false, maxRetransmits: 0 }); this.channel.onmessage (e) this.onMessage(e.data); this.pc.onicecandidate (e) { if (e.candidate) { this.sendSignal({ type: candidate, candidate: e.candidate }); } }; } }这里有几个关键参数需要解释。ordered: false表示不保证消息顺序maxRetransmits: 0表示不重传。这两个参数一起用就是把数据通道设置成“不可靠、不保序”模式类似于 UDP。为什么这么做因为游戏操作同步里过期的消息没有意义。玩家 A 在第 10 帧发了一个“左移”如果这个包丢了第 11 帧的“左移”状态已经覆盖了它重传只会增加延迟。实测在丢包率 5% 的网络环境下不可靠模式的体感延迟比可靠模式低 30% 以上。但要注意这个模式不适合所有游戏。如果是回合制或者需要精确指令的游戏你还是得用ordered: true和默认重传。OmniGame 默认提供两种模式在创建房间时可以选择。STUN 服务器我用的是一个公共的但如果你要做生产级项目建议自己搭一个。公共 STUN 在高峰期偶尔会响应慢导致 ICE 收集时间变长。我实测过自建 STUN 可以把连接建立时间从平均 2.3s 降到 1.1s。3.2 Shadow DOM 的挂载方式与样式隔离细节Shadow DOM 的挂载本身不复杂但有几个细节容易踩坑。OmniGame 的挂载逻辑是这样的function mountGame(container, options) { const host document.createElement(div); host.style.cssText position:relative;width:100%;height:100%;; const shadow host.attachShadow({ mode: open }); const style document.createElement(style); style.textContent :host { display: block; contain: strict; } .game-root { position: absolute; inset: 0; } canvas { width: 100%; height: 100%; display: block; } ; shadow.appendChild(style); const gameRoot document.createElement(div); gameRoot.className game-root; shadow.appendChild(gameRoot); container.appendChild(host); return gameRoot; }这里的关键是contain: strict。这个 CSS 属性告诉浏览器shadow 内部的布局和绘制不会影响外部外部也不会影响内部。实测加上这个属性之后游戏运行时的样式重计算时间减少了 60%因为浏览器不需要在每次游戏状态变化时去检查外部样式。另一个细节是mode: open还是closed。我选的是open因为这样宿主页面可以通过host.shadowRoot访问内部方便做调试和部分自定义。如果你要做完全封闭的组件可以用closed但那样连你自己调试都麻烦。还有一个坑Shadow DOM 内部的canvas尺寸不会自动跟随宿主。你需要用ResizeObserver监听宿主尺寸变化然后手动设置 canvas 的width和height属性不是 CSS 尺寸。这个我踩过坑一开始只设了 CSS 尺寸结果 canvas 渲染模糊因为像素比不对。3.3 零依赖打包的边界控制与代码分割策略零依赖不等于不打包。OmniGame 用 Next.js 的构建体系但做了严格的边界控制。核心原则是游戏运行时只允许使用浏览器原生 API任何第三方库必须在构建时被内联或剔除。具体做法是在next.config.js里配置webpack的externals把非必要的依赖全部排除。同时用next/dynamic做代码分割const GameCanvas dynamic(() import(../components/GameCanvas), { ssr: false, loading: () div classNamegame-loading加载中.../div });ssr: false很重要因为 WebRTC 和 Shadow DOM 在服务端渲染时没有意义强行 SSR 只会报错。loading组件给一个轻量的占位避免布局抖动。代码分割的粒度我控制在三个 chunk入口 chunkNext.js 页面外壳约 40KB、游戏核心 chunk渲染和逻辑约 80KB、P2P chunkWebRTC 封装约 30KB。P2P chunk 只在用户点击“开始对战”时才加载。实测首屏只加载入口 chunk交互时间 0.6s点击开始后加载游戏核心和 P2P总加载时间 1.4s。这个数据在 4G 网络下是可以接受的。4. 实操过程从零搭一个双人 P2P 小游戏原型4.1 环境准备与项目初始化先确保你本地有 Node.js 18 以上版本。然后初始化 Next.js 项目npx create-next-applatest omnigame --typescript --app cd omnigame不需要额外安装任何游戏相关依赖。WebRTC 和 Shadow DOM 都是浏览器原生 APITypeScript 类型在lib.dom.d.ts里已经有了。接下来创建信令 API Route。在app/api/signal/route.ts里写一个极简的转发逻辑import { NextRequest, NextResponse } from next/server; const rooms new Mapstring, any[](); export async function POST(req: NextRequest) { const { roomId, signal } await req.json(); if (!rooms.has(roomId)) rooms.set(roomId, []); const queue rooms.get(roomId)!; queue.push(signal); return NextResponse.json({ ok: true }); } export async function GET(req: NextRequest) { const roomId req.nextUrl.searchParams.get(roomId); if (!roomId || !rooms.has(roomId)) { return NextResponse.json({ signals: [] }); } const signals rooms.get(roomId)!; rooms.set(roomId, []); return NextResponse.json({ signals }); }这个信令端点用内存存储只适合开发和小规模使用。生产环境建议换成 Redis 或者直接用 WebSocket。但它的逻辑足够简单你可以清楚地看到信令交换的本质就是“一方放另一方取”。4.2 创建 PeerConnection 并建立数据通道在lib/peer.ts里封装连接逻辑。核心是区分“发起方”和“接收方”export async function createOffer(pc: RTCPeerConnection) { const offer await pc.createOffer(); await pc.setLocalDescription(offer); return offer; } export async function createAnswer(pc: RTCPeerConnection, offer: RTCSessionDescriptionInit) { await pc.setRemoteDescription(offer); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); return answer; }发起方的流程是创建 offer → 设置本地描述 → 发送 offer 到信令 → 轮询获取 answer → 设置远程描述。接收方的流程是轮询获取 offer → 设置远程描述 → 创建 answer → 设置本地描述 → 发送 answer 到信令。ICE candidate 的交换是双向的双方在onicecandidate里把 candidate 发到信令同时轮询对方的 candidate 并addIceCandidate。这个过程我建议加一个 500ms 的轮询间隔太快了浪费请求太慢了连接建立慢。实测 500ms 是一个比较平衡的值。4.3 游戏循环与状态同步的实现游戏循环用requestAnimationFrame状态同步用数据通道发送增量。核心逻辑是function gameLoop() { const now performance.now(); const delta now - lastTime; lastTime now; updateLocalState(delta); if (channel channel.readyState open) { const snapshot getLocalSnapshot(); channel.send(JSON.stringify(snapshot)); } render(); requestAnimationFrame(gameLoop); }这里的关键是“发送什么”。我试过三种方案全量状态、增量操作、混合模式。全量状态最简单但数据量大增量操作数据量小但容易因为丢包导致状态不一致混合模式是定期发全量、中间发增量。最终我选了混合模式每 10 帧发一次全量中间发增量。实测在 60fps 下数据通道的带宽占用不到 20KB/s完全在 WebRTC 的舒适区内。4.4 在 Next.js 页面里挂载 Shadow DOM 游戏实例页面组件里用useEffect挂载游戏use client; import { useEffect, useRef } from react; export default function GamePage() { const containerRef useRefHTMLDivElement(null); useEffect(() { if (!containerRef.current) return; let cleanup: (() void) | undefined; import(../lib/mount).then(({ mountGame }) { cleanup mountGame(containerRef.current!, { mode: p2p }); }); return () cleanup?.(); }, []); return div ref{containerRef} style{{ width: 100vw, height: 100vh }} /; }注意use client和动态import。这样游戏模块不会进 SSR 包也不会影响首屏。cleanup函数负责在组件卸载时断开 PeerConnection 和取消动画帧避免内存泄漏。5. 常见问题与排查技巧实录5.1 连接建立失败ICE 收集不到 candidate这是最常见的问题。表现是onicecandidate一直不触发或者触发了但 candidate 是空的。原因通常是 STUN 服务器不可达或者本地网络环境限制了 UDP。排查步骤第一打开chrome://webrtc-internals看 ICE candidate 的收集状态。第二检查 STUN 服务器地址是否可达。第三如果是在公司网络或某些受限环境下UDP 可能被完全阻断这时候需要配置 TURN 服务器做中继。TURN 会增加延迟但至少能连上。OmniGame 默认只配了 STUN因为 TURN 需要自己搭成本较高。如果你要做生产级项目建议至少准备一个 TURN 作为兜底。5.2 数据通道频繁断开心跳与重连机制WebRTC 的数据通道在长时间空闲后可能会被中间网络设备断开。表现是channel.readyState变成disconnected或closed。解决办法是加心跳。每 5 秒发一个空消息或者ping对方回pong。如果连续 3 次没收到pong就触发重连。重连的逻辑是重新走一遍 offer/answer 流程但复用同一个信令房间。我实测过不加心跳的情况下平均 3 到 5 分钟就会断一次。加了心跳之后连续运行 2 小时没有断开。5.3 Shadow DOM 内事件不触发事件重定向与 composed 属性Shadow DOM 有一个“事件重定向”机制内部元素触发的事件在外部监听时target会被重定向到宿主元素。如果你在外部用event.target判断是哪个按钮被点击会拿到宿主元素而不是内部按钮。解决办法是用event.composedPath()获取完整路径或者直接在 shadow 内部监听事件。OmniGame 的做法是在 shadow 内部绑定所有游戏相关事件外部只监听自定义事件用new CustomEvent并设置composed: true。5.4 首屏加载慢代码分割与预加载策略如果首屏加载超过 2 秒检查三个地方第一Next.js 的dynamic是否真的把游戏模块分割出去了第二有没有意外的第三方库被打进入口 chunk第三静态资源有没有开 gzip 或 brotli。我建议在next.config.js里开启compress: true并且用next/bundle-analyzer定期检查 chunk 大小。实测开启 brotli 之后入口 chunk 从 40KB 降到了 28KB。5.5 常见问题速查表问题现象可能原因排查方法解决方案ICE candidate 为空STUN 不可达查看 webrtc-internals更换 STUN 或加 TURN数据通道频繁断开空闲超时监听 readyState 变化加心跳和重连Shadow DOM 事件 target 错误事件重定向打印 composedPath内部监听或自定义事件首屏加载超过 2schunk 过大bundle-analyzer代码分割和压缩游戏画面模糊canvas 像素比不对检查 devicePixelRatio手动设置 canvas 尺寸状态不同步丢包导致增量丢失对比双方状态定期发全量快照6. 一些我踩过的坑和实际体会第一个坑是 WebRTC 的createDataChannel必须在createOffer之前调用。我一开始顺序反了结果数据通道一直建立不起来。这个在文档里没有特别强调但实际开发中很容易搞错。第二个坑是 Shadow DOM 里的canvas在 Safari 上渲染有问题。Safari 对contain: strict的支持不完整导致 canvas 尺寸计算错误。解决办法是加一个supports查询Safari 下用contain: layout代替。第三个坑是 Next.js 的 API Route 在开发模式下每次请求都会重新加载模块导致内存里的信令房间被清空。解决办法是用globalThis存房间数据或者直接用外部 Redis。开发模式下这个问题很隐蔽因为单次测试看不出来只有连续测试才会发现。第四个坑是 WebRTC 在移动端浏览器上的表现差异很大。iOS Safari 对数据通道的支持比 Android Chrome 差特别是在后台切换时容易断开。我的做法是在移动端加一个“页面可见性变化”监听页面切到后台时暂停游戏循环切回来时重新协商连接。最后分享一个小技巧如果你想让游戏支持观战模式不需要额外的服务器。让观战者作为第三个 peer 加入发起方把游戏状态同时发给对战方和观战方就行。WebRTC 的数据通道支持一对多只要你的上行带宽够。实测一个发起方同时连三个观战者延迟增加不到 10ms。这个项目后续还可以扩展的方向是用 WebCodecs 做视频流同步把游戏画面直接编码成视频流发给观战者这样观战端不需要运行游戏逻辑只需要解码播放。不过那是另一个话题了等我把坑踩完再分享。
返回列表