遇到过一个挺扎心的场景做一款网页小游戏明明代码量不大结果因为引了一堆依赖首屏加载比游戏本身还慢想加个联机功能又得租服务器搭转发通道用户一多服务器先撑不住。OmniGame 这个项目就是冲着这两个痛点去的——它把所有框架和依赖卸干净用纯 HTML、Canvas、JavaScript 手写游戏内核再通过 WebRTC 的 P2P 数据通道实现玩家之间的直接互联。最终交付物就是一个播种大小的静态文件浏览器打开就能玩联机数据不走中心服务器。这篇文章适合三类人一是被各类框架和打包工具折磨的小游戏开发者想看看到底多大的依赖是必需的二是想给网页游戏加实时对战、却受限于服务器带宽成本的独立开发者三是对 WebRTC 数据通道有兴趣、但不想啃大段英文文档的工程师。我会从零依赖的工程决策、手写游戏循环、P2P 建链、数据同步协议到最后踩过的坑完整还原这套方案从 0 到 1 的推进过程。1. 零依赖的开局为什么我把所有框架都卸了先说结论零依赖不是作秀不是“我厉害所以不用框架”而是对网页小游戏这个具体场景做的一次工程权衡。小游戏和大应用最大的区别在于资源预算——用户期望是“打开网页就能玩”不是“先等 3 秒加载框架再玩”。所以 OmniGame 的第一条铁律是最终产物尽量是单个 HTML 文件或纯静态文件不包含任何第三方代码游戏启动前不需要安装任何东西。1.1 零依赖的真正含义不止是“不用框架”很多人把零依赖理解成“不写 import 语句”其实只对了一半。OmniGame 要求的零依赖有三个层次产物零依赖最终交付物不引用任何 CDN、不加载任何外部 JS 文件所有代码内联在一个 HTML 里或者拆成同目录静态文件。构建零依赖不需要 webpack、vite、rollup 这类打包器也不需要 npm 安装流程。代码写完了直接扔到静态服务器上就是成品。运行时零依赖只依赖浏览器原生 API比如 Canvas 2D、requestAnimationFrame、WebSocket 和 WebRTC。这些都是 HTML5 标准能力不归任何一个框架所有。这里有个常见的疑问“不引入框架那 DOM 操作、动画、组件化怎么办”我的回答是小游戏本来就不该大量操作 DOM。传统网页游戏框架之所以要帮你管理 DOM是因为它们被浏览器 DOM 性能限制了。而 Canvas 2D 是一整块画布你直接往里面画像素级内容对象管理完全由自己的代码说了算根本不需要框架来抽象。1.2 三条路线的选型对比为什么要走最“笨”的路开发网页小游戏业内最常见的方案有三条路线方案典型代表交付产物联机能力维护成本纯静态 CanvasOmniGame 路线单个 HTML/静态文件原生 WebRTC/WebSocket最低原生 JS 少量库Phaser 轻量用法多个 JS 文件需自研或集成中等打包器 引擎框架Cocos / Unity WebGL大体积分包引擎自带抽象较高我承认 Phaser 这类框架能帮你省很多底层功夫但代价是你必须接受它的对象系统、场景管理系统和插件体系。项目一旦做大了升级框架版本本身就是一次大迁移。而 OmniGame 选择“最笨”的路反而是把可预测性拉满没有框架升级、没有依赖冲突、没有构建链断裂的风险。你写一版循环逻辑五年后浏览器新版本出来依然能跑。1.3 没有打包器怎么写代码ES Module 的工程化用法你可能会问“不用打包器代码全是 import 该怎么办”答案很简单用浏览器原生 ES Module。script typemodule import { Game } from ./js/game.js; import { InputSystem } from ./js/input.js; const game new Game(); game.start(); /script浏览器原生支持按需加载模块文件直接用相对路径互相引用。开发时本地起一个最简单的静态服务器就行比如python -m http.server 8080。这背后的工程关键是模块化的好处必须保留但打包这一步直接砍掉。发布时把根目录扔到任意静态托管上所有子路径关系不变。我当时花了一周时间测试这个模式的边界结论是只要模块数量控制在 20 个以内、每个模块职责单一代码组织和可维护性完全不输给用打包器的项目。做完这个验证之后OmniGame 的骨架就定了下来。2. 引擎内核的手写之路让游戏循环跑起来的底层逻辑零依赖只是工程前提真正要命的是把游戏引擎的核心——游戏循环、渲染、输入——全部自己写出来。这部分看着简单其实藏着几乎全部性能优化空间。OmniGame 的数据都是从用户输入到画面输出的完整链路每一步都由自己的代码掌控出了问题也能直接定位到某一行而不是在某框架的源码里找答案。2.1 requestAnimationFrame 定时器与固定时间步长的取舍网页游戏引擎的心脏就是游戏循环。听起来简单“每帧刷新画面呗”。但直接用requestAnimationFrame回调去驱动游戏逻辑会遇到一个经典问题不同设备的刷新率不一样。60Hz 屏幕回调整跑 60 次144Hz 屏幕回调跑 144 次如果逻辑按帧计算位置144Hz 屏幕上的角色移动速度就是 60Hz 屏的两倍多。解决办法是从物理引擎那借用固定时间步长fixed timestep思想渲染频率跟随屏幕刷新率但逻辑更新按固定毫秒间隔累积执行。const STEP_MS 16.6667; let lastTime performance.now(); let accumulator 0; function frame(timestamp) { let delta timestamp - lastTime; lastTime timestamp; // 防止页面后台切回来时 delta 过大 delta Math.min(delta, 250); accumulator delta; while (accumulator STEP_MS) { update(STEP_MS); // 固定步长更新 accumulator - STEP_MS; } render(); requestAnimationFrame(frame); } requestAnimationFrame(frame);这个写法的核心价值是把“逻辑”和“渲染”解耦。逻辑在固定时间步长里推进不管屏幕是多少刷新率游戏世界的物理规则始终一致。渲染则按屏幕节奏尽量显示最新状态中间有轻微插值空间。多人游戏中这个习惯尤其重要后面做状态同步时固定步长能极大地降低同步复杂度。2.2 Canvas 渲染对象管理从精灵池到脏矩形渲染层我用了两种优化手段分别是对象池和脏矩形都是轻量但极其有效的方案。对象池很容易理解避免每帧new对象、触发垃圾回收。OmniGame 里所有子弹、粒子、敌人在一个对象池里复用class Pool { constructor(factory, max) { this.factory factory; this.items []; for (let i 0; i max; i) this.items.push(factory()); } get() { return this.items.find((i) !i.active) || this.factory(); } release(item) { item.active false; } }脏矩形的思路则是整个 Canvas 不必每帧全量清空重绘可以把变化区域收集起来只更新这些区域。对大部分静态背景 少量移动元素的小游戏来说这个优化能让 CPU 占用明显下降。移动端 Safari 上的效果尤其明显因为大面积的clearRect在那上面特别昂贵。当然画面元素一多脏矩形维护成本就超过全量绘制了所以我做了一个开关允许在元素超过 80 个时自动降级为全量绘制。2.3 输入系统设计键盘、鼠标、触屏统一抽象网页小游戏的输入比想象中复杂因为用户可能用笔记本触控板、外接鼠标、手机触摸屏、甚至带键盘的平板。OmniGame 的输入系统抽象成统一接口按下事件、抬起事件、持续按压状态不再区分具体来源。实现上就是用三个集合来记录状态pressedSet、releasedSet、heldSet。每帧开始时清空pressedSet和releasedSet更新heldSet。所有游戏逻辑只查询这三个集合不直接绑定原生事件。这样键盘、鼠标、触屏的事件监听器只需要做翻译工作目标统一到一套虚拟按键上。这个抽象写起来很简单但帮了大忙——后面接联机输入同步时我只需要序列化虚拟按键状态不需要关心设备类型。3. WebRTC P2P 进场小游戏联机为什么要换赛道单机版的循环跑通之后下一个硬骨头是联机。一开始所有人都先想到 WebSocket因为那是成熟的浏览器长连接方案。但 OmniGame 的目标是“不依赖服务器转发游戏数据”这决定了 WebSocket 只能作为辅助方案真正的主角是 WebRTC 数据通道。3.1 WebSocket 方案的瓶颈到底在哪WebSocket 的问题不是一个脏话能说清的我用实际数据拆解一下。假如两个玩家分别在上海和北京游戏数据走 WebSocket 的路径是玩家 A → WebSocket 服务器比如华东地域→ 玩家 B。物理距离决定了这上百毫秒的基础延迟还不算服务器处理队列和带宽成本。更麻烦的是成本模型服务器每个连接都要维持 TCP 长连接1 万并发就需要 1 万连接还得实时转发所有消息。小游戏单价本来就低服务器带宽一上来利润全没了。于是我开始把目光投向“端到端直连”的方案——让玩家 A 的数据包直接飞到玩家 B中间不再经手服务器。3.2 WebRTC 数据通道的适用边界WebRTC 经常被人误解成“视频通话专用”其实它的数据通道RTCDataChannel就是为任意二进制数据设计的不管是文字还是游戏状态。它有两个核心优势传输路径是端到端 P2P只要 NAT 穿透成功数据就不经过中间服务器延迟显著降低。传输模式可选有序可靠类似 TCP、无序可靠、部分可靠允许丢旧包。游戏里很多状态数据根本不需要重传旧包选无序部分可靠模式就能在保持吞吐的同时降低延迟。但 WebRTC 也不是万能的它必须靠一个轻量信令服务器来交换连接参数。这个信令服务器只负责牵线搭桥建立连接之后立刻退到幕后即便此时信令服务器宕机已经在线的游戏对局也不会中断。3.3 一次完整的 P2P 链路建立过程从代码组织上看联机模块包含三段逻辑// 1. 创建连接对象 const pc new RTCPeerConnection({ iceServers: [{ urls: stun:stun.l.google.com:19302 }] }); // 2. 创建数据通道 const channel pc.createDataChannel(game, { ordered: false, maxRetransmits: 0 }); // 3. 交换信令经信令服务器中转一次 // 发起方创建 offer const offer await pc.createOffer(); await pc.setLocalDescription(offer); // 把 offer 发送给对端对端 setRemoteDescription // 对端创建 answer const answer await pc.createAnswer(); await pc.setLocalDescription(answer); // 把 answer 发回发起方发起方 setRemoteDescription这段代码看着短但实际操作里最大的认知更新是offer 和 answer 交换完成不代表连接成功还要等 ICE 候选收集完成。我最初踩过这个坑提前发游戏开始指令结果数据通道还没 ready导致开局前几秒的消息全丢了。后面做的修改是只在channel.readyState open时才允许进入游戏匹配流程并且加了一个“双方握手消息”作为真正开始信号的屏障。4. 信令与 ICE 穿透P2P 建链里最容易被低估的一环WebRTC 的文档一千篇里有八百篇在讲数据通道真正写 ICE 穿透和信令协议的少之又少。可实际项目中这一环才是决定你能不能把 P2P 玩游戏这件事落地的关键。4.1 信令协议设计房间模型与 SDP 交换信令服务器我用的是最轻量的方案——一个 Node.js 进程加 WebSocket只做三件事创建房间、加入房间、转发信号。核心模型就是房间玩家 A 创建房间拿到房间 ID。玩家 B 输入房间 ID加入房间。信令服务器把 A 的 offer 转给 B把 B 的 answer 转给 A之后双方 ICE 候选也是两边转发。SDPSession Description Protocol是 offer/answer 里那串很长的文本描述的是本端支持的媒体能力、编解码格式、传输地址。对小游戏来说我们只关心数据通道不需要音视频所以可以在创建 offer 时主动把音视频轨道全部剔除能减少不少 ICE 候选的生成量加快建链速度。4.2 ICE 候选收集与 NAT 穿透失败的兜底策略ICE 的候选收集分三种host本机内网地址、srflx经 STUN 服务器反射后的公网地址、relay经 TURN 服务器中转的地址。浏览器默认会把所有候选都交给应用你的前端代码要做的是把候选发给对端等对端也收集完毕后再做连通性检测。连通性检测由浏览器自己完成但有一个隐藏问题很多非对称 NAT 环境需要“同时打开”双向端口才能穿透成功如果双方候选交换太慢会错过“打洞”窗口。典型表现就是ICE 状态在 checking 和 failed 之间反复横跳。解决办法是粗糙但有效的“候选风暴”一旦生成本端候选立刻通过信令服务器发给对端不等 gather 完成就尝试小的候选集合预先打洞。NAT 穿透失败时WebRTC 不会自己恢复你必须提前准备好 TURN 服务器作为兜底。TURN 的本质是一个中继服务器数据会经过它转发延迟比 P2P 高但它保证了连接必达。OmniGame 的策略是如果 8 秒内 ICE 状态没有达到 connected就直接启用带 TURN 的配置重新创建连接而不是让玩家一直停在“正在连接”。4.3 断线检测与通道保活P2P 连接最怕的就是悄无声息地断。NAT 映射有超时移动网络切换会导致 IP 变化这些事件双方未必能及时感知。OmniGame 做了三层保活应用层心跳每 3 秒发一个极小的 ping 消息对端回 pong。超时判定连续 6 次心跳无响应判定断线触发重连流程。重连策略保留房间 ID重新走一遍完整信令流程80ms 内恢复游戏对局。这套三层机制在实际对战中非常有用。我测过移动端切 4G 到 Wi-Fi 的场景WebSocket 都断了但 WebRTC 的 ICE 重启机制成功让连接在几秒内自动恢复玩家几乎无感。省下的这种“无感切换”体验是中心服务器转发模式很难做到的。5. 数据同步策略多人同屏游戏最关键的工程细节连接建好只是万里长征第一步。联机游戏真正难的是两个玩家各自拥有完整游戏世界怎么让这两个世界的状态保持一致这比任何网络库的 API 都更考验工程能力。5.1 状态同步 vs 帧同步小游戏怎么选业界两条经典路线状态同步和帧同步。状态同步每个客户端维护整个世界的状态某一个实体发生变化时广播差异。优点是容错性强缺点是有状态差异风险大量实体时带宽消耗大。帧同步所有客户端执行相同输入序列逻辑完全确定渲染层回放。优点是带宽极小缺点是要求零逻辑差异任何浮点数运算不一致都会导致后期状态崩坏。OmniGame 的做法是混合用状态同步为主但对高频、低信息量的属性比如位置做帧同步式压缩发送。具体来说每秒只同步 10 次位置快照中间依靠插值平滑显示实体属性变化血量、道具、胜负则走可靠通道即时同步。这样既避开纯帧同步的确定性地狱又比纯状态同步省了带宽。5.2 消息协议的极简设计JSON 还是二进制零依赖项目的原则在消息协议上也适用。第一版我用 JSON 字符串调试起来方便但打了半天后发现性能瓶颈每个 JSON 消息的序列化和解析占用了大量时间。数据通道提供二进制传输能力我用 DataView 写了一个极简二进制协议// 消息头1字节类型 1字节长度 // 消息体按字段顺序写入 const buffer new ArrayBuffer(6); const view new DataView(buffer); view.setUint8(0, MSG_INPUT); // 类型 view.setUint8(1, 4); // 长度 view.setInt16(2, positionX, true); // 小端序 view.setInt16(4, positionY, true); channel.send(buffer);二进制协议最直接的好处是消息体积从几十字节降到几个字节。一个 64 人字段的完整状态快照JSON 要 200 字节二进制只要 40 字节。对高频消息来说差距是决定性的。但我也保留了 JSON 通道用于调试——开发模式下同时发一份 JSON 镜像线上则完全走二进制。5.3 延迟补偿与状态预测的轻量实现P2P 模式的延迟比中心服务器低但不可能为零。为了让操作手感不那么“飘”我加了客户端预测和服务器主机端回校逻辑。客户端提交输入后立即在本端预测执行该输入不等对端确认。对端权威主机按固定时间步长处理输入广播校正后的状态。客户端收到校正状态后与本地预测结果做对比只修正偏差超过阈值的实体避免反复横跳。这套逻辑写起来不算复杂但对体验的提升非常明显。没开预测之前移动操作有明显的“按了没反应”的感觉开了之后单人模式的手感几乎完全保留。对于动作类小游戏来说这个优化是 P2P 方案能不能用的分水岭。6. 实测踩坑记录延迟、穿透、数据风暴与浏览器策略讲完理论到了我最想写的部分实际运行中碰到的问题。这些都是文档里不会写的细节只能靠真机测试一脚一脚踩出来。6.1 首帧延迟的优化顺序网页小游戏最影响用户体验的就是首屏等待。我做了一个“首帧体检”发现拖慢首帧的不是游戏逻辑而是资源加载和 WebRTC 初始化被放在了启动路径上。优化顺序如下优先渲染静态画面HTML 内联样式先把游戏背景和 UI 画出来用户瞬间看到“游戏界面”。延迟创建 RTCPeerConnection只有玩家点击“开始匹配”后才初始化 WebRTC而不是进入页面就建立连接。资源懒加载大型背景图片和音效按需加载不阻塞关键路径。这个调整让首帧时间从 2.1 秒降到 0.6 秒。核心思路是首帧优先级最高任何非必要操作都挪到交互后再做。6.2 NAT 穿透失败的代价与 TURN 回退本地开发一切顺利一放到公网就发现个别玩家永远连不上。查下来是对方处在严格 NAT 后面STUN 反射的端口无法直接连通。此时只能走 TURN 中转但 TURN 服务器的带宽成本是真实存在的。我的应对是精细化分级先尝试 P2P 直连8 秒无果后切换 TURN。连接成功后定期做一次“P2P 探测”探测成功就换回直连把 TURN 带宽占用降到最低。这个探测要做得轻量避免反复横跳实际我是在一场对局结束后才做切换保证过程中不打断游戏。6.3 数据通道的背压处理别把对端刷爆一个特别容易忽略的坑channel.send()是异步不阻塞的但它并不保证对端能及时处理。你往数据通道里疯狂发消息发送端 CPU 轻松接收端可能崩溃或者丢弃消息。WebRTC 提供了bufferedAmount属性来表示待发送字节数。我写了一个简单的背压控制if (channel.bufferedAmount 16 * 1024) { // 待发送数据超过 16KB暂停新输入的处理 isPaused true; setTimeout(() { isPaused false; }, 16); return; }发消息前检查背压超过阈值就暂停输入处理让发送队列排空后再继续。这个保护机制极大减少了奇怪的数据丢失问题。我一开始没加结果高延迟网络下对局经常出现角色瞬移就是数据块被浏览器缓存导致的乱序堆积。6.4 浏览器差异与移动端性能调优WebRTC 在不同浏览器上的行为差异是真实存在的撕裂坑。Safari 对 ICE 候选的处理更保守偶尔需要主动调用pc.restartIce()才能稳定连接。移动端 Safari 在后台标签页会被冻结导致心跳中断。解决方案是监听visibilitychange事件回到前台时立刻补发一条信令消息触发 ICE 重启。Chrome 对部分可靠数据通道的参数兼容性最好Firefox 次之旧版 Edge 基本放弃。真机测试时我通常用三台设备同时跑一台桌面 Chrome、一台 iPhone Safari、一台安卓 Chrome任何一个环节出现同步偏移都能立刻暴露。移动端 Canvas 性能方面把canvas.width设为 CSS 像素的 1 倍而非 2 倍Retina 屏帧率反而更稳定——因为 2 倍渲染对 GPU 压力太大小游戏像素风格在 1 倍清晰度下也完全够看。最后再分享一个我在项目里反复调整的小细节联机测试不要依赖局域网。局域网内 WebRTC 直连表现极好但它掩盖了 NAT 穿透、延迟抖动和带宽限制这些真实问题。我后来强制让测试环境走公网用 Cloudflare 或任意免费静态托管部署信令服务器和游戏页面才真正复现了用户会遇到的所有情况。这套从零依赖到 P2P 的工程链路最值钱的不是某一个技巧而是整套“给人玩的游戏”到底需要解决哪些真实问题。你照着这条路走一遍大概率会发现很多原本以为需要框架解决的问题其实浏览器原生能力早就准备好了。