ARTICLE DETAIL

资讯详情

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

OmniGame:零依赖+Shadow DOM+WebRTC,重新定义网页小游戏工程上限

OmniGame:零依赖+Shadow DOM+WebRTC,重新定义网页小游戏工程上限 网页小游戏这个领域表面上看是写个 Canvas 画几个方块的事但真要把工程上限拉起来绕不开三个硬骨头怎么让游戏代码不污染宿主页面、怎么让两个玩家在浏览器里直接对话、怎么让这套东西在真实网络环境里不崩。OmniGame 这个项目就是冲着这三个问题去的它把零依赖的运行时、Shadow DOM 的样式隔离、WebRTC 的 P2P 数据通道还有 Next.js 的工程化外壳捏在了一起。我拿到这个标题的时候第一反应是——这不是又一个用 React 写贪吃蛇的玩具而是一套认真在解决网页游戏能不能当正经工程做的方案。下面我会把它的核心机制、选型逻辑、实操细节和踩坑经验完整拆一遍适合已经会写前端、但对 P2P 和隔离机制还停留在听说过阶段的开发者。1. 为什么网页小游戏需要重新定义工程上限1.1 传统网页游戏的三个天花板大部分人做网页小游戏路径都差不多引一个游戏引擎或者干脆手写 Canvas把逻辑塞进一个 script 标签样式用全局 CSS 糊上去。能跑但一旦你想把它嵌到别人的页面里、想加多人对战、想让它像个正经产品一样维护问题就全冒出来了。第一个天花板是样式污染。你的游戏 CSS 里写了个.btn { color: red }宿主页面也有个.btn两边打架谁后加载谁赢。你可能会说加个前缀不就完了但游戏里动态生成的 DOM 结构、第三方 UI 库、动画关键帧前缀根本管不住。第二个天花板是全局状态冲突。游戏里用了window.gameState宿主页面也用了同名变量直接覆盖。更隐蔽的是事件监听你在document上挂了个keydown宿主页面的输入框就再也打不了字了。第三个天花板是多人对战的服务器成本。传统做法是玩家 A 发消息到服务器服务器转发给玩家 B。两个人玩一局服务器要处理几十上百条消息玩家一多带宽和延迟都是钱。而很多小游戏其实只需要两个玩家点对点通信服务器纯属多余。OmniGame 的思路很直接用 Shadow DOM 把游戏封成一个影子盒子用 WebRTC 把服务器从数据链路里踢出去用 Next.js 把整个工程管起来。这三个选择不是拍脑袋每一个都对应上面一个天花板。1.2 零依赖不等于功能少标题里零依赖这四个字容易被误解成啥都不装手写一切。实际上 OmniGame 的零依赖指的是运行时零依赖——游戏核心跑起来不需要引入任何第三方库不依赖 jQuery、不依赖 lodash、不依赖任何游戏引擎。它用的是浏览器原生能力Canvas 2D/WebGL、Shadow DOM、WebRTC、Web Audio。为什么强调这个因为依赖是有代价的。你引一个 200KB 的库用户首屏就多等 200KB库升级了 API 变了你得跟着改库有 bug你只能等作者修或者自己 fork。小游戏本来体量就小被依赖拖累得不偿失。原生 API 虽然写起来啰嗦一点但胜在稳定、可控、体积小。当然零依赖不等于不用构建工具。Next.js 在这里扮演的是工程化外壳的角色负责打包、路由、开发服务器、生产优化它不侵入游戏运行时。这个边界要划清楚否则容易把零依赖理解成连构建都不要。1.3 这套方案适合谁说句实在话这套东西不是给我就想周末写个扫雷的人准备的。它适合的是想把网页游戏做成可嵌入组件、需要多人实时对战、对包体积和加载速度有要求、愿意花时间理解浏览器底层机制的开发者。如果你只是想快速出个 demo用现成引擎更省事。但如果你做的东西要长期维护、要嵌到别人的产品里、要控制成本那这套思路值得认真看。2. Shadow DOM 隔离让游戏代码和宿主页面互不干扰2.1 样式隔离的真实痛点我先讲个真实场景。你把游戏嵌到一个电商详情页里游戏里有个开始按钮你写了.start-btn { background: #ff6b00; padding: 12px 24px }。结果宿主页面的全局样式里有一条button { padding: 0; background: none }优先级虽然比你低但它是后加载的某些情况下会覆盖你的样式。你调试半天发现按钮变成了一个没有背景的裸文字。更麻烦的是反向污染。你的游戏里用了* { box-sizing: border-box }宿主页面本来没这个设置结果整个页面的布局全乱了。这种问题在开发环境往往发现不了因为你的测试页面很干净一上线到真实宿主页面就炸。Shadow DOM 解决的就是这个问题。它创建一个独立的 DOM 子树这个子树里的样式和外部完全隔离。外面的 CSS 进不来里面的 CSS 出不去。这不是靠命名约定或者优先级技巧而是浏览器层面的硬隔离。2.2 attachShadow 的正确打开方式创建一个 Shadow DOM 的核心 API 就一行const host document.getElementById(game-container); const shadowRoot host.attachShadow({ mode: open });mode有两个值open和closed。open意味着外部可以通过host.shadowRoot访问到这个影子根closed则返回null。很多人一看到隔离就选closed觉得更安全。但实测下来closed会给你自己挖坑——调试的时候你没法从控制台访问内部结构某些测试工具也拿不到节点。除非你有明确的安全需求否则选open隔离效果是一样的只是访问权限不同。创建完 shadowRoot 之后你所有的 DOM 操作都要在这个 root 上进行const canvas document.createElement(canvas); canvas.width 800; canvas.height 600; shadowRoot.appendChild(canvas); const style document.createElement(style); style.textContent :host { display: block; position: relative; } canvas { display: block; width: 100%; height: auto; } ; shadowRoot.appendChild(style);注意:host这个选择器它指向的是宿主元素本身也就是#game-container。这是从 Shadow DOM 内部给宿主元素加样式的唯一方式。你不能在内部用#game-container去选它因为那个 ID 在影子树里是看不见的。2.3 事件重定向与焦点管理的坑Shadow DOM 有个容易被忽略的行为叫事件重定向。你在影子树内部点了一个按钮事件冒泡到宿主元素时event.target会被重写成宿主元素而不是内部那个按钮。这是为了保护封装性但如果你在外部监听点击事件想判断点的是哪个内部元素就会拿到错误的信息。解决办法是用event.composedPath()它返回完整的事件路径包含影子树内部的节点host.addEventListener(click, (e) { const path e.composedPath(); const innerButton path.find(el el.classList?.contains(start-btn)); if (innerButton) { // 处理内部按钮点击 } });焦点管理是另一个坑。Shadow DOM 内部的元素默认是可以获得焦点的但如果宿主页面有焦点陷阱比如模态框可能会和游戏内的键盘操作冲突。我的做法是给游戏容器加tabindex0让整个游戏作为一个焦点单元内部再用keydown监听处理具体按键避免焦点在内部元素之间乱跳。提示Shadow DOM 的样式隔离对font-face和 CSS 变量是例外。字体定义和 CSS 自定义属性是可以穿透影子边界的这既是特性也是坑用的时候要留意。2.4 和 iframe 隔离的对比说到隔离很多人第一反应是 iframe。iframe 确实隔离得更彻底连 JavaScript 执行环境都是独立的。但它有几个致命问题性能开销大每个 iframe 都是一个独立的文档环境通信麻烦父子页面之间要用postMessage异步且啰嗦SEO 和可访问性差搜索引擎和屏幕阅读器对 iframe 内容的处理都不理想。Shadow DOM 的隔离粒度刚好卡在样式和 DOM 隔离但共享 JS 环境这个位置。对于游戏来说这个粒度是合适的——你不需要独立的 JS 环境你需要的是样式不打架、DOM 不冲突。而且 Shadow DOM 是同步的操作起来跟普通 DOM 没区别开发体验好太多。隔离方案样式隔离JS 隔离性能开销通信成本适用场景全局 CSS 前缀弱无无无简单页面iframe强强高高完全独立的应用Shadow DOM强无低无可嵌入组件3. WebRTC P2P把服务器从数据链路里拿掉3.1 为什么小游戏适合 P2P先算一笔账。假设你做一个双人对战的小游戏玩家每秒钟产生 10 条操作消息一局游戏平均 3 分钟。传统 C/S 架构下这些消息全部经过服务器玩家 A 发 10 条/秒给服务器服务器转发 10 条/秒给玩家 B反向同理。一局下来服务器要处理 3600 条消息。如果有 1000 个并发对局服务器每秒要处理 20000 条消息的转发。P2P 架构下玩家 A 和玩家 B 直接建立连接消息不经过服务器。服务器只在建立连接时帮忙交换一下连接信息之后就可以退场了。同样是 1000 个并发对局服务器的压力从持续转发降到偶尔协助建连成本差了一个数量级。当然 P2P 不是万能的。它适合玩家数量少2-4 人、对实时性要求高、不需要服务器权威判定的场景。如果是大型多人在线或者需要防作弊的竞技游戏还是得用服务器。但对于网页小游戏这个品类P2P 的性价比非常高。3.2 WebRTC 数据通道的建立流程WebRTC 建立 P2P 连接的过程业内叫信令但信令本身不是 WebRTC 标准的一部分需要你自己实现。整个流程分几步第一步双方各自创建一个RTCPeerConnection对象。第二步发起方创建一个 offer描述自己的媒体和数据能力通过信令服务器发给接收方。第三步接收方收到 offer 后创建一个 answer回发给发起方。第四步双方交换 ICE candidate也就是各自的网络地址候选找到一条能通的路径。用代码表示发起方大概是这样const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.example.com:3478 }] }); const dataChannel pc.createDataChannel(game, { ordered: false, maxRetransmits: 0 }); const offer await pc.createOffer(); await pc.setLocalDescription(offer); // 通过信令服务器把 offer 发给对方 pc.onicecandidate (e) { if (e.candidate) { // 通过信令服务器把 candidate 发给对方 } };接收方const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.example.com:3478 }] }); pc.ondatachannel (e) { const dataChannel e.channel; dataChannel.onmessage (msg) { // 处理游戏消息 }; }; await pc.setRemoteDescription(offer); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); // 通过信令服务器把 answer 回发给发起方这里有个关键点createDataChannel的配置。ordered: false表示不保证消息顺序maxRetransmits: 0表示不重传。这两个设置是为了降低延迟——游戏操作消息过期了就过期了重传反而会造成延迟累积。但如果你传的是关键状态比如游戏结束就得用可靠通道。OmniGame 的做法是开两条通道一条不可靠的传高频操作一条可靠的传关键事件。3.3 STUN 和 TURNNAT 穿透的现实WebRTC 要建立 P2P 连接最大的障碍是 NAT网络地址转换。大部分设备都在 NAT 后面没有公网 IP外部无法直接连进来。STUN 服务器的作用是帮设备发现自己的公网地址TURN 服务器则是在 P2P 打不通时做中继。现实情况是大约 80% 到 90% 的连接可以通过 STUN 直接打通剩下的 10% 到 20% 需要 TURN 中继。TURN 中继意味着流量还是经过服务器成本又回来了。所以如果你的用户群体网络环境复杂TURN 服务器的带宽成本要提前算进去。注意STUN 服务器可以自己搭也可以用公开的。但公开 STUN 服务器不稳定生产环境建议自建。TURN 服务器因为要转发流量基本必须自建或者买商业服务。3.4 连接状态监控与断线重连P2P 连接不是一劳永逸的网络切换、设备休眠、长时间无数据都可能导致连接断开。RTCPeerConnection提供了connectionstatechange事件状态包括new、connecting、connected、disconnected、failed、closed。我的经验是disconnected状态不要立刻判定为断线它可能是暂时的网络抖动等几秒可能会恢复。但failed状态基本就是没救了需要走重连流程。重连不是简单地重新createOffer因为 ICE candidate 可能已经失效稳妥的做法是关掉旧的 peer connection重新走一遍完整的信令流程。pc.onconnectionstatechange () { if (pc.connectionState failed) { // 清理旧连接 pc.close(); // 重新初始化并走信令流程 initConnection(); } };4. Next.js 在游戏工程里的角色定位4.1 为什么不用纯静态 HTML有人会问既然运行时零依赖为什么不直接写个 HTML 文件非要上 Next.js答案在于工程化。纯静态 HTML 在开发阶段没问题但一旦涉及多页面、代码分割、环境变量、API 路由信令服务器、生产构建优化就会变得难以维护。Next.js 提供的东西恰好是游戏工程需要的文件路由让多游戏共存变得简单每个游戏一个页面API Routes 可以直接写信令服务器的逻辑不用单独起一个后端代码分割让每个游戏只加载自己的代码开发服务器的热更新让调试体验好很多。关键是 Next.js 不侵入游戏运行时。游戏核心代码是纯原生的Next.js 只负责把它打包和托管。这个边界很重要它保证了游戏代码的可移植性——哪天你不想用 Next.js 了把游戏代码抽出来放到任何环境都能跑。4.2 信令服务器的实现位置信令服务器是 P2P 架构里唯一必须存在的服务端组件。它的职责很简单帮两个玩家交换 offer、answer 和 ICE candidate交换完就可以不管了。用 Next.js 的 API Routes 实现一个简单的内存版信令大概长这样// pages/api/signal.js const rooms new Map(); export default function handler(req, res) { const { roomId, peerId, message } req.body; if (!rooms.has(roomId)) { rooms.set(roomId, new Map()); } const room rooms.get(roomId); if (message) { // 存储消息等待对方拉取 room.set(peerId, message); res.status(200).json({ ok: true }); } else { // 拉取对方的消息 const otherPeer [...room.keys()].find(id id ! peerId); const otherMessage otherPeer ? room.get(otherPeer) : null; res.status(200).json({ message: otherMessage }); } }这是最简版本用轮询拉取消息。生产环境应该用 WebSocket 或者 Server-Sent Events 做推送减少延迟。但核心逻辑就这么点——信令服务器不需要理解游戏内容它只是个消息中转站。4.3 构建产物与加载策略Next.js 默认的构建产物对游戏来说有几个优化点。第一用dynamic import把游戏代码做成懒加载用户不点进游戏页面就不下载游戏代码。第二把游戏资源图片、音频放到public目录利用 Next.js 的静态资源服务。第三用next/dynamic的ssr: false选项因为游戏代码依赖window、document这些浏览器 API服务端渲染会报错。import dynamic from next/dynamic; const GameCanvas dynamic(() import(../components/GameCanvas), { ssr: false, loading: () div加载中.../div });这个ssr: false很关键。我见过有人忘了加结果构建时各种window is not defined报错排查半天才发现是服务端渲染的问题。5. 从零搭一个 OmniGame 风格的最小可运行版本5.1 项目骨架与依赖边界先明确依赖边界package.json里只有 Next.js、React、React DOM 这三个生产依赖游戏运行时零依赖。开发依赖加上 ESLint、TypeScript 之类的工具但这些东西不进生产包。目录结构大概这样omni-game/ ├── pages/ │ ├── index.js # 游戏列表页 │ ├── game/[id].js # 游戏页面 │ └── api/signal.js # 信令服务器 ├── games/ │ └── pong/ │ ├── index.js # 游戏入口创建 Shadow DOM │ ├── engine.js # 游戏逻辑纯原生 │ └── style.css # 游戏样式注入 Shadow DOM ├── components/ │ └── GameHost.js # 游戏宿主组件 └── public/ └── assets/ # 游戏资源关键原则是games/目录下的代码不 import 任何 npm 包只用浏览器原生 API。这样游戏代码可以独立测试、独立部署甚至抽出来放到别的项目里。5.2 游戏宿主的封装GameHost.js是连接 React 世界和原生游戏世界的桥梁。它的职责是创建一个容器元素挂载 Shadow DOM然后把游戏逻辑注入进去import { useEffect, useRef } from react; export default function GameHost({ gameFactory }) { const containerRef useRef(null); useEffect(() { const host containerRef.current; if (!host) return; const shadowRoot host.attachShadow({ mode: open }); const cleanup gameFactory(shadowRoot); return () { if (typeof cleanup function) cleanup(); }; }, [gameFactory]); return div ref{containerRef} style{{ width: 100%, height: 100% }} /; }注意gameFactory返回一个 cleanup 函数用于在组件卸载时清理游戏资源——取消动画帧、关闭 WebRTC 连接、移除事件监听。这个清理逻辑非常重要否则在 React 的严格模式下开发环境会故意挂载卸载两次游戏会重复初始化出现两个游戏实例同时运行的情况。5.3 游戏循环与渲染游戏循环用requestAnimationFrame这是浏览器提供的标准动画 API会自动和屏幕刷新率同步。不要用setInterval它的时间精度差而且在后台标签页里会被节流。export function createGame(shadowRoot) { const canvas document.createElement(canvas); canvas.width 800; canvas.height 600; shadowRoot.appendChild(canvas); const ctx canvas.getContext(2d); let rafId null; let lastTime 0; function loop(timestamp) { const delta timestamp - lastTime; lastTime timestamp; update(delta); render(ctx); rafId requestAnimationFrame(loop); } rafId requestAnimationFrame(loop); return () { cancelAnimationFrame(rafId); }; }delta是两帧之间的时间差用它来驱动游戏逻辑可以保证不同刷新率的设备上游戏速度一致。60Hz 屏幕上一帧是 16.67ms144Hz 屏幕上是 6.94ms如果不用 delta 而假设每帧固定时间高刷设备上游戏会跑得飞快。5.4 P2P 对战的接入把 WebRTC 接入游戏核心是把数据通道的消息和游戏状态绑定。以 Pong 为例玩家 A 控制左 paddle玩家 B 控制右 paddle。A 的 paddle 位置是本地权威的通过数据通道同步给 BB 的 paddle 位置同理。function setupP2P(dataChannel, gameState) { dataChannel.onmessage (e) { const msg JSON.parse(e.data); if (msg.type paddle) { gameState.remotePaddleY msg.y; } else if (msg.type ball) { gameState.ball msg.ball; } }; // 本地 paddle 变化时发送 function sendPaddle(y) { if (dataChannel.readyState open) { dataChannel.send(JSON.stringify({ type: paddle, y })); } } return { sendPaddle }; }这里有个设计决策球的位置由谁权威两种方案一种是主机权威host authoritative由一方计算球的位置同步给另一方另一种是各自模拟定期校正。前者简单但主机玩家有延迟优势后者公平但容易出现状态不一致。小游戏一般选主机权威实现简单延迟影响也不大。6. 实测中暴露的问题与处理经验6.1 Shadow DOM 里的 Canvas 尺寸问题这个坑我踩过。在 Shadow DOM 里创建 Canvas用 CSS 设置width: 100%然后读canvas.width发现还是默认的 300。原因是 Canvas 有两个尺寸概念CSS 尺寸显示大小和缓冲区尺寸实际像素数。CSS 的width: 100%只改显示大小不改缓冲区。正确做法是监听容器尺寸变化手动设置缓冲区const resizeObserver new ResizeObserver((entries) { for (const entry of entries) { const { width, height } entry.contentRect; 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); } }); resizeObserver.observe(host);devicePixelRatio是为了适配高清屏。在 Retina 屏上dpr 是 2如果缓冲区不乘以 2画面会模糊。乘以 2 之后再用ctx.scale缩放回来逻辑坐标不变但渲染更清晰。6.2 WebRTC 连接在移动端的表现移动端的 WebRTC 比桌面端麻烦得多。第一网络切换频繁从 WiFi 切到蜂窝网络ICE candidate 会失效连接可能断开。第二后台标签页会被系统限制requestAnimationFrame暂停数据通道的消息处理也可能延迟。第三某些移动浏览器对 WebRTC 的支持不完整需要做特性检测。我的处理方式是监听visibilitychange事件页面切到后台时暂停游戏逻辑切回来时检查连接状态如果断了就走重连。同时给用户一个明确的连接状态提示不要让他们对着一个卡住的画面干等。document.addEventListener(visibilitychange, () { if (document.hidden) { pauseGame(); } else { resumeGame(); checkConnectionHealth(); } });6.3 信令服务器的并发问题前面那个内存版信令服务器在开发环境够用但生产环境有几个问题。第一内存存储意味着服务器重启后房间信息全丢正在建连的玩家会失败。第二单机内存无法水平扩展多实例部署时玩家可能连到不同的实例互相看不到消息。第三没有清理机制废弃的房间会一直占内存。改进方向是用 Redis 之类的共享存储替代内存 Map加一个定时清理任务删除超过一定时间没有活动的房间。如果并发量不大用数据库也行但要注意读写性能。信令消息的生命周期很短通常几秒内就会被消费掉所以存储的 TTL 可以设得很短。6.4 数据通道的消息序列化开销游戏消息用 JSON 序列化是最简单的但 JSON 有开销。每条消息都要JSON.stringify和JSON.parse高频消息下这个开销不可忽略。优化方案是用ArrayBuffer传二进制数据手动编码解码。比如 paddle 位置用一个 Float32Array 存两个值x 和 y转成 ArrayBuffer 发送接收方直接读// 发送 const buffer new ArrayBuffer(8); const view new Float32Array(buffer); view[0] paddleX; view[1] paddleY; dataChannel.send(buffer); // 接收 dataChannel.binaryType arraybuffer; dataChannel.onmessage (e) { const view new Float32Array(e.data); const paddleX view[0]; const paddleY view[1]; };实测下来二进制编码比 JSON 快 3 到 5 倍消息体积也小很多。但代价是可读性差调试时看不到明文。我的建议是开发阶段用 JSON性能测试后再决定要不要换二进制。序列化方式编码速度消息体积可读性适用场景JSON中大好开发调试、低频消息ArrayBuffer快小差高频操作、性能敏感MessagePack较快较小中折中方案7. 这套架构的边界与后续扩展方向7.1 什么场景不适合 P2PP2P 不是银弹有几个场景明确不适合。第一需要服务器权威判定的竞技游戏比如 FPS 的命中判定必须由服务器裁决否则作弊成本太低。第二玩家数量超过 4 人的场景P2P 是全连接拓扑每个人都要和其他所有人建连连接数随人数平方增长4 人以上就开始吃力。第三需要持久化状态的游戏比如 MMOP2P 没有中心存储状态同步很麻烦。OmniGame 的定位是网页小游戏这个定位本身就框定了适用范围——2 到 4 人的休闲对战、合作类游戏不需要强反作弊不需要持久化世界状态。在这个范围内P2P 的性价比是最高的。7.2 从双人扩展到多人的思路如果确实需要 3 到 4 人P2P 还是可以做的但拓扑要改。全连接每个人连每个人在 4 人时是 6 条连接还能接受。超过 4 人就要考虑星型拓扑选一个玩家当主机其他人连主机主机负责转发。但主机玩家的网络质量直接影响所有人而且主机掉线整个房间就散了。更稳妥的方案是混合架构用服务器做房间管理和状态同步P2P 只用于高频的实时数据。这样既保留了 P2P 的低延迟优势又有服务器的可靠性兜底。但复杂度上去了要看项目是否值得。7.3 工程化层面的持续优化最后说几个工程化上的优化点。第一把游戏核心逻辑做成独立的 npm 包和 Next.js 外壳解耦这样可以在不同项目里复用。第二加自动化测试游戏逻辑用单元测试P2P 连接用集成测试Shadow DOM 隔离用端到端测试。第三做性能监控记录首屏加载时间、连接建立时间、消息往返延迟这些数据是优化的依据。我在实际项目里的体会是网页游戏的工程上限不在于用了多牛的技术而在于每个技术选择是否解决了真实问题。Shadow DOM 解决隔离WebRTC 解决成本和延迟Next.js 解决工程化零依赖解决体积和可控性。每一个选择都有明确的理由而不是为了炫技。这套架构跑下来一个双人对战小游戏的首次加载可以控制在 100KB 以内连接建立时间在 1 到 2 秒游戏内消息延迟在 50ms 以内这个数据在网页游戏里算是相当能打了。
返回列表