做网页小游戏这几年,我越来越觉得“工程化”对小项目来说是双刃剑。一提到做游戏,大多数人默认要引框架、配构建链、接引擎,光是让“Hello World”跑起来就要花掉一个晚上。而 OmniGame 这个项目的出发点恰恰相反——它想验证一件近乎反潮流的事:一个正经能玩、能多人联机的网页小游戏,能不能做到零第三方依赖,并且用浏览器原生的 WebRTC 建立 P2P 连接,让玩家和玩家的浏览器直接互传数据?
答案是能,而且效果比预期好不少。整个项目跑完之后,我重新理解了“网页小游戏的工程上限”这句话:上限不取决于你引了多少工具,而取决于你对浏览器原生能力的掌控程度。这篇文章就把 OmniGame 从零到一的工程决策、核心实现和踩坑过程全部拆开,包括零依赖的项目骨架怎么组织、WebRTC 信令握手怎么设计、P2P 联机里数据通道怎么跑、延迟补偿怎么做,以及我实测下来的参数和经验。不管你是想做一个“复制链接就能玩”的小游戏,还是想了解 WebRTC 在游戏场景里的落地姿势,这篇都可以直接抄作业。
1. OmniGame 到底做了什么:零依赖 + P2P 的组合拳
1.1 网页小游戏的现状:大多数人一上来就引框架
现在的网页小游戏市场很有意思。用户要的其实特别简单:点开链接就玩,不要下载,不要注册,最好连加载进度条都别让我看太久。mikutap 这类音乐节奏小游戏之所以能爆火,就是因为它是纯 HTML + JS 的产物,打开网页立刻就是节奏点,一分钟内就能完成一次完整体验。
但开发者这边完全是另一个画风。不做复杂画面就先用 Canvas 加个循环,行不行?行。可一旦团队习惯成型,大家会下意识地在项目里塞进 Vue/React、引入 Phaser/Cocos 引擎、配置 Vite 打包、装一堆 npm 包。这一套组合拳打下来,一个“双击 HTML 就能跑”的轻巧项目,变成了一个依赖树深不见底、构建日志几百行的前端工程。最尴尬的是,这些复杂度换来的能力,在小游戏场景里大部分时间用不上。我做 OmniGame 时故意把这一整套全砍掉,整个游戏逻辑、渲染、音频、网络全部基于浏览器原生 API,结果项目意外地清爽:不用管依赖冲突,不用管框架升级,代码放在那里两年再打开也不会坏。
我并不是说框架不好。而是想说,网页小游戏服务的是“轻量、即开即玩、低门槛”的需求,这种需求本来就不需要重武器。零依赖不只是技术洁癖,它是一种主动的工程约束:当你的每个能力都来自浏览器时,你能拿到的就是一整套经过多年沉淀、跨端兼容的升级版 API,并且这些 API 没有供应链风险,没有版本碎片。对“5 分钟一局”的小游戏来说,这是最高效的起点。
1.2 WebRTC P2P 给网页小游戏带来什么变量
单机小游戏做得再精致,终归是“一个人自嗨”。一旦想加入联机,传统路线通常是 WebSocket 服务器中转:客户端把手势指令发给服务器,服务器再分发给房间里的其他玩家。这套模式成熟、稳定,但有两个非常现实的问题。一是延迟翻倍,一份数据从 A 到 B 必须绕行服务器,尽管服务器通常放在 IDC 里,物理距离还是会让体感打折;二是服务器带宽和并发是长期成本,玩家人数一多,机器成本直线上升,个人开发者和小型团队很难放心往里砸钱。
WebRTC 的出现改变了这个局面。它本身就是浏览器原生的点对点通信能力,游戏数据可以在玩家之间直连互传,不需要经过应用层服务器。OmniGame 在做联机时,正是围绕 WebRTC 的 RTCPeerConnection 和 RTCDataChannel 来搭建的。P2P 带来的最直接收益就是延迟逼近物理极限,假设两个玩家都在国内同一城市,公网直连的延迟通常在 20 到 50 毫秒之间,比任何服务器中转方案都低。
当然,P2P 不是没有代价。WebRTC 建立连接前需要一个信令服务来交换双方的 SDP 和 ICE 候选信息,这个服务只负责“握手撮合”,不碰游戏数据;而且在某些 NAT 网络环境下,P2P 打洞会失败,必须退回到 TURN 中继。这两个问题我在第 3 章和第 5 章会详细讲。这里先给出结论:对于 2 到 8 人的轻量对战、协作小游戏,WebRTC P2P 在体验上、成本上、工程复杂度上都是一个可以认真考虑的选择它让“没有后端”的单人小游戏,一下子跃迁成了“没有游戏服务器”的多人小游戏。
1.3 “重新定义工程上限”这句话的落点
很多人看到“零依赖 + WebRTC P2P”这个组合,第一反应是“炫技”。但 OmniGame 想证明的恰恰相反:这是一种被低估的工程务实。当你不依赖框架时,你会发现浏览器的原生能力已经走了非常远,Canvas 2D 足够应付绝大多数轻量场景,Web Audio API 能完成音频合成,RTCPeerConnection 能直接打通端到端连接。零依赖不是“从简”,而是“足够深地理解浏览器之后,用最小集合实现最大能力”。
所谓工程上限,在这个项目里具体落在几件事上:模块化的代码组织、多人联机的可靠握手、丢包乱序下的游戏状态同步、跨浏览器兼容的兼容适配、以及几十 KB 级别的加载体积。把这五件事在零依赖的约束下全部跑通,比“引引擎后一键生成房间”难得多,但得到的控制感和迁移自由也完全不同。这整篇文章的每一章,本质上都是在讲这个上限是怎么被一点点抬高的。
2. 零依赖工程骨架:不用框架,项目照样有条理
2.1 项目结构与模块化:ES Modules 就是免费的模块系统
零依赖不意味着代码可以随便堆。最关键的第一步是选好模块机制。现代浏览器全都支持 ES Modules,所以 OmniGame 直接在浏览器里用 import / export 来组织代码,完全不需要打包器。项目目录是这样规划的:
omni-game/ ├─ public/ │ ├─ index.html │ ├─ css/ │ │ └─ main.css │ └─ js/ │ ├─ main.js │ ├─ engine/ │ │ ├─ loop.js │ │ ├─ render.js │ │ ├─ input.js │ │ └─ assets.js │ ├─ game/ │ │ ├─ state.js │ │ ├─ entities.js │ │ └─ config.js │ ├─ net/ │ │ ├─ signaling.js │ │ ├─ peer.js │ │ └─ protocol.js │ └─ ui/ │ └─ screens.js └─ server/ └─ signaling.js浏览器加载模块只需在 HTML 里写一行<script type="module" src="./js/main.js"></script>,main.js 再按需 import 其他模块即可。这样做的好处是依赖关系一目了然,任何人打开项目都能看到清晰的层次:engine 负责底层循环和渲染,game 负责游戏逻辑,net 负责人联机,ui 负责界面。
有人问,不用 TypeScript 怎么保证代码质量?我在 OmniGame 里的做法是给关键函数写 JSDoc 注释,IDE 能直接识别类型提示。对于这种规模的项目,JSDoc 的收益已经超过引入 TypeScript 编译链的成本。还有个小细节:开发期不需要任何构建工具,一条python3 -m http.server 8080或者随便一个静态服务器命令就能跑起来。如果你想追求“双击 HTML 就能玩”,把代码合并成一个 script 标签也是分分钟的事,零依赖的意义就在这里——你随时可以退回到最原始的形态,不会因为“没装依赖”而瘫痪。
2.2 游戏循环:为什么要写固定时间步长
小游戏的心脏是游戏循环。初学者喜欢用setInterval(fn, 16)模拟 60FPS,这在小游戏里是典型的坑。浏览器对 setInterval 没有帧同步概念,后台标签页还会强制节流,计时器会漂移,帧率不稳的时候游戏逻辑也会跟着抽风。
OmniGame 用的是requestAnimationFrame+ 固定时间步长(fixed timestep)方案。核心思路:rAF 负责提供准确的帧时间戳,逻辑更新则每次固定推进 16.667ms,渲染可以每帧执行。代码如下:
export class GameLoop { constructor(update, render, step = 16.667) { this.update = update; this.render = render; this.step = step; this.accumulator = 0; this.lastTime = performance.now(); this.rafId = 0; this.running = false; } start() { this.running = true; this.lastTime = performance.now(); this.rafId = requestAnimationFrame(this.tick); } tick = (now) => { if (!this.running) return; let frameTime = now - this.lastTime; this.lastTime = now; // 防止切后台回来时 accumulator 一下子堆积太多帧 if (frameTime > 250) frameTime = 250; this.accumulator += frameTime; while (this.accumulator >= this.step) { this.update(this.step / 1000); this.accumulator -= this.step; } this.render(); this.rafId = requestAnimationFrame(this.tick); }; stop() { this.running = false; cancelAnimationFrame(this.rafId); } }这里的step / 1000就是每次更新对应的秒数,所有游戏逻辑里的速度都用这个值计算,保证任何刷新率下角色速度一致。上限 250ms 的判断很重要,否则用户切换标签页回来时,循环会瞬间补算几十帧,轻则卡顿,重则物理穿透。这个细节如果少了,你会在联机对战时遇到“切出去再切回来,我的角色飞出了地图”这种奇奇怪怪的 bug。
2.3 Canvas 渲染与资源策略:零依赖不等于零优化
渲染层选了 Canvas 2D,这是零依赖小游戏最稳妥的选择。相比 WebGL,Canvas 2D API 上手成本低,浏览器兼容性好,性能对 2D 小游戏完全够用。不过 Canvas 2D 也有自己的性能陷阱,最典型的就是 fillStyle/strokeStyle 切换。Canvas 的绘制状态切换成本很高,每帧里频繁改颜色、切字体,比多画几个圆还慢。我的做法是:所有实体先按种类排序,一次性设置好绘制状态再批量画完,避免同帧内反复横跳。
Retina 屏的适配也是老生常谈。直接按 CSS 像素画,高分屏上会糊。我在 render.js 里统一做了缩放:
const dpr = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);画布的逻辑尺寸始终用 CSS 像素,渲染时系统自动按物理像素输出,既保持清晰度,又不用改游戏逻辑里的坐标。
资源加载方面,OmniGame 做了一个非常小的 manifest 机制:图片资源清单写在一个 JSON 里,启动时 Promise.all 并行加载,配合一个简单的进度条。相比直接在代码里 new Image() 后赌运气,这种清单式管理更可控,需要加资源时不用改代码逻辑。音频则用 Web Audio API 合成,小体积的提示音效全部用振荡器生成,不额外加载音频文件,这也是保持“复制链接就能玩”理念的一部分。
2.4 UI 切换与场景管理:别把显示逻辑焊死在游戏循环里
零依赖项目最常见的失控点,是游戏逻辑和 UI 逻辑混在一起。OmniGame 把 UI 层单独拆成 screens.js,每个场景(主菜单、房间、对战、结算)是一组独立的 DOM 容器,切换场景时只做看板和隐藏,游戏循环只关心当前场景是否允许更新。这个简单的分层避免了很多后期维护时的痛苦,尤其是联机对战时,房间状态和战斗画面是两个节奏完全不同的系统,混在一起必然是灾难。
3. WebRTC P2P 联机模块:从信令握手到数据通道
3.1 信令服务:只做媒人,不碰游戏数据
WebRTC 有一个特性常被人误解:它号称“P2P”,但建立连接之前必须有一个“带外”通道让双方交换元数据。这个通道叫信令服务(signaling)。在 OmniGame 里,信令服务是一个独立的小型 Node.js 服务,用 WebSocket 实现,只做两件事:维护房间成员列表、转发 SDP 与 ICE Candidate。
房间机制我采用了最简单的邀请码制。房主创建一个房间得到一个 6 位数字码,其他人输入码加入。服务端代码的核心逻辑如下:
import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8787 }); const rooms = new Map(); wss.on('connection', (ws) => { ws.on('message', (msg) => { const data = JSON.parse(msg); if (data.type === 'create') { const roomId = Math.floor(100000 + Math.random() * 900000).toString(); rooms.set(roomId, new Set([ws])); ws.send(JSON.stringify({ type: 'created', roomId })); ws.roomId = roomId; } if (data.type === 'join') { const room = rooms.get(data.roomId); if (!room) { ws.send(JSON.stringify({ type: 'error', message: '房间不存在' })); return; } ws.roomId = data.roomId; room.add(ws); // 通知房主有新成员加入 for (const member of room) { member.send(JSON.stringify({ type: 'peer-ready', count: room.size })); } } if (data.type === 'signal') { // 把信令数据转发给指定 peer,不解析内容 const sender = ws; for (const member of rooms.get(ws.roomId) || []) { if (member !== sender) { member.send(JSON.stringify({ type: 'signal', payload: data.payload })); } } } }); ws.on('close', () => { const room = rooms.get(ws.roomId); if (room) { room.delete(ws); if (room.size === 0) rooms.delete(ws.roomId); } }); });关键设计:信令服务对 SDP 和 ICE 数据完全“黑盒”转发,不解析、不落地、不缓存。这样保证信令服务的实现极简,同时游戏数据永远不会经过它。开发时直接node server/signaling.js起服务,简单可靠。
有人问:如果不想维护信令服务,能不能用免费公共信令服务?可以,比如 PeerServer 这类现成方案,但那就引入了第三方依赖,和 OmniGame 的零依赖理念冲突。而且自建信令服务非常简单,20 行核心代码,完全没有偷懒的必要。
3.2 RTCPeerConnection 与 RTCDataChannel 初始化全流程
WebRTC 的连接建立流程可以浓缩成四句话:创建 RTCPeerConnection,生成 offer,交换 SDP,交换 ICE Candidate。我用一个统一函数把 host 和 client 两侧的对称式代码封装在一起:
function createPeer(isHost) { const config = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, // TURN 按需配置,见 3.3 ], }; const pc = new RTCPeerConnection(config); let channel = null; if (isHost) { // 房主主动创建 DataChannel channel = pc.createDataChannel('game', { ordered: false, maxRetransmits: 0, }); setupChannel(channel); } else { // 加入房间的客户端等待房主创建 channel pc.ondatachannel = (ev) => { channel = ev.channel; setupChannel(channel); }; } pc.onicecandidate = (ev) => { if (ev.candidate) { sendSignal({ ice: ev.candidate }); } }; return { pc, getChannel: () => channel }; } async function startOffer(pc) { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ sdp: pc.localDescription }); } async function handleOffer(pc, sdp) { await pc.setRemoteDescription(sdp); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendSignal({ sdp: pc.localDescription }); }这里的 sendSignal 就是上文的信令通道。整个握手过程是一个状态机,我在 peer.js 里维护了一个简单状态字段,从new → connecting → connected → closed流转,UI 层根据状态显示“连接中 / 已连接 / 已断开”。很多新手写 WebRTC 时容易忽略状态机,结果连接失败后界面没有任何反馈,用户一脸懵。
关于 DataChannel 参数,我用了ordered: false, maxRetransmits: 0,也就是“无序 + 不可靠”通道,这非常适合实时游戏:游戏指令是时间敏感的,旧指令重传没有任何意义——丢失的一帧输入,你补发一帧“两帧前的输入”反而会把游戏状态搞乱。初始化流程里如果需要可靠通道(比如交换玩家昵称、房间配置),我会再创建一个ordered: true的 channel,和实时指令通道隔离,各司其职。
3.3 ICE 与 STUN/TURN:能否连上主要看这几项
ICE(Interactive Connectivity Establishment)是 WebRTC 打洞的核心机制。一个 RTCPeerConnection 会收集三类 candidate:
| 候选类型 | 来源 | 含义 |
|---|---|---|
| host | 本机网卡 | 直连 IP,比如局域网内互打 |
| srflx | STUN 反射 | NAT 映射后的公网地址,打洞成功的关键 |
| relay | TURN 中继 | 中继服务器的转发地址,兜底方案 |
两端拿到彼此的候选列表后,会尝试所有候选对的连通性检查。如果双方都在一个局域网里,host 类型的候选直接就连上了,速度极快;如果双方在不同公网 NAT 后面,则依赖 srflx 候选打洞;如果打洞失败且配了 TURN,则退化为 relay 中继。
STUN 的作用是“让浏览器知道自己的公网 IP:端口”。我在 OmniGame 里沿用了 Google 公共 STUN(stun:stun.l.google.com:19302),开发联机时很方便。但注意,STUN 不是万能的,如果你的网络是对称 NAT(很多公司网和校园网就是这样),打洞成功率会明显下降。这时候只有两条路:换网络环境,或者配置 TURN。
TURN 可以理解为“WebRTC 最后的救生圈”。免费的公共 TURN 服务基本不可靠,自建的话可以部署开源 coturn 方案,配置也不复杂。我的建议是:原型阶段先用 STUN,楼起来后再决定要不要为所有玩家撑 TURN。因为 TURN 服务器需要带宽成本,只有当打洞失败率显著影响玩家体验时才值得投入。调试时打开chrome://webrtc-internals,可以看到两端的 candidate 类型和 ICE 状态,这是排查一切连接问题的第一现场。
还有一点:很多开发者在 localhost 下测试时一切正常,因为浏览器把 localhost 当作安全上下文,且本地网络环境不存在 NAT,host candidate 直接通了。一旦部署到公网、两个真实玩家连接时才发现一堆问题。所以 WebRTC 联机功能必须提前放到真实公网环境测试,end-to-end 打通一次后再谈优化。
3.4 游戏协议:DataChannel 里到底传什么
DataChannel 提供的是“通道”,传什么格式完全由你决定。最初 OmniGame 图省事直接传 JSON,后来发现虽然在开发期方便,但每 50ms 一条 JSON 的序列化开销其实并不小,而且包体大,对移动网络的弱网环境很不友好。后期我把实时指令改成了二进制协议。
一条玩家输入帧的二进制结构长这样:
// 7 bytes:4字节时间戳 + 1字节按键位域 + 2字节坐标增量 const buf = new ArrayBuffer(7); const dv = new DataView(buf); const timestamp = performance.now(); dv.setFloat32(0, timestamp); dv.setUint8(4, keyMask); // 每个bit代表一个按键,比如0x01=左,0x02=右 dv.setInt16(5, deltaX); // 左右移动的增量 channel.send(buf);游戏状态快照也做了类似压缩,每个实体固定写入坐标、速度、血量这几个关键数据。整个快照算下来通常不超过 64 字节,每 50ms 发一次,带宽占用几乎可以忽略不计。这带来的体感是延迟更低、弱网下更抗丢包。
这里有个重要的设计原则:PKT 时间戳一定要用单调时钟performance.now()而不是Date.now()。Date.now()依赖系统时钟,可能被 NTP 校准甚至用户手动改时间,会导致插值缓冲直接错乱。performance.now()是单调递增的,从页面加载开始计时,双端各用自己的时间戳,但只需要相对偏移量就能对齐。简易做法是收到第一条快照时记录偏移,后续都按偏移修正。
4. P2P 游戏同步实战:主机权威 + 客户端预测
4.1 为什么不用帧同步:P2P 没有权威,帧同步容易把局面搞崩
做联机游戏,很多人第一反应是“帧同步”。帧同步的核心理念是:所有客户端播同一段输入序列,每个端各自运行完全确定性的模拟,只要初始状态一致、输入一致、算法完全确定性,各个端的世界就会一直一致。
听起来很完美,但我强烈不建议在 P2P 小游戏里用帧同步。原因有三。第一,浏览器端的游戏逻辑很难做到严格确定性:浮点数运算在不同设备上可能会有细微误差,JavaScript 的对象遍历顺序、Math.random() 如果要确定性必须自己实现伪随机器,这些细节会不断在“看似无关紧要”的地方制造分歧。第二,帧同步对丢包处理要求极高,一个关键输入帧丢了、重传晚到,整个模拟就从分歧点开始永久分叉。第三,P2P 网络没有权威节点,玩家之间的物理延迟不一致,帧同步还需要每帧等最慢的那个人,整体节奏会被拖累。
所以 OmniGame 采用的方案是主机权威(Host Authority)。房主的浏览器是“服务器”,完整跑游戏世界逻辑;加入游戏的客户端只负责两件事:发自己的输入指令,接收房主广播的状态快照并渲染。这个模型天然适合小游戏,实现简单,又不牺牲手感。
4.2 主机权威 + 客户端预测:实现一个够用的插值缓冲
主机权威模式下最直接的做法是:客户端每收到一帧快照就把角色坐标“瞬移”到快照位置。坏处是快照频率如果不够高,客户端看到的都是“跳帧”,卡顿明显。解决手段是采用插值缓冲:客户端不直接使用最新快照,而是把快照存在一个有延迟的缓冲队列里,渲染时按时间轴在两个历史快照之间做线性插值。
插值缓冲的简化实现如下:
const snapshots = []; const BUFFER_SIZE = 8; function pushSnapshot(snap) { if (snapshots.length >= BUFFER_SIZE) snapshots.shift(); snapshots.push(snap); } function renderInterpolated(nowMs, entity) { for (let i = 1; i < snapshots.length; i++) { const prev = snapshots[i - 1]; const next = snapshots[i]; if (nowMs >= prev.ts && nowMs <= next.ts) { const t = Math.min(Math.max((nowMs - prev.ts) / (next.ts - prev.ts), 0), 1); entity.x = prev.x + (next.x - prev.x) * t; entity.y = prev.y + (next.y - prev.y) * t; return; } } }缓冲队列本质上是有意引入 50~100ms 的渲染延迟,换来的是平滑的运动轨迹。插值缓冲对“其他人的运动”效果很好,但对于玩家自己控制的那个角色就不够用了:没有人希望自己按方向键后 100ms 才看到角色转向。
所以客户端预测的核心是:本地先跑一份“乐观世界”,玩家输入立即改变自机位置,同时上报给主机;主机回传快照后,本地把预测误差平滑纠正回去。我在 OmniGame 里做了一套极简的误差修正,核心逻辑是:如果本地预测和主机快照的位置偏差超过一个阈值,就用线性插值在两三帧内把角色拉回正确位置;偏差小时不强行纠正,保留本地手感的流畅性,等下一个快照自然修正。这种“按需纠正”的策略在小游戏里完全够用,体验远好于每帧硬性对齐。
4.3 参数调优实测经验
同步参数没有标准答案,但有一套从 OmniGame 实测中总结出的起点配置:
| 参数 | 取值 | 说明 |
|---|---|---|
| 状态快照频率 | 20~30 次/秒 | 单人/平缓场景取 20,对抗/竞速场景取 30 |
| 输入上报频率 | 30~50 次/秒 | 触摸事件节流后 30,键盘高频按下时 50 |
| 插值缓冲长度 | 2~4 个快照 | 相同于 50~100ms 渲染延迟,越小越跟手 |
| 无序通道 | ordered: false | 实时指令丢包不重传 |
| 可靠通道 | ordered: true | 初始化配置、房间信息等 |
实测下来,局域网 P2P 的往返延迟通常在 5ms 以内,公网直连在 20~80ms,手机 4G 网络下 80~150ms。在插值缓冲 + 客户端预测的双重加持下,玩家感知到的“操作跟手感”和“其他人的流畅度”都能保持在一个不错的范围。
还有一个容易忽略的参数:主机快照发送的时间戳。我建议用performance.now()直接取主机侧的单调时钟,并用首帧握手时记录的时间偏移来对齐双端时间轴。否则插值函数里nowMs和prev.ts不在同一个时钟体系,插值会变得完全混乱。
5. 常见问题与排查技巧实录
5.1 连不上:如何面对 NAT 打洞失败
P2P 联机最容易遇到的就是“明明都在线,就是连不上”。遇到这种情况,第一件事就是打开 Chrome 的chrome://webrtc-internals,看两端的 candidate 和 ICE 状态。根据状态可以快速定位:
最常见的失败场景是 ICE 状态长时间停在checking,没有任何候选对被选中。这时打开 candidate 列表,如果双方交换的候选基本都是 host 类型,说明无法完成公网打洞;如果交换里只有 srflx 却没有 relay,则说明没配 TURN。这种情况下要么加 TURN,要么让玩家换到同一内网测试。还有一种场景是两个玩家都在同一公司网、同一 NAT 后面,host 候选明明互相可达,却因为 NAT 的端口限制导致 host 不通,这种时候反而是加上 srflx 和 relay 能帮忙绕过去。
排查这类问题有个技巧:在 WebRTC 状态机里把连接阶段状态打印出来,比如new → gathering → checking → connected → completed。哪个阶段卡住就去查哪个阶段对应的配置。前期把状态日志做好,后面排查能省一半时间。
5.2 浏览器兼容性和后台冻结的坑
零依赖项目的另一个隐藏成本是浏览器兼容。虽然现代浏览器的 WebRTC 支持已经非常普及,但细节差异依然存在。比如 Safari 的 ICE 候选收集在某些版本里慢得出奇,对 trickle ICE 的支持也不如 Chrome 积极;微信内置浏览器和部分国产浏览器的 WebRTC 实现是滞后且不标准的。我在 OmniGame 里做了一层能力检测,进入联机页面前先RTCPeerConnection是否存在,不存在就直接弹提示,避免用户卡在“连接中”页面干等。
另外,ES Modules 在现代浏览器均支持,但如果你要兼容特别老的内核,零依赖反而成了优势:自己写的代码合并成单个 script 标签非常简单,不需要为框架跑构建链。
还有两个纯浏览器层面的现实问题。第一是后台标签页会冻结requestAnimationFrame和定时器,玩家在对战时切走标签页再回来,连接可能已经超时断开。解决方案是监听visibilitychange事件,页面回到前台时立刻检查连接状态并重建,同时在空闲时发心跳包保持链路活跃。第二是有部分用户出于隐私保护,会在浏览器设置或插件中主动关闭 WebRTC 功能,这对 P2P 游戏来说就是“天然无法连接”的直接原因。这类情况属于用户侧行为,游戏侧没法绕过,只能在 UI 上给出清晰的提示,让用户去检查自己的浏览器设置。
5.3 部署与体积控制
零依赖项目部署起来出奇地轻:游戏静态资源直接丢到任意静态托管(GitHub Pages、Netlify、OSS 等),信令服务则是唯一需要持续运行的 Node 进程。开发时两者可以在同一台机器上跑,上线后建议分开,信令服务的 WebSocket 需要一个支持长连接的运行环境,普通的云函数对 WebSocket 支持不友好,优先考虑轻量应用服务或者容器。
体积方面的数据很有意思:OmniGame 的整个游戏逻辑代码压缩后约 60KB,远小于一个典型 Vite + React 项目的首屏产物。加载速度带来的体验差异是决定性的,玩家点开链接后几乎零等待直接进入主菜单。这个数字本身就是零依赖理念的胜利:你不用做太多性能优化,就天然处在一个非常低的开销区间。
6. 写在最后:几个只有踩过坑才写得出的经验
做完 OmniGame 最深的体会是:零依赖的真正价值不是“不用装包”这个表象,而是它逼着你在更高的层面思考每一个功能——游戏循环为什么这样写,数据通道为什么这样配,同步为什么选这个模型。你没法靠调用一个现成引擎来逃避这些决策,弯路走得多了,反而把浏览器原生 API 的边界摸得很透。
如果让我给准备做网页小游戏的人一个建议:先在零依赖里把雏形跑通,再考虑要不要引引擎。这个项目最终能跑通 P2P 联机,说明浏览器原生能力已经比想象中走得远得多。另外一个实操层面的小技巧是:从最开始就做一套浏览器层面的状态日志埋点,WebRTC 连接阶段的每个状态转变都印出来。这套日志在后续排查中会被反复用到,而且零依赖项目的代码控制力够强,加这种埋点成本极低,收益却巨大。