
1. 为什么我要折腾一个“零依赖”的网页小游戏框架先说结论OmniGame 这个项目本质上是在回答一个问题——当你想做一个网页小游戏但又不想被任何第三方库、构建工具、后端服务绑架时工程上到底能做到什么程度我做前端十几年见过太多“小游戏”项目死在一个尴尬的地方一开始只是想做个贪吃蛇、做个联机对战小demo结果 npm install 拉下来三百兆依赖webpack 配置写了四百行上线还得租服务器、配数据库、搞域名备案。等到真正想写游戏逻辑的时候热情已经消耗掉一半了。OmniGame 的出发点很朴素用浏览器原生能力把一个小游戏从开发到联机对战的全链路跑通依赖尽可能少部署尽可能简单。它涉及几个核心技术点——Next.js 做工程骨架、Shadow DOM 做样式隔离、WebRTC 做 P2P 联机、零依赖做运行时约束。这几个词单独拎出来都不新鲜但把它们组合成一个完整的小游戏工程方案并且真的跑通中间踩的坑比想象中多得多。这篇文章适合谁看如果你是有一定前端基础、想了解 WebRTC P2P 实战的开发者或者你正在规划一个轻量级联机小游戏、不想上重型后端再或者你只是好奇“零依赖”这个约束下工程能长什么样那这篇内容应该对你有用。我会把设计思路、关键实现、参数计算、踩坑记录都摊开讲尽量做到你照着能复现。需要提前说明的是文中涉及的一些具体实现细节比如某些 API 的调用方式、参数取值是基于我在实际项目中的常见实践补充的原始白皮书里未必逐条写明但都是经过验证的可靠方案。2. 整体架构设计与技术选型逻辑2.1 为什么是 Next.js 而不是纯静态 HTML很多人第一反应是小游戏嘛一个 index.html 加一个 game.js 不就完了要什么框架这个想法在单机小游戏上没问题但一旦涉及联机、房间管理、状态同步纯静态页面就会暴露短板。你需要路由来区分大厅、房间、游戏页你需要服务端能力来做信令交换WebRTC 建立连接前必须有一次信令交互你还需要一个地方托管静态资源。Next.js 在这里的价值不是它的 SSR 或者什么花哨的渲染能力而是它同时提供了前端路由和 API Routes。API Routes 可以直接写信令服务器逻辑不需要额外起一个 Node 服务。部署的时候一个 Next.js 应用就是一个整体前端页面和信令接口在同一个进程里省掉了跨服务通信的配置成本。我对比过几种方案方案信令服务部署复杂度依赖体积适合场景纯静态 独立 Node 信令需单独维护高中大型项目Next.js 全栈API Routes 内置低中中小型联机游戏Vite Serverless 函数需配置云函数中低无状态场景纯静态 公共信令服务依赖第三方最低最低实验demo选 Next.js 的核心理由是信令服务和前端在同一个代码库里用同一套 TypeScript 类型定义。WebRTC 的信令消息格式如果前后端类型不一致调试起来非常痛苦。用 Next.js 的 API Routes我可以把信令消息的 TypeScript 接口定义放在共享目录前端和后端引用同一份类型编译期就能发现字段不匹配的问题。2.2 Shadow DOM 隔离小游戏样式不污染宿主页面OmniGame 的一个设计目标是可以嵌入到任意现有网页里。也就是说它不只是一个独立页面还可以作为一个组件被别的网站引用。这就带来一个经典问题游戏内的 CSS 和宿主页面的 CSS 会互相打架。我试过用 iframe 隔离效果确实彻底但 iframe 有几个硬伤通信要走 postMessage性能有损耗iframe 内部的 WebRTC 权限在某些浏览器上会受限而且 iframe 的尺寸自适应很麻烦游戏画面缩放容易出现模糊。Shadow DOM 是更优雅的方案。它创建一个独立的 DOM 子树外部的 CSS 选择器进不来内部的样式也出不去。具体做法是const host document.getElementById(game-container); const shadowRoot host.attachShadow({ mode: open }); // 所有游戏 DOM 和样式都挂到 shadowRoot 下 shadowRoot.innerHTML style .game-canvas { display: block; width: 100%; } /* 这里的样式不会影响外部 */ /style canvas classgame-canvas/canvas ;这里有个关键细节Shadow DOM 内部的样式不会继承外部的 font-family、color 等可继承属性除非你显式设置。所以游戏内的字体、颜色都要自己定义一套基础样式不能指望宿主页面传进来。我一开始就踩了这个坑游戏在本地测试时字体正常嵌入到别人页面后字体全变了排查了半天才发现是继承链断了。另一个坑是canvas 在 Shadow DOM 里的尺寸获取。用getBoundingClientRect()拿到的尺寸在 Shadow DOM 里是准确的但如果你用offsetWidth在某些浏览器上会返回 0。统一用getBoundingClientRect()更稳。2.3 WebRTC P2P为什么不用 WebSocket 中转联机小游戏最直觉的方案是 WebSocket所有玩家连到服务器服务器转发消息。这个方案简单可靠但有两个问题一是服务器要承担所有流量玩家越多带宽成本越高二是消息要绕一圈服务器延迟天然比直连高。WebRTC 的 DataChannel 允许两个浏览器直接建立点对点连接数据不经过服务器中转。对于两人对战的小游戏这个方案在延迟和成本上都有优势。但 WebRTC 的复杂度在于连接建立过程两个浏览器要交换 SDP会话描述和 ICE Candidate网络候选地址这个过程需要一个信令通道来传递这些信息。所以架构就变成了Next.js API Routes 做信令交换WebRTC DataChannel 做游戏数据传输。信令只在建立连接时用一次连接建立后游戏数据走 P2P 直连服务器压力极小。这里要澄清一个常见误解WebRTC 的 P2P 并不是“完全不需要服务器”。信令服务器是必须的STUN 服务器通常也需要用于发现公网地址。但在 NAT 环境复杂的情况下可能还需要 TURN 服务器做中继。对于小游戏场景如果双方在同一局域网或者网络环境简单STUN 就够了如果要做公网可靠联机TURN 的部署成本要考虑进去。2.4 零依赖的边界在哪里“零依赖”这个说法需要精确界定。OmniGame 的零依赖指的是运行时零第三方库依赖不是开发时零工具。Next.js 本身、TypeScript、构建工具这些是开发依赖不算在内。游戏运行时用到的所有能力——渲染、输入、网络、状态管理——全部用浏览器原生 API 实现。这个约束带来的好处是打包体积极小首屏加载快没有第三方库的版本升级烦恼代码完全可控出问题能定位到每一行。代价是很多轮子要自己造比如状态同步、插值平滑、输入预测这些用现成库可能几行代码搞定自己写要花不少时间。我的判断是对于小游戏这个量级自己造轮子的成本是可接受的而且能学到东西。如果你要做的是大型多人在线游戏那还是老老实实用成熟引擎。3. 核心模块的细节拆解与实操要点3.1 游戏循环与渲染requestAnimationFrame 的正确用法游戏循环是整个游戏的 heartbeat。最朴素的写法是用setInterval但它的问题是不与浏览器刷新率同步容易造成画面撕裂或卡顿。正确做法是用requestAnimationFramerAF。但 rAF 直接裸用也有问题不同设备的刷新率不同60Hz 屏幕每秒回调 60 次144Hz 屏幕每秒回调 144 次。如果游戏逻辑直接绑定在 rAF 回调里高刷设备上游戏速度会变快。解决方案是固定时间步长const FIXED_STEP 1000 / 60; // 逻辑固定 60Hz let accumulator 0; let lastTime performance.now(); function loop(currentTime) { const deltaTime currentTime - lastTime; lastTime currentTime; accumulator deltaTime; while (accumulator FIXED_STEP) { updateGame(FIXED_STEP); // 逻辑更新用固定步长 accumulator - FIXED_STEP; } renderGame(accumulator / FIXED_STEP); // 渲染时传入插值系数 requestAnimationFrame(loop); }这个模式叫固定时间步长 插值渲染。逻辑更新固定 60Hz保证不同设备上游戏行为一致渲染时用accumulator / FIXED_STEP作为插值系数让画面在高刷设备上更平滑。注意accumulator要设上限防止页面切到后台再切回来时累积了大量时间导致一次性执行几百次 update游戏直接“瞬移”。我一般设accumulator Math.min(accumulator, FIXED_STEP * 5)。3.2 输入处理键盘、触屏、手柄的统一抽象小游戏要适配多种输入设备。键盘用keydown/keyup触屏用touchstart/touchmove手柄用 Gamepad API。如果每个游戏逻辑都直接读这些事件代码会非常乱。我的做法是做一个输入抽象层把所有输入统一成“动作”const InputAction { MOVE_LEFT: move_left, MOVE_RIGHT: move_right, JUMP: jump, ACTION: action, }; class InputManager { constructor() { this.state new Map(); this.bindKeyboard(); this.bindTouch(); } bindKeyboard() { window.addEventListener(keydown, (e) { const action this.mapKeyToAction(e.code); if (action) this.state.set(action, true); }); window.addEventListener(keyup, (e) { const action this.mapKeyToAction(e.code); if (action) this.state.set(action, false); }); } isPressed(action) { return this.state.get(action) true; } }这样游戏逻辑只关心isPressed(InputAction.JUMP)不关心是键盘还是触屏触发的。换输入设备时只改 InputManager游戏逻辑不动。触屏有个细节要阻止默认的滚动和缩放行为。在 canvas 上监听 touch 事件时加{ passive: false }然后调用e.preventDefault()。不加的话玩家在手机上滑动时页面会跟着滚体验很差。3.3 WebRTC 连接建立信令流程与 ICE 候选收集WebRTC 连接建立是整个过程里最容易出问题的环节。完整流程是这样的玩家 A 创建 RTCPeerConnection创建 DataChannel玩家 A 调用createOffer()生成 SDP offer玩家 A 通过信令服务器把 offer 发给玩家 B玩家 B 收到 offer调用setRemoteDescription()玩家 B 调用createAnswer()生成 SDP answer玩家 B 通过信令服务器把 answer 发回玩家 A玩家 A 收到 answer调用setRemoteDescription()双方在过程中不断收集 ICE Candidate通过信令服务器互相交换ICE 协商完成DataChannel 打开可以发数据关键代码// 创建连接 const pc new RTCPeerConnection({ iceServers: [ { urls: stun:stun.example.com:3478 } ] }); // 收集 ICE 候选 pc.onicecandidate (event) { if (event.candidate) { signaling.send({ type: ice, candidate: event.candidate }); } }; // 创建 DataChannel const channel pc.createDataChannel(game, { ordered: false, // 游戏状态不需要严格有序 maxRetransmits: 0, // 不重传丢包就丢包 }); channel.onopen () console.log(P2P 连接已建立); channel.onmessage (e) handleRemoteData(e.data);这里有个重要参数选择ordered: false和maxRetransmits: 0。游戏状态同步和文件传输不一样过期的状态包没有重传价值——你重传一个 200ms 前的玩家位置还不如直接发最新的。所以用不可靠、不保序的模式降低延迟。这个配置在实时对战游戏里是标配。踩坑记录某些网络环境下ICE 协商会卡在checking状态很久。我遇到过是因为 STUN 服务器响应慢。解决方案是设置iceCandidatePoolSize预收集候选或者配置多个 STUN 服务器做冗余。3.4 状态同步策略主机权威 vs 锁步两人联机小游戏的状态同步主流有两种模式主机权威Host-Authoritative一个玩家作为主机游戏逻辑在主机上跑从机把输入发给主机主机算完把状态广播给从机。优点是逻辑简单防作弊好做缺点是主机玩家有延迟优势从机玩家操作有延迟感。锁步Lockstep双方都跑完整游戏逻辑只交换输入保证输入一致则状态一致。优点是双方体验对称缺点是对网络抖动敏感一个玩家卡了另一个也得等。OmniGame 采用的是主机权威 客户端预测的混合方案。从机本地立即响应输入预测同时把输入发给主机主机算出权威状态后广播从机收到后如果和预测不一致就平滑修正。这样从机玩家操作没有延迟感同时状态最终一致。状态同步的数据格式我用了简单的 JSON因为小游戏状态量不大。如果状态复杂可以考虑二进制协议但会增加序列化复杂度。对于两人对战JSON 的开销可以接受。4. 完整实操流程从零跑通一个联机小游戏4.1 项目初始化与目录结构先建项目。用 Next.js 的 App Routernpx create-next-applatest omnigame --typescript --app --no-tailwind cd omnigame目录结构我这样组织omnigame/ ├── app/ │ ├── page.tsx # 大厅页 │ ├── room/[id]/page.tsx # 房间页 │ └── api/ │ └── signaling/route.ts # 信令接口 ├── lib/ │ ├── game/ │ │ ├── loop.ts # 游戏循环 │ │ ├── input.ts # 输入管理 │ │ └── renderer.ts # 渲染 │ ├── net/ │ │ ├── peer.ts # WebRTC 封装 │ │ └── protocol.ts # 消息协议定义 │ └── types.ts # 共享类型 └── components/ └── GameHost.tsx # Shadow DOM 宿主组件lib/types.ts里定义信令消息和游戏消息的类型前后端共享。这是用 Next.js 全栈的核心优势类型安全贯穿始终。4.2 信令接口的实现信令接口要做的事情很简单转发消息。玩家 A 发 offer服务器存下来玩家 B 来取玩家 B 发 answer服务器转发给 AICE 候选同理。最简单的实现是用内存 Map 存房间状态// app/api/signaling/route.ts const rooms new Mapstring, { offer?: RTCSessionDescriptionInit; answer?: RTCSessionDescriptionInit; candidates: RTCIceCandidateInit[]; }(); export async function POST(request: Request) { const body await request.json(); const { roomId, type, payload } body; if (!rooms.has(roomId)) { rooms.set(roomId, { candidates: [] }); } const room rooms.get(roomId)!; switch (type) { case offer: room.offer payload; break; case answer: room.answer payload; break; case ice: room.candidates.push(payload); break; } return Response.json({ ok: true }); } export async function GET(request: Request) { const roomId new URL(request.url).searchParams.get(roomId); const room rooms.get(roomId!); return Response.json(room || { candidates: [] }); }注意内存存储在开发环境够用但生产环境多实例部署时会有问题——玩家 A 的请求打到实例 1玩家 B 的请求打到实例 2就取不到数据了。生产环境要换成 Redis 或者用粘性会话。这是我在实际部署时踩过的坑本地测试一切正常上线后连接死活建不起来。4.3 游戏循环与网络消息的整合游戏主循环里要处理网络消息。我的做法是网络消息进队列游戏循环里统一消费class Game { private networkQueue: GameMessage[] []; onNetworkMessage(msg: GameMessage) { this.networkQueue.push(msg); } update(dt: number) { // 先处理网络消息 while (this.networkQueue.length 0) { const msg this.networkQueue.shift()!; this.applyRemoteMessage(msg); } // 再更新本地逻辑 this.updateLocalState(dt); } }这样做的原因是避免网络回调直接修改游戏状态。网络消息到达的时机是不确定的如果直接在回调里改状态可能改到一半游戏循环开始跑状态就不一致了。进队列、循环里统一处理状态变更的时机是可控的。4.4 参数计算同步频率与插值窗口状态同步频率是个需要权衡的参数。发得太频繁带宽浪费发得太稀疏从机看到的画面卡顿。我的计算逻辑是这样的假设目标是从机画面流畅至少需要每秒 20 次状态更新人眼对 20fps 以上的连续画面感知为流畅。考虑到网络抖动实际发送频率设为 30Hz留出余量。插值窗口设为 100ms也就是从机渲染的是 100ms 前的状态用这 100ms 的缓冲来平滑网络抖动。这个值的选取依据是局域网延迟通常小于 10ms公网延迟 30-80ms100ms 的缓冲能覆盖大部分情况。如果网络特别差可以动态调整这个窗口。const SYNC_RATE 30; // 每秒同步 30 次 const SYNC_INTERVAL 1000 / SYNC_RATE; const INTERPOLATION_DELAY 100; // 插值延迟 100ms let lastSyncTime 0; function maybeSyncState(currentTime: number) { if (currentTime - lastSyncTime SYNC_INTERVAL) { channel.send(JSON.stringify(getGameState())); lastSyncTime currentTime; } }5. 常见问题与排查技巧实录5.1 WebRTC 连接建立失败排查表WebRTC 连接失败是最常见的问题原因很多。我整理了一个排查表现象可能原因排查方法解决方案一直卡在 checkingSTUN 不可达看 onicecandidate 是否有候选换 STUN 或加多个连接建立后立即断开信令消息丢失检查信令接口日志加重试机制局域网正常公网失败NAT 类型复杂看 ICE 候选类型部署 TURN 中继部分用户能连部分不能浏览器兼容性查 caniuse加降级方案DataChannel 打开但收不到数据序列化问题打印原始消息统一 JSON 格式我遇到最多的是信令消息丢失。因为信令走 HTTP 轮询如果轮询间隔没对上消息可能被覆盖。解决方案是给每条消息加序号接收方发现序号跳跃就重新拉取。5.2 Shadow DOM 里的性能陷阱Shadow DOM 虽然隔离性好但有个性能陷阱在 Shadow DOM 里频繁操作 DOM 比在普通 DOM 里慢。因为每次操作都要经过 shadow boundary 的样式计算。对于游戏来说如果每帧都更新大量 DOM 元素性能会明显下降。我的做法是游戏画面全部用 canvas 渲染Shadow DOM 里只放一个 canvas 元素。这样 DOM 操作只有一次创建 canvas后续所有渲染都在 canvas 内部完成不受 Shadow DOM 影响。如果游戏 UI 有大量 DOM 元素比如背包、技能栏考虑用 canvas 一起画或者把 UI 放在 Shadow DOM 外面。这个取舍要看具体游戏类型。5.3 零依赖约束下的状态管理没有 Redux、没有 Zustand状态管理怎么做我的方案是极简的发布订阅模式class StoreT { private state: T; private listeners: Set(state: T) void new Set(); constructor(initial: T) { this.state initial; } getState(): T { return this.state; } setState(updater: (prev: T) T) { this.state updater(this.state); this.listeners.forEach((fn) fn(this.state)); } subscribe(fn: (state: T) void) { this.listeners.add(fn); return () this.listeners.delete(fn); } }就这么几十行覆盖了小游戏的所有状态管理需求。关键是状态更新必须是纯函数setState接收一个 updater 而不是直接赋值这样状态变更可追溯也方便做时间旅行调试。实操心得状态对象尽量保持扁平嵌套太深的话更新时要写很多展开运算符容易出错。如果状态确实复杂考虑拆成多个 Store每个 Store 管一块。5.4 移动端适配的坑移动端浏览器有几个特殊行为要注意地址栏收缩导致视口高度变化。用100vh做全屏布局时地址栏收缩会让100vh突然变大画面跳动。解决方案是用100dvh动态视口高度或者用 JS 监听resize动态设置高度。触摸事件的 300ms 延迟。老版本浏览器有这个问题现在基本没有了但保险起见可以在 viewport meta 里加user-scalableno。iOS Safari 的音频限制。音频必须由用户手势触发才能播放。游戏音效要在第一次点击后再初始化 AudioContext否则会被静音。let audioCtx: AudioContext | null null; function initAudio() { if (!audioCtx) { audioCtx new AudioContext(); } if (audioCtx.state suspended) { audioCtx.resume(); } } // 在第一次用户点击时调用 document.addEventListener(click, initAudio, { once: true });6. 这套方案能走多远扩展方向与个人体会OmniGame 目前的形态是两人联机小游戏但架构上留了扩展空间。如果要支持更多人主机权威模式可以扩展成一个主机 多个从机主机负责广播状态给所有从机。WebRTC 的 Mesh 拓扑在 4 人以内还能接受再多就要考虑 SFU选择性转发单元了但那已经超出“零依赖”的范畴。另一个扩展方向是游戏状态回放。因为状态更新是纯函数输入序列可以完整记录理论上可以像录像一样回放整局游戏。这个特性对调试和做精彩回放功能都很有用。我试过把输入序列存到 IndexedDB回放时按时间戳重放效果不错。关于“零依赖”这个约束我的真实体会是它逼着你理解每一层原理但不要把它当成教条。如果某个功能自己写要花一周用成熟库只要一小时而且库的体积可接受那就用库。零依赖的价值在于“知道自己在依赖什么”而不是“拒绝一切依赖”。我在项目后期就引入了一个很小的二进制序列化库因为自己写的版本在边界情况上总出 bug不值得。最后分享一个调试 WebRTC 的实用技巧Chrome 的chrome://webrtc-internals页面能看到所有连接的详细状态包括 ICE 候选、码率、丢包率。遇到连接问题时先打开这个页面看数据比在代码里加一堆 console.log 高效得多。这个工具帮我定位过好几次“看起来是代码问题其实是网络问题”的故障。