☰
零依赖网页小游戏框架:WebRTC P2P与Shadow DOM工程实践
2026/10/8 16:30:54 网站建设 项目流程

1. 为什么我要折腾一个“零依赖”的网页小游戏框架

先说结论:OmniGame 是我在过去几个月里断断续续搭出来的一个网页小游戏工程骨架,核心目标只有一句话——让一个网页小游戏从“能跑”变成“跑得稳、传得快、嵌得进”。它解决的不是玩法问题,而是工程问题:怎么在不引入一堆第三方库的前提下,把资源加载、状态同步、跨端嵌入这三件事做扎实。

如果你做过网页小游戏,大概率经历过这样的场景:本地开发爽得飞起,一上线就发现首屏白屏三秒;两个人想联机,结果要么走服务器中转延迟高,要么直连打不通;想把这个小游戏嵌到别人的页面里,样式互相污染,改一个全局样式整个游戏界面就崩了。OmniGame 就是冲着这三个痛点去的。

它适合谁看?如果你是有一定前端基础、想自己动手做一个可联机、可嵌入、可维护的网页小游戏的人,这篇内容会对你有用。如果你只是想找个现成引擎套模板,那可能不太对路——OmniGame 更像是一套“工程思路 + 关键实现片段”的组合,而不是一个开箱即用的黑盒。

我先把整体技术选型摆出来:零运行时依赖是底线,WebRTC P2P负责实时通信,Shadow DOM负责样式隔离,Next.js负责工程化和构建。这四个词看起来跨度挺大,但它们其实是一条线上的:零依赖保证体积和可控性,P2P 保证通信效率,Shadow DOM 保证嵌入安全,Next.js 保证开发体验。下面我逐个拆开讲,把每个选择背后的“为什么”说清楚。

2. 零依赖到底意味着什么,以及我为什么坚持它

2.1 零依赖不等于不用工具,而是运行时零依赖

很多人一听“零依赖”就以为是纯手写、什么库都不用。这是个误解。我说的零依赖,指的是最终产物在浏览器运行时,不依赖任何第三方运行时库。构建阶段我照样用 Next.js、用打包工具、用类型检查,这些是开发工具,不是运行时依赖。

为什么要在意这个?因为网页小游戏最怕的就是“依赖地狱”。你引一个物理引擎,它依赖一个数学库,数学库又依赖一个工具函数库,最后打包出来几百 KB,首屏加载直接劝退。更麻烦的是版本冲突:A 库要 lodash 4,B 库要 lodash 3,打包器给你塞两份,体积翻倍。

我的做法是:核心运行时只保留浏览器原生 API。渲染用 Canvas 2D 或 WebGL 原生接口,通信直接用RTCPeerConnection,DOM 操作用原生querySelector那一套。需要工具函数怎么办?自己写。一个clamp、一个lerp、一个事件总线,加起来不到一百行,比引一个库划算得多。

2.2 体积账要算清楚,别凭感觉

我实测过一组数据,同一个功能模块,用第三方库和手写实现的体积差距:

功能第三方库方案手写方案体积差
事件总线mitt 约 200B自写约 80B省 120B
数学工具引入完整 math 库约 8KB按需自写约 400B省 7.6KB
状态管理轻量库约 3KB自写约 600B省 2.4KB
序列化通用库约 5KB自写约 300B省 4.7KB

单看每一项都不大,但叠起来就是十几 KB。对于一个小游戏来说,十几 KB 可能就是首屏快 200 毫秒的差别。而且手写的好处是你完全知道每一行代码在干什么,出问题好排查,不会出现“库内部报错但我看不懂”的情况。

注意:零依赖不是教条。如果某个功能手写成本极高、第三方库又足够小且稳定,该用还是用。我的判断标准是:手写超过 200 行且容易出 bug 的,才考虑引入。

2.3 零依赖带来的一个隐藏好处:可控的降级策略

没有第三方库,意味着你可以精确控制每一个 API 的降级路径。比如 WebGL 不可用时降级到 Canvas 2D,RTCPeerConnection不可用时降级到轮询。如果用了封装库,降级逻辑往往被库藏起来了,你想改都改不动。

我在 OmniGame 里写了一个能力探测模块,启动时先跑一遍:

const caps = { webgl: (() => { try { const c = document.createElement('canvas'); return !!(c.getContext('webgl') || c.getContext('experimental-webgl')); } catch (e) { return false; } })(), rtc: typeof RTCPeerConnection !== 'undefined', shadow: typeof ShadowRoot !== 'undefined' };

然后根据caps决定走哪条渲染和通信路径。这套逻辑自己写也就几十行,但换来的是任何环境下都不会直接崩。

3. WebRTC P2P:把联机延迟压到最低的关键

3.1 为什么不用 WebSocket 中转

先说清楚 P2P 和传统中转的区别。传统方案是:玩家 A 发消息给服务器,服务器转发给玩家 B。这条链路里,服务器是必经节点,延迟 = A 到服务器 + 服务器到 B。如果服务器在异地,这个延迟可能上百毫秒。

P2P 的目标是让 A 和 B 直接连,消息不经过服务器。理想情况下延迟就是 A 到 B 的网络延迟,可能只有几十毫秒。对于实时性要求高的游戏(比如对战、协作),这个差别是能感觉出来的。

但 P2P 有个前提:双方要能互相找到并打通网络路径。这就是 WebRTC 要解决的问题。它通过 ICE 框架,尝试各种方式建立连接,包括直连和中继。直连成功就是真 P2P,直连失败才走中继兜底。

3.2 信令服务器:P2P 也离不开的“介绍人”

很多人以为 P2P 就完全不需要服务器了,这是错的。WebRTC 建立连接前,双方需要交换连接信息(SDP 和 ICE candidate),这个交换过程叫信令,必须通过一个双方都能访问的通道完成。OmniGame 里我用一个极简的信令服务来做这件事,它只负责转发,不参与游戏数据。

信令流程大致是这样:

  1. 玩家 A 创建RTCPeerConnection,生成 offer,发给信令服务器
  2. 信令服务器把 offer 转给玩家 B
  3. 玩家 B 收到 offer,生成 answer,回传给信令服务器
  4. 信令服务器把 answer 转给玩家 A
  5. 双方同时收集 ICE candidate,通过信令服务器互换
  6. 连接建立,后续游戏数据走 P2P 通道

关键代码片段:

// 创建连接 const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.example.com:3478' }] }); // 收集候选 pc.onicecandidate = (e) => { if (e.candidate) { signaling.send({ type: 'candidate', data: e.candidate }); } }; // 数据通道 const channel = pc.createDataChannel('game', { ordered: false, maxRetransmits: 0 }); channel.onmessage = (e) => handleGameMessage(e.data);

这里有个细节值得说:createDataChannel的配置。ordered: false表示不保证顺序,maxRetransmits: 0表示不重传。对于位置同步这种“旧数据没用了”的场景,这样配置能大幅降低延迟。但如果是聊天消息这种不能丢的,就要改成可靠通道。

3.3 NAT 穿透的现实:别指望 100% 直连

我得泼盆冷水:P2P 直连成功率不是 100%。在复杂的网络环境下(比如双方都在严格的 NAT 后面),直连可能打不通,这时候需要中继服务器兜底。WebRTC 的 ICE 框架会自动尝试,直连失败就走中继。

所以架构上要接受一个现实:P2P 是“尽力而为”,中继是“保底”。OmniGame 里我把中继配置成可选的,如果部署环境没有中继,就退化成纯信令转发模式,虽然延迟高一点,但至少能玩。

实操心得:测试 P2P 连通性时,一定要在真实网络环境下测,别只在本地局域网测。局域网里直连成功率接近 100%,会让你产生“一切正常”的错觉。

4. Shadow DOM:让游戏能安全嵌入任何页面

4.1 样式污染是嵌入场景的头号杀手

假设你做了个小游戏,想嵌到别人的博客里。你的游戏用了.button这个类名,博客也用了.button,两边样式一冲突,要么你的按钮变形,要么博客的按钮变形。这就是样式污染。

传统解法是给所有类名加前缀,比如.omni-button。但这治标不治本,因为还有全局样式(比如* { box-sizing: border-box })会渗透进来,你的游戏布局可能因此错位。

Shadow DOM 的解法是创建一个独立的 DOM 子树,这个子树里的样式和外部完全隔离。外部样式进不来,内部样式出不去。这是浏览器原生能力,不需要任何库。

4.2 挂载方式与关键配置

class OmniGameElement extends HTMLElement { connectedCallback() { const shadow = this.attachShadow({ mode: 'closed' }); const container = document.createElement('div'); container.className = 'game-root'; shadow.appendChild(container); // 样式注入到 shadow 内部 const style = document.createElement('style'); style.textContent = ` .game-root { width: 100%; height: 100%; position: relative; } canvas { display: block; width: 100%; height: 100%; } `; shadow.appendChild(style); this.initGame(container); } } customElements.define('omni-game', OmniGameElement);

mode: 'closed'表示外部无法通过element.shadowRoot访问内部,隔离更彻底。但代价是调试时看不到内部结构,需要权衡。开发阶段我建议用'open',上线再改'closed'。

4.3 尺寸自适应:嵌入场景的必修课

嵌入到别人页面里,容器尺寸是不确定的。可能是固定 800x600,也可能是响应式的。OmniGame 用ResizeObserver监听容器尺寸变化,动态调整 Canvas 分辨率:

const ro = 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.setTransform(dpr, 0, 0, dpr, 0, 0); } }); ro.observe(container);

这里devicePixelRatio的处理很关键。不处理的话,在高分屏上画面会糊。处理了之后,渲染坐标和物理像素解耦,逻辑代码不用改。

5. Next.js 在游戏工程里的角色定位

5.1 为什么游戏项目也用 Next.js

有人会问:游戏不是应该用 Vite 或者纯 webpack 吗,为什么用 Next.js?我的理由是:OmniGame 不只是游戏,它还是一个可被分享、可被索引的页面。Next.js 提供的路由、SSR、静态导出能力,让游戏页面本身可以像普通网页一样被访问和分享。

具体来说,Next.js 帮我解决了三件事:

  • 路由和页面组织:游戏大厅、房间页、设置页,用文件路由管理,清晰
  • 静态导出:next export出来的纯静态文件,可以扔到任何静态托管上
  • 代码分割:游戏核心逻辑和 UI 分离,首屏只加载必要的部分

5.2 游戏逻辑与框架的边界

这里有个坑要提醒:不要把游戏主循环塞进 React 组件里。React 的渲染机制和游戏的高频更新是冲突的。我的做法是:React 只负责 UI 外壳(菜单、按钮、状态显示),游戏主循环跑在独立的 Canvas 上,两者通过事件通信。

// React 组件只做挂载 useEffect(() => { const game = new OmniGame(canvasRef.current); game.start(); return () => game.destroy(); }, []);

游戏内部状态变化通过自定义事件通知 React,React 不直接操作游戏对象。这样职责清晰,性能也好。

5.3 构建产物的体积控制

Next.js 默认会引入一些运行时,对于游戏来说可能偏重。我做了几件事来瘦身:

  • 关闭不必要的 polyfill
  • 游戏核心代码单独打包,不走框架的 chunk 分割
  • 图片资源用原生格式,不用框架的图片优化组件

实测下来,游戏核心包能控制在 30KB 以内(gzip 后),加上框架外壳总共不到 80KB。这个体积对于网页游戏来说是可以接受的。

6. 常见问题与排查技巧实录

6.1 P2P 连不上怎么办

这是最高频的问题。排查顺序我总结成一张表:

现象可能原因排查方法
一直停在 connecting信令没通看信令服务器日志,确认 offer/answer 是否互换
ICE 状态 failedNAT 太严格检查是否配置了中继服务器
连上但收不到数据数据通道没开确认ondatachannel或onopen是否触发
延迟忽高忽低走了中继看 candidate 类型,host/srflx 是直连,relay 是中继

我的经验是:先把信令打通,再调 P2P。很多人一上来就怀疑 NAT,其实大部分问题是信令没配对。

6.2 Shadow DOM 里的资源加载

Shadow DOM 内部加载图片、字体时,相对路径的基准是文档的 URL,不是 Shadow Root 的 URL。这个容易搞混。我的做法是统一用绝对路径,或者在挂载时把 base URL 传进去。

6.3 内存泄漏的预防

游戏对象销毁时,一定要清理:

  • RTCPeerConnection.close()
  • ResizeObserver.disconnect()
  • 移除所有事件监听
  • 取消requestAnimationFrame

我踩过的坑是:requestAnimationFrame没取消,页面切走后循环还在跑,CPU 占用下不来。后来在destroy方法里统一清理,问题解决。

实操心得:写一个disposables数组,所有需要清理的东西都 push 进去,销毁时统一遍历执行。这个模式能避免 90% 的泄漏问题。

7. 后续可以怎么扩展

OmniGame 目前的定位是“工程骨架”,不是完整游戏。如果你想基于它做东西,几个方向可以考虑:一是接入物理引擎(但要注意体积),二是做房间匹配系统(需要后端配合),三是加录制回放(把输入序列存下来重放即可)。

我个人在实际操作中的体会是:网页小游戏的工程难点从来不在玩法,而在加载、通信、嵌入这三件事。把这三件事做扎实,玩法反而是最容易迭代的部分。OmniGame 就是把这三点用最朴素的方式实现了一遍,没有魔法,每一行都能讲清楚为什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询