
1. 为什么我要折腾一个“零依赖”的网页小游戏框架先说结论OmniGame 是我在过去几个月里断断续续搭出来的一个网页小游戏工程骨架核心目标只有一句话——让一个网页小游戏从“能跑”变成“跑得稳、传得快、嵌得进”。它解决的不是玩法问题而是工程问题怎么在不引入一堆第三方库的前提下把资源加载、状态同步、跨端嵌入这三件事做扎实。如果你做过网页小游戏大概率经历过这样的场景本地开发爽得飞起一上线就发现首屏白屏三秒两个人想联机结果要么走服务器中转延迟高要么直连打不通想把这个小游戏嵌到别人的页面里样式互相污染改一个全局样式整个游戏界面就崩了。OmniGame 就是冲着这三个痛点去的。它适合谁看如果你是有一定前端基础、想自己动手做一个可联机、可嵌入、可维护的网页小游戏的人这篇内容会对你有用。如果你只是想找个现成引擎套模板那可能不太对路——OmniGame 更像是一套“工程思路 关键实现片段”的组合而不是一个开箱即用的黑盒。我先把整体技术选型摆出来零运行时依赖是底线WebRTC P2P负责实时通信Shadow DOM负责样式隔离Next.js负责工程化和构建。这四个词看起来跨度挺大但它们其实是一条线上的零依赖保证体积和可控性P2P 保证通信效率Shadow DOM 保证嵌入安全Next.js 保证开发体验。下面我逐个拆开讲把每个选择背后的“为什么”说清楚。2. 零依赖到底意味着什么以及我为什么坚持它2.1 零依赖不等于不用工具而是运行时零依赖很多人一听“零依赖”就以为是纯手写、什么库都不用。这是个误解。我说的零依赖指的是最终产物在浏览器运行时不依赖任何第三方运行时库。构建阶段我照样用 Next.js、用打包工具、用类型检查这些是开发工具不是运行时依赖。为什么要在意这个因为网页小游戏最怕的就是“依赖地狱”。你引一个物理引擎它依赖一个数学库数学库又依赖一个工具函数库最后打包出来几百 KB首屏加载直接劝退。更麻烦的是版本冲突A 库要 lodash 4B 库要 lodash 3打包器给你塞两份体积翻倍。我的做法是核心运行时只保留浏览器原生 API。渲染用 Canvas 2D 或 WebGL 原生接口通信直接用RTCPeerConnectionDOM 操作用原生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 2DRTCPeerConnection不可用时降级到轮询。如果用了封装库降级逻辑往往被库藏起来了你想改都改不动。我在 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 里我用一个极简的信令服务来做这件事它只负责转发不参与游戏数据。信令流程大致是这样玩家 A 创建RTCPeerConnection生成 offer发给信令服务器信令服务器把 offer 转给玩家 B玩家 B 收到 offer生成 answer回传给信令服务器信令服务器把 answer 转给玩家 A双方同时收集 ICE candidate通过信令服务器互换连接建立后续游戏数据走 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(); }, []);游戏内部状态变化通过自定义事件通知 ReactReact 不直接操作游戏对象。这样职责清晰性能也好。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 就是把这三点用最朴素的方式实现了一遍没有魔法每一行都能讲清楚为什么。