
网页小游戏这个领域表面上看是能跑就行但真要把工程上限往上顶一顶你会发现到处都是天花板资源加载慢、状态同步难、多人联机要服务器、样式隔离做不干净、首屏白屏时间长。OmniGame 这套东西就是冲着这些天花板去的。它想做的事情很明确——用零依赖的思路打底把 Shadow DOM 做样式隔离用 Next.js 撑起工程化骨架最后用 WebRTC 的 P2P 数据通道把多人联机这块最难啃的骨头拿下。整套方案下来一个网页小游戏可以做到不依赖任何第三方运行时库、不依赖中心化游戏服务器就能实现实时对战。这篇文章我会把这套架构从设计动机到落地细节完整拆一遍包括我实际踩过的坑和验证过的参数。不管你是刚接触 WebRTC 的前端还是想给自己的小游戏加联机功能的老手应该都能从里面找到能直接抄的东西。1. 为什么网页小游戏需要重新定义工程上限1.1 网页小游戏的三个隐形天花板大部分人做网页小游戏起步路径都差不多找个 Canvas 库或者游戏引擎写个游戏循环打包上线。跑起来没问题但一旦你想往产品方向走就会撞上三堵墙。第一堵墙是依赖膨胀。你引入一个游戏引擎它可能带进来十几个子依赖打包体积轻松上兆。对于一个打开网页就能玩的小游戏来说加载 2MB 的 JS 再等解析执行用户早就关页面了。第二堵墙是样式污染。游戏 UI 和宿主页面的 CSS 互相打架你写个.btn类名结果被页面的全局样式覆盖调半天调不好。第三堵墙是联机成本。想做多人对战传统方案是架一个 WebSocket 服务器做状态中转服务器要钱、要运维、要处理并发小项目根本扛不住。这三堵墙不是技术难题而是工程选择问题。OmniGame 的思路是既然每堵墙都有对应的现代 Web 能力可以拆那就干脆从零开始把每一层都换成更轻、更独立的方案。1.2 零依赖不等于功能少这里要先澄清一个概念。零依赖zero-dependency不是说不用任何库、所有东西手写而是指运行时不依赖第三方包——你的产物里没有node_modules的东西被打进去。开发阶段你照样可以用构建工具、用 TypeScript、用各种开发辅助但最终交付给浏览器的是纯粹的、你自己掌控的代码。这么做的好处很直接体积可控、没有供应链风险、没有版本冲突、调试的时候栈追踪干净。代价是你得自己实现一些基础设施比如事件系统、状态管理、渲染循环。但对于小游戏这种场景这些基础设施本身就不复杂自己写反而更贴合需求。我实测下来一个中等复杂度的小游戏零依赖方案的核心运行时可以控制在 30KB 以内gzip 后比引入引擎小了将近两个数量级。1.3 四个技术支柱的分工OmniGame 的架构可以拆成四块每块解决一个具体问题技术支柱解决的问题核心收益零依赖运行时体积与供应链产物小、可控、无冲突Shadow DOM样式与 DOM 隔离UI 不打架、可嵌入任意页面Next.js工程化与首屏路由、构建、SSR/SSG 支持WebRTC P2P多人实时联机无中心服务器、低延迟这四块不是简单堆叠而是有明确的层次关系。Next.js 是外壳和构建层零依赖运行时是游戏内核Shadow DOM 是内核和宿主页面之间的隔离层WebRTC 是内核之间的通信层。理解这个分层后面所有的设计决策就都顺了。2. 零依赖运行时的设计取舍2.1 游戏循环用什么驱动游戏循环是整个运行时的心脏。常见选择有三个requestAnimationFramerAF、setInterval、setTimeout递归。我的结论很明确主循环必须用 rAF没有例外。原因在于 rAF 的回调时机是和浏览器刷新率对齐的。60Hz 屏幕就是每 16.67ms 一次120Hz 就是 8.33ms 一次。浏览器会在每一帧的渲染前调用你的回调这样你的逻辑更新和屏幕刷新是同步的不会出现撕裂或者不必要的重绘。而setInterval的问题是它和渲染不同步而且标签页切到后台时会被节流到最低 1 秒一次游戏直接卡死。但 rAF 也有个坑它传给你的时间戳精度在不同浏览器里不一样而且高刷屏上帧率不稳定。所以我的做法是固定时间步长 累加器模式const FIXED_STEP 1000 / 60; // 逻辑固定 60Hz let accumulator 0; let lastTime 0; function loop(timestamp) { if (!lastTime) lastTime timestamp; let delta timestamp - lastTime; lastTime timestamp; // 防止切后台回来后的巨大 delta 导致追帧爆炸 if (delta 250) delta FIXED_STEP; accumulator delta; while (accumulator FIXED_STEP) { update(FIXED_STEP); // 逻辑更新固定步长 accumulator - FIXED_STEP; } render(accumulator / FIXED_STEP); // 渲染插值 requestAnimationFrame(loop); }这个模式的关键在于逻辑更新和渲染解耦。逻辑永远以 60Hz 的固定步长跑保证物理模拟的确定性渲染则用插值系数把状态平滑到当前实际帧。这样在 120Hz 屏幕上游戏不会变快在掉帧时物理也不会失真。这个确定性对后面 WebRTC 的 P2P 同步至关重要因为两端必须用同样的步长才能保证状态一致。2.2 事件系统怎么做到又小又够用零依赖意味着不能用 EventEmitter 库但游戏里到处都需要事件输入事件、状态变更、网络消息。我的做法是写一个极简的发布订阅核心就三个方法class Emitter { constructor() { this._map new Map(); } on(type, fn) { if (!this._map.has(type)) this._map.set(type, new Set()); this._map.get(type).add(fn); return () this.off(type, fn); // 返回取消订阅函数 } off(type, fn) { this._map.get(type)?.delete(fn); } emit(type, payload) { const set this._map.get(type); if (!set) return; for (const fn of set) fn(payload); } }这里有个细节值得说on返回一个取消订阅的函数而不是让你自己保存引用去调off。这个模式在 React 的useEffect里特别顺手因为 cleanup 直接返回这个函数就行。另外用Set而不是数组是为了防止同一个函数被重复注册导致触发多次——这个 bug 我在早期版本里踩过调试了半天才发现是监听器重复绑定。注意emit里遍历Set的时候如果回调里又调用了on或off会改变集合结构。虽然 JS 的Set迭代器对删除是安全的但新增的元素会在本次迭代中被访问到。如果不想这样可以先Array.from(set)拷贝一份再遍历代价是一点性能开销。游戏里高频事件比如每帧的输入建议用拷贝低频事件直接用原集合。2.3 状态管理为什么不用 Redux 那套游戏状态和 UI 状态是两回事。Redux 那套不可变更新、action/reducer 的模式用在游戏里会带来大量对象分配每帧都在创建新对象GC 压力巨大。OmniGame 用的是可变状态 脏标记的方案。核心思路是游戏实体entity就是普通对象直接改属性。但每个实体有一个dirty标记只有被改过的实体才会在渲染阶段被处理。对于需要同步到网络的状态再额外维护一个待同步字段列表。const player { x: 0, y: 0, vx: 0, vy: 0, hp: 100, _dirty: true, _syncFields: [x, y, hp] }; function movePlayer(dx, dy) { player.x dx; player.y dy; player._dirty true; }这个方案的好处是零分配、零抽象开销。坏处是你得自己管好什么时候标脏、什么时候清脏。我的经验是在 update 阶段末尾统一清脏而不是改一次清一次这样一帧内的多次修改只触发一次渲染。2.4 资源加载的零依赖方案游戏资源无非就是图片、音频、JSON 配置。零依赖意味着不能用 PIXI 的 loader 或者 three 的 LoadingManager得自己写。核心就是一个 Promise 化的加载器function loadImage(src) { return new Promise((resolve, reject) { const img new Image(); img.onload () resolve(img); img.onerror () reject(new Error(Failed: ${src})); img.src src; }); } async function loadAll(manifest) { const entries await Promise.all( Object.entries(manifest).map(async ([key, src]) { const img await loadImage(src); return [key, img]; }) ); return Object.fromEntries(entries); }这里有个实战技巧图片解码要用img.decode()。onload只代表图片数据下载完了不代表解码完了。如果直接拿onload的图片去drawImage第一帧可能会因为同步解码而卡顿。正确做法是onload之后再await img.decode()确保解码完成再进游戏。img.onload async () { await img.decode(); // 确保解码完成 resolve(img); };这个细节在移动端尤其重要因为移动设备的解码性能差异很大不做这一步首帧卡顿会非常明显。3. Shadow DOM 做样式隔离的真实收益与代价3.1 为什么 iframe 不是好选择说到样式隔离很多人第一反应是 iframe。iframe 确实隔离得彻底但它的问题也很致命通信成本高、性能开销大、SEO 不友好、无法共享主文档的字体和资源。对于一个小游戏来说iframe 里再跑一个完整的文档内存占用直接翻倍而且父子通信要走postMessage异步且序列化做高频交互很别扭。Shadow DOM 是更现代的选择。它把一棵 DOM 子树封装在一个 shadow root 里外部的 CSS 选择器进不来内部的样式也出不去除非用 CSS 自定义属性或者::part显式暴露。对于游戏 UI 来说这正好是我们想要的游戏内部的按钮、面板、HUD 有自己的样式体系不受宿主页面影响也不会污染宿主页面。3.2 attachShadow 的 mode 选择创建 shadow root 的时候有个mode参数open还是closed这个选择很多人纠结。我的建议是游戏场景一律用open。const host document.getElementById(game-host); const shadow host.attachShadow({ mode: open });closed模式下外部通过host.shadowRoot拿到的是null看起来更封装。但实际上这个封装很脆弱而且会给你自己带来麻烦调试的时候拿不到 shadow root测试的时候没法查询内部元素做主题切换的时候没法从外部注入样式。open模式下你依然可以通过shadowRoot访问但正常代码不会去碰它封装性靠约定而不是靠强制。工程上可调试性比理论上的封装更重要。3.3 样式注入的两种方式Shadow DOM 内部的样式有两种注入方式style标签和CSSStyleSheetadoptedStyleSheets。前者兼容性好后者性能好且可复用。// 方式一style 标签 const style document.createElement(style); style.textContent .hud { position: absolute; top: 8px; }; shadow.appendChild(style); // 方式二adoptedStyleSheets推荐 const sheet new CSSStyleSheet(); sheet.replaceSync(.hud { position: absolute; top: 8px; }); shadow.adoptedStyleSheets [sheet];adoptedStyleSheets的优势在于同一个CSSStyleSheet对象可以被多个 shadow root 共享浏览器只需要解析一次。如果你有多个游戏实例比如一个页面里嵌了好几个小游戏这个优化很值。不过要注意adoptedStyleSheets在旧版本浏览器里支持不全需要做特性检测if (adoptedStyleSheets in Document.prototype) { shadow.adoptedStyleSheets [sheet]; } else { const style document.createElement(style); style.textContent cssText; shadow.appendChild(style); }3.4 事件穿透与焦点管理的坑Shadow DOM 有个很容易踩的坑事件重定向。当 shadow 内部的事件冒泡到宿主元素时event.target会被重定向为宿主元素而不是实际触发的内部元素。这在做事件委托的时候会出问题。解决办法是用event.composedPath()它返回完整的事件路径包含 shadow 内部的元素host.addEventListener(click, (e) { const path e.composedPath(); const realTarget path[0]; // 实际点击的元素 if (realTarget.classList.contains(start-btn)) { startGame(); } });另一个坑是焦点管理。shadow 内部的元素获得焦点时document.activeElement会指向宿主元素而不是内部元素。如果你用document.activeElement判断当前焦点会得到错误结果。正确做法是用shadow.activeElement。还有键盘事件。如果游戏需要捕获方向键要注意方向键默认会滚动页面。在 shadow 内部监听keydown并preventDefault是有效的但前提是焦点在 shadow 内部。如果焦点在宿主页面事件根本不会进到 shadow 里。所以游戏启动时要主动把焦点移到 shadow 内的一个可聚焦元素上比如给容器加tabindex0然后focus()。4. Next.js 在游戏工程里的角色定位4.1 为什么游戏项目也需要 Next.js有人会问一个纯客户端的小游戏要 Next.js 干嘛直接 Vite 打包一个静态页面不就行了这个问题的答案取决于你的分发场景。如果你的游戏是独立页面Vite 确实够了。但如果你的游戏要嵌入到一个内容站点里或者你需要落地页、排行榜、用户中心这些周边页面Next.js 的价值就出来了文件路由游戏页、落地页、排行榜页各自独立不用配路由SSG/SSR落地页可以静态生成首屏快利于分享传播API Routes需要轻量后端的时候比如排行榜存储、房间信令可以直接写图片优化next/image自动处理游戏截图、图标最关键的是API Routes 可以做 WebRTC 的信令服务器。WebRTC 建立连接需要交换 SDP 和 ICE candidate这个交换过程需要一个信令通道。用 Next.js 的 API Routes 写一个极简的信令端点不需要额外的服务器部署在一起就行。4.2 游戏内核必须动态加载Next.js 默认会做 SSR但游戏内核依赖window、document、Canvas这些浏览器 API在服务端渲染时会直接报错。所以游戏组件必须用动态导入 禁用 SSRimport dynamic from next/dynamic; const GameCanvas dynamic(() import(../components/GameCanvas), { ssr: false, loading: () div classNamegame-skeleton加载中.../div });ssr: false确保这个组件只在客户端渲染。loading提供一个骨架屏避免白屏。这里有个细节骨架屏的尺寸要和游戏画布一致否则加载完成时会有布局跳动CLS影响体验。4.3 信令端点的最小实现WebRTC 的信令交换本质上就是两个客户端通过一个中间人互相传递消息。用 Next.js API Routes 实现核心就是一个内存里的房间表// pages/api/signal.js const rooms new Map(); // roomId - [peerA, peerB] export default function handler(req, res) { const { roomId, peerId, type, payload } req.body; if (!rooms.has(roomId)) rooms.set(roomId, []); const room rooms.get(roomId); if (type join) { room.push({ peerId, messages: [] }); res.json({ ok: true, peers: room.length }); return; } // 把消息投递给房间里的其他 peer for (const peer of room) { if (peer.peerId ! peerId) { peer.messages.push({ from: peerId, type, payload }); } } res.json({ ok: true }); }这个实现很粗糙生产环境要考虑房间过期清理、消息拉取的长轮询或 SSE。但对于验证 P2P 连接来说够用了。关键点是信令只负责交换连接信息一旦 P2P 通道建立游戏数据就不再经过服务器。这就是 P2P 架构省成本的核心。注意这个内存方案在 Serverless 环境下不可靠因为不同请求可能落到不同实例。生产环境要么用支持粘性会话的部署要么把房间状态放到外部存储Redis 之类。验证阶段用本地next dev跑没问题。4.4 构建产物的体积控制Next.js 默认的构建产物对游戏来说偏大因为带了很多框架运行时。几个优化点游戏内核用独立的入口不要和页面框架混在一个 chunk用next.config.js的webpack配置做代码分割关闭不必要的 polyfill现代浏览器不需要图片资源走 CDN不要打进 bundle我实测一个包含游戏内核 落地页的项目优化后首屏 JS 可以控制在 80KB 左右gzip其中游戏内核占 30KB框架运行时占 50KB。如果对首屏要求极致可以把游戏内核做成按需加载落地页只加载框架。5. WebRTC P2P 联机的完整落地路径5.1 P2P 到底省掉了什么传统联机方案是客户端-服务器-客户端A 的操作发给服务器服务器广播给 B。这个模型下服务器是瓶颈也是成本中心。P2P 方案是 A 和 B 直接建立数据通道A 的操作直接发给 B不经过服务器。省掉的是服务器带宽成本、服务器运维、中心节点的单点故障。代价是NAT 穿透的复杂性、没有权威状态作弊问题、连接数受限于每个客户端的带宽。对于小游戏来说这个取舍是划算的。因为小游戏的玩家数量少通常 2-4 人状态量小作弊动机低。用 P2P 换掉服务器成本对小项目来说是刚需。5.2 RTCPeerConnection 的建立流程建立 P2P 连接的标准流程是这样的A 创建RTCPeerConnection创建 data channelA 调用createOffer()生成 SDPsetLocalDescription()A 把 SDP 通过信令发给 BB 收到后setRemoteDescription()调用createAnswer()生成应答 SDPB 把应答 SDP 通过信令发回 AAsetRemoteDescription()应答双方通过 ICE 收集网络候选地址互相交换找到可通的路径后连接建立data channel 打开代码骨架const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.example.com:3478 }] }); const channel pc.createDataChannel(game, { ordered: false, // 游戏状态不需要严格有序 maxRetransmits: 0 // 不重传丢包就丢包 }); channel.onopen () console.log(P2P 通道已建立); channel.onmessage (e) handleRemoteState(JSON.parse(e.data)); // 发起方 const offer await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ type: offer, payload: offer }); // 接收方 pc.ondatachannel (e) { const ch e.channel; ch.onmessage (ev) handleRemoteState(JSON.parse(ev.data)); };5.3 data channel 的可靠性配置createDataChannel的配置直接决定了游戏的手感。这里有两个关键参数配置值适用场景ordered: true有序聊天、指令、关键事件ordered: false无序位置同步、高频状态maxRetransmits: 0不重传实时位置丢包就丢maxRetransmits: N重传 N 次关键操作maxPacketLifeTime: NN 毫秒内重传时效性操作对于实时对战游戏位置同步用无序 不重传因为旧的位置数据没有意义重传只会增加延迟。关键操作比如开火、使用道具用有序 重传因为这些不能丢。实际项目里我会开两个 data channel一个reliable通道走关键事件一个unreliable通道走高频状态。这样各取所需互不干扰。5.4 状态同步的插值与预测P2P 联机最难的不是建立连接而是让两端的画面看起来一致。因为网络有延迟B 看到的 A 的位置永远是过去的。如果不做处理A 的移动会一顿一顿的。解决方案是插值B 收到 A 的位置后不立即显示而是缓存在一个缓冲区里延迟一小段时间比如 100ms再渲染。这样即使有抖动渲染出来的运动也是平滑的。const INTERP_DELAY 100; // ms const buffer []; // { time, x, y } function onRemoteState(state) { buffer.push({ time: performance.now(), ...state }); // 只保留最近 1 秒的数据 while (buffer.length 0 buffer[0].time performance.now() - 1000) { buffer.shift(); } } function getInterpolated(now) { const renderTime now - INTERP_DELAY; // 找到 renderTime 前后的两个状态做线性插值 for (let i 0; i buffer.length - 1; i) { if (buffer[i].time renderTime buffer[i 1].time renderTime) { const t (renderTime - buffer[i].time) / (buffer[i 1].time - buffer[i].time); return { x: buffer[i].x (buffer[i 1].x - buffer[i].x) * t, y: buffer[i].y (buffer[i 1].y - buffer[i].y) * t }; } } return buffer[buffer.length - 1] || { x: 0, y: 0 }; }本地玩家则用预测本地输入立即生效不等服务器确认。这样操作手感是零延迟的。代价是可能出现回滚——如果远端的状态和本地预测不一致需要修正。对于小游戏简单的做法是本地玩家永远以本地为准远端玩家用插值不做严格的一致性校验。这样手感最好代价是极端情况下两端画面会有细微差异但小游戏可以接受。5.5 连接失败时的降级策略P2P 不是万能的。有些网络环境下 NAT 穿透会失败尤其是对称 NAT这时候需要 TURN 中继。TURN 服务器会转发流量等于退化成客户端-服务器模式但至少能连上。const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.example.com:3478 }, { urls: turn:turn.example.com:3478, username: user, credential: pass } ] });ICE 的流程是先试直连host candidate不行试 STUN 打洞srflx candidate再不行走 TURN 中继relay candidate。所以配置里同时给 STUN 和 TURN浏览器会自动按优先级尝试。我的经验是监控pc.oniceconnectionstatechange如果状态长时间停留在checking或者变成failed就提示用户网络环境不支持 P2P降级到单人模式或者提示切换网络。不要傻等用户体验很差。6. 实测中暴露的问题与修复记录6.1 高刷屏上的物理失真最早版本我用的是可变步长delta直接传给物理更新。结果在 120Hz 屏幕上游戏速度明显比 60Hz 快。原因是物理公式里有些地方用了delta的平方比如加速度积分可变步长下积分误差会累积。改成固定步长 累加器之后这个问题彻底消失。物理模拟必须用固定步长这是铁律。渲染可以用可变帧率但逻辑不行。6.2 Shadow DOM 里的 Canvas 尺寸问题Canvas 在 shadow DOM 里有个坑getBoundingClientRect()返回的尺寸是对的但如果你用 CSS 设置了width: 100%Canvas 的实际像素尺寸canvas.width不会自动跟着变导致画面模糊。正确做法是监听ResizeObserver在尺寸变化时手动同步const ro new ResizeObserver((entries) { const rect entries[0].contentRect; const dpr window.devicePixelRatio || 1; canvas.width rect.width * dpr; canvas.height rect.height * dpr; canvas.style.width rect.width px; canvas.style.height rect.height px; ctx.scale(dpr, dpr); // 处理高分屏 }); ro.observe(canvas);devicePixelRatio这一步不能省否则在 Retina 屏上画面会糊。但要注意ctx.scale会累积每次 resize 前要先ctx.setTransform(1, 0, 0, 1, 0, 0)重置。6.3 信令消息的竞态WebRTC 建立连接时ICE candidate 是异步产生的可能在 SDP 交换完成之前就产生了。如果信令通道还没准备好这些 candidate 就丢了导致连接失败。解决办法是缓存 candidate等setRemoteDescription完成后再批量添加const pendingCandidates []; pc.onicecandidate (e) { if (e.candidate) { if (pc.remoteDescription) { sendSignal({ type: candidate, payload: e.candidate }); } else { pendingCandidates.push(e.candidate); } } }; // setRemoteDescription 之后 await pc.setRemoteDescription(remoteDesc); for (const c of pendingCandidates) { await pc.addIceCandidate(c); } pendingCandidates.length 0;这个 bug 很隐蔽因为它在网络好的时候不出现网络差的时候才暴露。我是在弱网测试时才发现的。6.4 内存泄漏事件监听器没清理游戏销毁时如果 rAF 循环还在跑、事件监听器还在就会内存泄漏。尤其是单页应用里反复进出游戏页面泄漏会累积。我的做法是给游戏实例一个destroy()方法统一清理class Game { constructor() { this._rafId null; this._cleanups []; } start() { const loop (t) { /* ... */ this._rafId requestAnimationFrame(loop); }; this._rafId requestAnimationFrame(loop); } destroy() { cancelAnimationFrame(this._rafId); this._cleanups.forEach(fn fn()); this._cleanups.length 0; this.pc?.close(); } }所有addEventListener、on订阅、ResizeObserver都要注册到_cleanups里。这个模式虽然土但很有效。React 里就在useEffect的 cleanup 里调destroy()。7. 从这套架构里能带走什么OmniGame 这套东西本质上是在回答一个问题在不依赖重型框架和中心化服务器的前提下网页小游戏的工程上限能推到多高。我的答案是足够高。零依赖运行时把体积压到极致Shadow DOM 解决了嵌入和隔离Next.js 提供了工程化和信令能力WebRTC P2P 把联机成本降到接近零。如果你要落地类似方案我的建议是分阶段来。第一阶段先把零依赖运行时和 Shadow DOM 跑通做一个单机小游戏验证渲染循环、事件系统、资源加载这些基础设施。第二阶段接入 Next.js把游戏嵌进页面跑通信令端点。第三阶段再上 WebRTC先做两个客户端的连接验证再做状态同步和插值。不要一上来就全上那样出问题很难定位。最后分享一个我在调试 P2P 时的小技巧Chrome 的chrome://webrtc-internals页面能看到所有 RTCPeerConnection 的详细状态包括 ICE candidate 的收集情况、连接状态变化、data channel 的收发统计。排查连接问题时这个页面比打日志高效得多。另外测试 P2P 一定要在真实的双设备环境下测同一台机器开两个标签页测不出 NAT 穿透的问题。