1. 为什么我要折腾一个“零依赖”的网页小游戏框架
先说结论:OmniGame 是我在过去几个月里反复推倒重来三次之后,才勉强敢拿出来讲的一个网页小游戏工程方案。它的核心目标很朴素——让一个网页小游戏在不装任何第三方运行时依赖的前提下,既能单机跑得飞快,又能通过 WebRTC 做 P2P 联机,还能把游戏 UI 干净地塞进宿主页面里不打架。听起来像三个不相干的需求,但真正做过网页小游戏的人都知道,这三件事凑在一起,工程复杂度是呈指数级上升的。
我做这个的起因很具体。之前帮朋友做一个嵌入到内容站点里的答题对战小游戏,第一版用了常见的游戏引擎加一堆 npm 包,结果打包出来 2MB 起步,首屏加载慢得让人想关页面;第二版砍到只剩 Canvas 手写渲染,体积下来了,但联机部分又得引入信令服务和一堆网络库,部署成本反而更高。第三次我下定决心:渲染层零依赖手写,联机层用浏览器原生的 WebRTC,UI 隔离用 Shadow DOM,构建层用 Next.js 做壳。这套组合就是 OmniGame 的雏形。
这篇文章适合谁看?如果你正在做嵌入型网页小游戏、想做轻量级 P2P 联机、或者单纯好奇 WebRTC 在游戏场景里到底怎么落地,那这篇应该能给你省下不少试错时间。我会把每个技术选型背后的“为什么”、参数怎么算、坑在哪里,全部摊开讲。全文基于我自己的实操记录,不是文档翻译,也不是概念科普。
2. 整体架构设计与技术选型拆解
2.1 三个核心诉求倒推出的技术栈
先把需求翻译成工程语言。OmniGame 要解决的三件事,对应三组技术约束:
- 零依赖:指的是运行时不依赖任何外部 JS 库。渲染、输入、状态机、网络封装全部自己写,最终产物只有一个 HTML 加若干静态资源。这样做的直接好处是首屏极快,且不会被第三方库的 breaking change 拖累。
- WebRTC P2P 联机:玩家之间直连,不经过游戏服务器中转游戏数据。服务器只负责最开始的“牵线”(信令),牵完就退场。这样带宽成本几乎为零,延迟也更低。
- Shadow DOM 隔离:游戏作为一个组件嵌入宿主页面,样式和事件不能污染宿主,宿主也不能干扰游戏。Shadow DOM 是浏览器原生的隔离方案,比 iframe 轻,比全局 CSS 命名空间靠谱。
这三者其实是有内在张力的。零依赖意味着网络层要手写,而 WebRTC 的 API 本身相当啰嗦(RTCPeerConnection、ICE、SDP 协商一大堆);Shadow DOM 又会让事件冒泡和焦点管理变得微妙。所以架构设计的核心,就是把这些复杂度收敛到几个边界清晰的模块里。
2.2 为什么是 Next.js 而不是纯静态
有人会问:既然追求零依赖,为什么构建层还要用 Next.js?这不是自相矛盾吗?
不矛盾。这里的“零依赖”指的是运行时,不是构建时。Next.js 在这里承担三个角色:一是提供 SSR 首屏,让游戏外壳的 HTML 先出来,用户感知更快;二是做静态导出(next export),最终产物就是纯静态文件,可以扔到任何 CDN;三是它的路由和 API Route 能力,可以顺手把 WebRTC 的信令服务塞进一个 Serverless 函数里,省得单独维护一台信令服务器。
我实测下来,用 Next.js 的 API Route 做信令,冷启动大概 200~400ms,对于“进房间”这种一次性操作完全够用。真正高频的游戏数据走 WebRTC DataChannel,根本不碰服务器。这个分工是关键:信令走 HTTP,游戏数据走 P2P。
2.3 模块划分与数据流
整个 OmniGame 我拆成五个模块,边界尽量清晰:
| 模块 | 职责 | 依赖 |
|---|---|---|
| Core | 游戏循环、状态机、时间步进 | 无 |
| Renderer | Canvas 2D 绘制、脏矩形优化 | 无 |
| Input | 键盘/触摸/指针事件归一化 | 无 |
| Net | WebRTC 封装、DataChannel 消息协议 | 浏览器原生 API |
| Shell | Shadow DOM 挂载、生命周期管理 | 无 |
数据流是这样的:Input 采集原始事件 → Core 更新状态 → Renderer 绘制 → 如果是联机模式,Core 把状态变更打包成消息 → Net 通过 DataChannel 广播 → 对端 Net 收到后交给 Core 做状态同步。Shell 在最外层负责把这一切挂到宿主页面的指定节点上。
这个划分的好处是,每个模块都能单独测试。比如 Net 模块我可以脱离游戏,用两个浏览器标签页直接对发消息验证;Renderer 可以喂假数据看绘制效果。模块化不是为了好看,是为了让你在出问题时能快速定位是哪一层挂了。
3. 核心细节解析与实操要点
3.1 零依赖渲染:Canvas 2D 的脏矩形策略
零依赖渲染最容易踩的坑是“每帧全屏重绘”。小游戏还好,一旦画面元素多起来,全屏 clear + 重绘会直接把帧率拖垮。我的做法是脏矩形(dirty rectangle):只重绘发生变化的区域。
具体实现上,我给每个可绘制对象维护一个dirty标记和上一帧的包围盒。每帧开始时,收集所有 dirty 对象的包围盒,合并成若干个矩形区域,然后只对这些区域做clearRect和重绘。合并逻辑我用的是简单的矩形并集,如果两个矩形重叠就合并成一个大矩形,避免重复绘制。
这里有个参数要算清楚:脏矩形合并的阈值。如果两个脏矩形距离很近但不重叠,合并它们可能反而更亏(多绘了空白区域)。我的经验阈值是:当两个矩形之间的间隙小于单个矩形平均宽度的 20% 时,合并;否则分开处理。这个 20% 是我在几个不同游戏上试出来的经验值,不是理论最优,但足够稳。
注意:脏矩形策略对“全屏渐变背景”这类效果不友好,因为背景每帧都在变。我的处理是把静态背景预渲染到离屏 Canvas,每帧只 blit 一次,动态元素再叠加脏矩形。
3.2 WebRTC 连接建立:SDP 协商与 ICE 的那些坑
WebRTC 的 P2P 连接建立,本质是两端交换 SDP(会话描述)和 ICE 候选地址。流程听起来简单,实操里全是细节。
第一步是创建RTCPeerConnection。这里有个关键配置:
const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.example.com:3478' } ], iceCandidatePoolSize: 2 });iceCandidatePoolSize这个参数很多人忽略。它表示预取的 ICE 候选数量。设成 2 可以让候选收集提前开始,缩短连接建立时间。但设太大也没用,反而占资源。我实测 2 是个甜点值。
第二步是创建 DataChannel。注意,谁先创建 DataChannel,谁就是协商的发起方。在游戏场景里,通常房主(Host)创建 DataChannel,加入方(Guest)监听ondatachannel。
// Host 侧 const channel = pc.createDataChannel('game', { ordered: false, maxRetransmits: 0 });这里的参数是重点。ordered: false表示不保证消息顺序,maxRetransmits: 0表示不重传。这两个设置合起来就是UDP 语义,适合实时性要求高、丢一两帧无所谓的游戏状态同步。如果你做的是回合制或者需要可靠传输,那就得改成ordered: true且不设maxRetransmits(走 TCP 语义)。
第三步是 SDP 交换。Host 调createOffer,Guest 调createAnswer,中间通过信令服务器传递。这里最容易出问题的是ICE 候选的收集时机。候选是异步产生的,可能在 SDP 交换完成之后才陆续出来。所以必须监听onicecandidate,把候选通过信令通道实时发给对端。
pc.onicecandidate = (event) => { if (event.candidate) { signaling.send({ type: 'ice', candidate: event.candidate }); } };实操心得:ICE 候选分 host、srflx、relay 三种类型。host 是局域网地址,srflx 是经过 STUN 反射的公网地址,relay 是经过 TURN 中转的地址。如果两端都在对称 NAT 后面,host 和 srflx 都连不上,就必须有 TURN 服务器兜底。TURN 会消耗带宽,但它是最后的保险。我的建议是:STUN 必配,TURN 按需配,别省这个钱。
3.3 Shadow DOM 隔离:样式与事件的边界处理
Shadow DOM 的核心价值是样式隔离。宿主页面的 CSS 进不来,游戏内部的 CSS 出不去。但这也带来两个问题:一是字体、颜色等设计 token 无法继承,二是事件在 Shadow 边界上的行为需要额外处理。
样式方面,我用 CSS 自定义属性(CSS Variables)做“穿透”。宿主可以在挂载点上定义--omni-primary-color之类的变量,Shadow 内部通过var()引用。这样既保持了隔离,又留了定制口子。
/* 宿主页面 */ #game-mount { --omni-primary-color: #4a90d9; --omni-font: system-ui, sans-serif; }/* Shadow 内部 */ :host { color: var(--omni-primary-color, #333); font-family: var(--omni-font, sans-serif); }事件方面,Shadow DOM 内部的事件默认不会冒泡到宿主(除非composed: true)。键盘事件尤其要注意:如果游戏需要捕获方向键,得在 Shadow 根节点上监听,并且调用preventDefault防止页面滚动。但preventDefault在 passive 监听器里是无效的,所以必须显式声明{ passive: false }。
shadowRoot.addEventListener('keydown', (e) => { if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); input.handle(e); } }, { passive: false });注意:焦点管理是 Shadow DOM 的另一个坑。如果游戏区域没有
tabindex,它无法接收键盘事件。我的做法是给挂载容器加tabindex="0",并在点击时主动focus()。
3.4 消息协议设计:二进制还是 JSON
DataChannel 支持两种数据格式:字符串(通常是 JSON)和二进制(ArrayBuffer/Blob)。JSON 可读性好、调试方便,但体积大、解析慢;二进制体积小、解析快,但调试麻烦。
我的选择是混合:控制类消息(加入、离开、准备)用 JSON,高频状态同步用二进制。二进制我用的是自定义的紧凑格式:1 字节消息类型 + 若干字节的定长字段。比如玩家位置同步,就是[type:1][playerId:1][x:2][y:2],一共 6 字节。相比 JSON 的{"t":"pos","id":1,"x":100,"y":200}动辄 30 多字节,压缩率超过 80%。
这里有个计算:假设 60fps 同步 4 个玩家,JSON 方案每秒约30 * 4 * 60 = 7200字节,二进制方案约6 * 4 * 60 = 1440字节。在弱网环境下,这个差距会直接影响丢包率和延迟。
4. 实操过程与核心环节实现
4.1 从零搭建项目骨架
第一步是初始化 Next.js 项目。我用的是 App Router,因为它的布局和组件模型更清晰。
npx create-next-app@latest omnigame --typescript --app --no-tailwind注意我特意没选 Tailwind。原因很简单:OmniGame 的样式全部在 Shadow DOM 内部,Tailwind 的全局类名机制在这里帮不上忙,反而增加构建复杂度。游戏 UI 的样式我手写,量不大,可控性更高。
项目结构大概是这样:
omnigame/ app/ page.tsx # 宿主页面 api/ signal/route.ts # 信令 API Route game/ core.ts # 游戏循环与状态机 renderer.ts # Canvas 渲染 input.ts # 输入处理 net.ts # WebRTC 封装 shell.ts # Shadow DOM 挂载 protocol.ts # 消息协议 public/ sprites/ # 静态资源game/目录下的代码全部是纯 TypeScript,不依赖任何框架。这样它们可以被单独测试,也可以被复用到其他项目里。
4.2 信令服务的实现与部署
信令服务我放在app/api/signal/route.ts,用 Next.js 的 Route Handler 实现。它的职责很简单:维护房间号到连接的映射,转发 SDP 和 ICE 候选。
// 简化版信令逻辑 const rooms = new Map<string, Set<Controller>>(); export async function POST(req: Request) { const { roomId, type, payload } = await req.json(); const room = rooms.get(roomId) || new Set(); // 广播给房间内其他连接 for (const peer of room) { peer.enqueue(JSON.stringify({ type, payload })); } return new Response('ok'); }实际生产环境我用的是 Server-Sent Events(SSE)做下行推送,POST 做上行发送。SSE 比 WebSocket 简单,在 Serverless 环境里也更省心。但要注意,Serverless 函数有执行时长限制,SSE 连接不能一直挂着。我的做法是信令连接只在“进房间”阶段保持,连接建立后立即关闭。因为 P2P 一旦连上,就不再需要信令了。
实操心得:信令服务不需要持久化任何数据,房间状态全在内存里。这意味着它可以水平扩展,但同一房间的两端必须打到同一个实例上。如果你的部署环境是多实例的,要么用粘性会话,要么把房间状态放到外部存储(如 Redis)。我图省事,直接单实例部署,反正信令流量极小。
4.3 游戏循环与时间步进
游戏循环我用的是requestAnimationFrame加固定时间步进(fixed timestep)。为什么不用可变步进?因为可变步进在不同帧率下物理表现不一致,联机时会导致两端状态漂移。
固定步进的逻辑是:累积真实经过的时间,每超过一个固定步长(我设的是 16.67ms,即 60Hz)就执行一次逻辑更新,剩余时间用于插值渲染。
const STEP = 1000 / 60; let accumulator = 0; let lastTime = performance.now(); function loop(now: number) { const delta = now - lastTime; lastTime = now; accumulator += delta; while (accumulator >= STEP) { core.update(STEP); accumulator -= STEP; } const alpha = accumulator / STEP; renderer.draw(core.state, alpha); requestAnimationFrame(loop); }alpha是插值系数,用于在两次逻辑更新之间平滑渲染。这个细节很多人忽略,导致画面看起来“一顿一顿”的。加上插值之后,即使逻辑是 60Hz,渲染也能跟显示器的刷新率对齐。
4.4 P2P 联机的完整握手流程
把前面讲的碎片拼起来,完整的联机流程是这样的:
- Host 创建房间,生成房间号,连上信令服务。
- Guest 输入房间号,连上信令服务。
- Host 创建
RTCPeerConnection和 DataChannel,调createOffer,把 offer 通过信令发给 Guest。 - Guest 收到 offer,创建
RTCPeerConnection,调setRemoteDescription,再调createAnswer,把 answer 发回 Host。 - 双方在
onicecandidate里把候选通过信令互发,调addIceCandidate。 - 连接状态变为
connected,DataChannel 的onopen触发,游戏开始同步。
这个过程我实测下来,局域网内大概 100~300ms 建立连接,跨公网 500ms~2s 不等,取决于 NAT 类型和网络质量。
注意:
setRemoteDescription和addIceCandidate都是异步的,而且有顺序要求。如果候选先于 remote description 到达,addIceCandidate会报错。我的处理是维护一个候选队列,等 remote description 设置完成后再批量添加。
4.5 状态同步策略:谁说了算
联机游戏最核心的问题是“谁的状态是权威”。OmniGame 用的是**主机权威(host-authoritative)**模型:Host 是唯一的状态真相来源,Guest 只发送输入,Host 计算后广播结果。
这样做的好处是避免了两端各自计算导致的漂移。代价是 Guest 的操作要等一个 RTT 才能看到反馈。对于休闲小游戏,这个延迟可以接受;对于竞技类,就需要客户端预测和回滚,复杂度会高很多。
Guest 侧的输入打包成[type:1][inputBits:1],每秒发 30 次(不是 60 次,省带宽)。Host 收到后更新状态,再把状态快照广播给所有 Guest,也是每秒 30 次。中间帧靠插值补。
5. 常见问题与排查技巧实录
5.1 连接建立失败排查表
WebRTC 连接失败是最常见的问题,原因五花八门。我整理了一张排查表,按概率从高到低排:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 一直停在 connecting | ICE 候选没交换成功 | 打印双方候选列表,看是否有 srflx |
| 连接后立即断开 | SDP 协商不匹配 | 检查 offer/answer 的 m-line 是否一致 |
| 局域网能连,公网不能 | 缺 STUN/TURN | 确认 iceServers 配置正确 |
| 部分用户连不上 | 对称 NAT | 部署 TURN 服务器兜底 |
| DataChannel 打不开 | 创建时机不对 | 确认 createDataChannel 在 createOffer 之前 |
5.2 性能问题的定位思路
游戏卡顿的原因可能出在渲染、逻辑、网络任何一层。我的定位方法是分层计时:在 Core、Renderer、Net 三个模块的入口和出口打时间戳,每 60 帧统计一次平均值。
const perf = { core: 0, render: 0, net: 0, sample(name: string, fn: () => void) { const t0 = performance.now(); fn(); this[name] = this[name] * 0.9 + (performance.now() - t0) * 0.1; } };用指数移动平均(EMA)而不是简单平均,是为了让数据更平滑,避免单帧尖刺干扰判断。实测下来,如果render稳定超过 8ms,就该优化绘制了;如果core超过 4ms,说明逻辑太重;net一般不是瓶颈,除非消息量特别大。
5.3 内存泄漏的隐蔽来源
网页小游戏的内存泄漏,十有八九出在事件监听和定时器上。Shadow DOM 卸载时,如果内部注册的监听器没清理,整个 Shadow 树都会被引用住,无法回收。
我的做法是给 Shell 模块加一个dispose()方法,统一清理:
dispose() { cancelAnimationFrame(this.rafId); this.pc?.close(); this.channel?.close(); this.shadowRoot?.replaceChildren(); this.listeners.forEach(([target, type, fn]) => { target.removeEventListener(type, fn); }); }listeners是一个数组,每次addEventListener时把参数存进去,dispose时统一移除。这个模式有点笨,但极其可靠。我踩过一次坑:游戏切换场景时忘了关旧的 DataChannel,结果两个连接同时收消息,状态直接错乱。
5.4 弱网环境下的表现优化
弱网是 P2P 联机的天敌。我的优化手段有三个:
- 降低同步频率:从 60Hz 降到 30Hz,带宽减半,肉眼几乎看不出差别。
- 状态压缩:位置用 16 位整数(精度到像素),角度用 8 位(精度到 1.4 度),够用就行。
- 丢包容忍:DataChannel 设
maxRetransmits: 0,丢了就丢了,下一帧的状态会覆盖。对于位置同步,这比等重传更划算。
实操心得:我做过一个对比测试,在模拟 10% 丢包的环境下,可靠传输(重传)的延迟中位数是 180ms,不可靠传输是 45ms。对于实时游戏,45ms 的体验远好于 180ms,哪怕偶尔丢一帧。
6. 这套方案还能怎么扩展
OmniGame 目前的形态是一个最小可用的框架,但它的模块化设计留了不少扩展口子。比如 Net 模块可以加一个“中继模式”,当 P2P 实在连不上时,自动降级到通过服务器转发;Renderer 可以加 WebGL 后端,应对更复杂的画面;Core 可以加录像和回放,把输入序列存下来就能重放整局游戏。
我自己接下来想试的是多房间 Mesh 组网:超过 2 个玩家时,不是所有人都连 Host,而是组成一个网状结构,每个玩家只和最近的几个邻居同步。这样 Host 的带宽压力会小很多,但一致性维护会更复杂。这个方向我还在验证,等有稳定结论了再单独写一篇。
最后分享一个我在调试 WebRTC 时最常用的小技巧:Chrome 的chrome://webrtc-internals页面能看到所有连接的详细统计,包括候选类型、往返延迟、丢包率、码率。每次连接出问题,我第一件事就是打开这个页面,比在代码里打日志高效得多。