WebRTC + Shadow DOM 实战:一个高性能网页小游戏平台的技术复盘
最近几个月,我利用业余时间做了一个网页小游戏平台,叫 OmniGame。从最开始的一个想法,到现在上线了 100 多款游戏、支持 P2P 联机,中间踩了不少坑。这篇文章我想从技术角度复盘一下,整个过程中遇到的问题、我们的解决方案,以及一些对浏览器技术的思考。
一、为什么想做这个东西
事情的起因很简单:某天下午我在工位上犯困,想打开浏览器玩个扫雷醒醒神。
结果我搜了一下"在线小游戏",点进前几个网站,体验让我非常难受:
- 首先是一堆弹窗广告,关完都要十秒钟
- 然后首屏加载了 6MB 的 JavaScript,转圈转了半天
- 好不容易游戏出来了,玩了 30 秒,又弹一个"恭喜你获得新手大礼包"
- 更气人的是,玩完关了标签页,发现我正在写的文档网站样式都变了——游戏的全局 CSS 污染了整个页面
我当时就在想:Web 技术发展这么多年了,做个网页小游戏怎么还是这么 primitive?
于是我就有了一个想法:能不能自己做一个干净的、没有广告的、打开就玩的小游戏平台?
后来越想越觉得可以做,而且可以做得很不一样:
- 零广告——不加任何广告 SDK,首屏体积能压到极小
- P2P 联机——用 WebRTC 做直连,不经过服务器,延迟低还省钱
- 沙盒隔离——用 Shadow DOM 把每款游戏隔离起来,不污染页面
这个项目从今年年初开始做,到现在差不多半年了。这篇文章就聊聊中间几个核心技术点的实现思路和踩坑经验。
二、首屏体积从 6MB 压到 200KB,我们做了什么
第一个坑是性能。
我最开始用 Next.js 搭官网的时候,跑了一下 Lighthouse,首屏性能得分只有 45 分——主要问题就是 JS 包太大了。我看了一下打包分析,光 react、react-dom、next 这几个基础包就快 200KB 了,再加上其他第三方库,首屏轻轻松松就上了 1MB。
当时我就定了一个目标:首屏加载体积必须压到 200KB 以内。
这个目标听起来很极端,但做起来其实就是一个原则:能不引入的依赖,坚决不引入。
2.1 砍掉所有非必要的第三方库
我先把项目里的第三方依赖全部列了一遍:
- 动画库:Framer Motion(150KB)→ 换成 CSS transition + Web Animations API
- 图标库:@fortawesome/fontawesome(200KB)→ 换成 inline SVG
- 工具库:lodash(70KB)→ 换成自己写的几个工具函数
- 日期处理:dayjs(7KB)→ 根本用不到,直接删了
砍完之后,整个项目的运行时依赖就剩 react、react-dom、next 这三个基础包。生产环境打包出来的 JS,首屏 chunk 大概 120KB,gzip 之后大概 40KB。
2.2 代码分割做到极致
Next.js 的 App Router 自带代码分割,但很多人用得不好——所有组件都打到首屏 chunk 里了。
我们的做法是:
- 首页的游戏列表,只加载前 20 个游戏的元数据,其他的全部懒加载
- 每一款游戏的详情、联机功能,都是独立的 chunk,用户点进去才加载
- 后台管理页面、设置页面,全部都是动态 import,根本不进首屏
这样做的结果是:用户打开首页,不需要加载"游戏详情页"的代码、不需要加载"联机引擎"的代码、不需要加载"后台管理"的代码——只加载首页本身需要的东西。
2.3 静态资源的优化
图片方面,我们把所有游戏缩略图都转成了 WebP 格式,尺寸控制在 20KB 以内。而且我们不会在首屏加载所有图片——用 IntersectionObserver 做了懒加载,视口内的图片才加载,视口外的不加载。
字体方面,我们直接用了系统默认字体栈,不加载任何网络字体:
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
这样做有两个好处:一是省了字体加载的体积,二是字体渲染更快——系统字体是操作系统自带的,不需要网络下载。
最后加了 Cloudflare CDN,所有静态资源都有严格的缓存策略:JS/CSS 带 content hash,缓存一年;HTML 缓存一分钟。用户第二次访问,几乎就是从本地缓存直接读,打开速度极快。
2.4 效果
做完这些优化之后,我们跑了一下 Lighthouse:
- 首屏性能得分:98 分
- 首屏加载体积:gzip 后不到 80KB
- 首屏加载时间(4G 网络):大概 0.8 秒
对比我最开始点的那些小游戏网站(首屏 6MB+,加载 10 秒+),这个提升是数量级的。
三、WebRTC P2P 联机,我踩过的那些坑
第二个核心技术点是联机。
最开始我想的是用 WebSocket 做中转——实现简单,服务端就是一个 WebSocket 服务器,广播消息就行。但很快我就发现两个问题:
第一,延迟高。你和朋友在同一个办公室,连同一个 Wi-Fi,结果你的操作要先发到几百公里外的云服务器,再发回你朋友的电脑——一个来回 50ms 就没了。对于动作类游戏,这个延迟是不可接受的。
第二,成本高。所有流量都过我们的服务器,用户越多,带宽成本越高。这对于业余项目来说,是个不小的负担。
后来我就想到了 WebRTC——能不能让两台设备直连,不经过我们的服务器?
3.1 WebRTC 的基本原理
简单说一下 WebRTC 是怎么工作的:
- 两台设备先通过一个"信令服务器"交换一些信息——SDP 描述、ICE 候选
- 然后两台设备尝试打洞——如果 NAT 类型合适,就能直接建立 P2P 连接
- 一旦连接建立,数据就直接在两台设备之间传,不经过任何服务器
这个过程听起来简单,但实现起来坑特别多。
3.2 坑一:STUN 服务器的问题
我最开始写了个 demo,两台设备在同一个局域网下,能连上。结果一跨网,就失败了。
查了半天才发现:我根本没配 STUN 服务器。
WebRTC 打洞的原理是:你把自己的本地 IP 和公网 IP(通过 STUN 服务器获取)都告诉对方,然后双方尝试从不同的路径连接。如果不配置 STUN 服务器,你根本不知道自己的公网 IP 是什么,打洞自然就失败了。
后来我加了两个公共的 STUN 服务器:
stun:stun.l.google.com:19302
stun:stun1.l.google.com:19302
跨网连接的成功率一下子就上来了。
3.3 坑二:DataChannel 的配置
最开始我用的是默认的 DataChannel 配置,结果发现:传数据的时候丢包率很高,有时候延迟波动很大。
查了一下文档,发现 DataChannel 有两种模式:
- unreliable 模式:不保证送达,也不保证顺序,延迟最低
- reliable 模式:保证送达,保证顺序,延迟高一些
对于游戏来说,我们需要的是:位置更新这种实时性强的数据,用 unreliable 模式;而游戏开始、结束这种关键事件,用 reliable 模式。
后来我针对不同类型的消息,用了不同的 channel 配置,丢包和延迟的问题就解决了。
3.3 坑三:打洞失败怎么办
即使配了 STUN 服务器,还是有一部分用户打洞失败——比如双方都是对称型 NAT,这种情况下直连是不可能的。
这个时候就需要 TURN 服务器做中转。
我自己在服务器上部署了一个 coturn(开源的 TURN 服务器),然后配置到 WebRTC 的 ICE 候选里。如果打洞失败,就自动降级到 TURN 中转。
虽然中转的延迟比直连高,但至少能连上——总比连不上强。
3.4 封装成简单的 API
WebRTC 的底层细节太多了,对于游戏开发者来说太复杂。所以我把所有这些细节都封装起来,对外暴露一个非常简单的 API:
// 创建房间,返回房间号
const roomCode = await OmniNet.createRoom();
// 加入房间
await OmniNet.joinRoom(roomCode);
// 发消息
OmniNet.send({ type: 'move', x: 100, y: 200 });
// 监听消息
OmniNet.onMessage((data) => {
// 处理对方发来的数据
});
开发者不需要知道什么是 ICE、什么是 SDP、什么是打洞——这些我们全部处理好了。
3.5 实际效果
现在我们的联机效果是:
- 局域网内延迟:1-5ms,几乎和本地玩一样
- 跨网直连延迟:30-80ms,取决于双方的网络情况
- 打洞失败的情况:自动降级到 TURN 中转,延迟 100-200ms,但至少能连上
而且因为流量不经过我们的服务器,带宽成本几乎为零——用户越多,我们越省钱。
四、Shadow DOM 沙盒,为什么不用 iframe
第三个坑是游戏环境的隔离。
最开始我想的很简单:每款游戏放到一个 iframe 里不就行了?iframe 天然就是隔离的。
但实际做的时候,发现 iframe 有几个问题:
第一,性能开销大。每一个 iframe 都是一个独立的浏览器上下文,创建和销毁的开销很大。而且 iframe 之间通信很麻烦——要 postMessage,还要序列化反序列化。
第二,样式不好控制。我想给游戏容器做圆角、做阴影、做过渡动画?很难——因为 iframe 是一个独立的文档,你控制不了它的内部样式。
后来我想到了 Shadow DOM。
4.1 Shadow DOM 是什么
Shadow DOM 是 Web Components 标准的一部分。简单说,就是你可以在普通的 DOM 树里,创建一个"隔离的子树"——这个子树内部的 CSS 和 JS,对外界完全不可见,外界的 CSS 也影响不到它内部。
听起来和 iframe 很像,但它比 iframe 好太多了:
- 性能好:Shadow DOM 不是独立的浏览器上下文,它就是普通的 DOM,创建销毁几乎没有开销
- 样式可控:你可以在父页面里给 Shadow DOM 宿主元素做圆角、阴影、动画
- 通信简单:Shadow DOM 里的 JS 和父页面的 JS 是同一个上下文,直接调用就行
4.2 我们的实现方式
每款游戏的运行方式是这样的:
- 先在页面上创建一个普通的
<div>作为宿主元素 - 给这个 div 附加一个 Shadow Root:
div.attachShadow({ mode: 'open' }) - 把游戏的 HTML、CSS、JS 全部注入到这个 Shadow Root 里面
这样,游戏内部的所有 CSS 都只在 Shadow Root 内部生效,不会泄漏到外面;外面的所有 CSS 也影响不到游戏内部。
JS 方面,我们把游戏的代码放在一个立即执行函数里,避免污染全局变量:
(function() {
// 游戏代码全部在这里
// 不会污染 window 全局对象
})();
4.3 效果
现在的效果是:
- 100 款游戏打开,每一款都在自己的沙盒里,互不干扰
- 游戏崩了?把对应的 Shadow Root 销毁就行,其他游戏和主页面完全不受影响
- 游戏的弹窗、按钮、样式,全部都在自己的沙盒里,不会把整个页面搞乱
之前我遇到的那个"游戏 CSS 污染整个页面"的问题,从根本上就解决了。
五、一些对浏览器技术的思考
做这个项目的过程中,我最大的感受是:浏览器已经非常强大了,但很多人还没有把这些能力用好。
WebRTC 出来快十年了,Shadow DOM 也出来很多年了,但真正把它们组合起来做好产品的,并不多。
很多人做网页游戏,第一反应还是用 iframe、用 WebSocket、用 jQuery 写一堆全局脚本——这种老技术栈,体验当然好不了。
但反过来想,这也是机会:因为浏览器的能力一直在变强,但很多产品还停留在十年前的思路。只要你愿意去用这些原生能力,就能做出体验好很多的产品。
5.1 关于"零依赖"的思考
我做这个项目还有一个很深的感受:很多时候,我们引入第三方库,是因为我们觉得这些功能很复杂,但实际上浏览器原生就能做到。
比如动画——以前我们用 jQuery 的 animate,后来用 Framer Motion,现在发现 CSS transition + Web Animations API 就够了,而且性能更好、体积更小。
比如图标——以前我们用图标字体,后来用各种图标库,现在发现 inline SVG 就够了,而且更灵活、体积更小。
比如存储——以前我们用各种状态管理库,现在发现浏览器原生的 localStorage、IndexedDB 就够了。
当然,这不是说第三方库不好——很多时候第三方库确实能提高开发效率。但我们应该先想一下:这个功能,浏览器原生能不能做到?如果能,是不是就不用引入第三方库了?
5.2 关于隐私的思考
做这个项目的时候,我从一开始就没打算加第三方统计 SDK、没打算加广告 SDK。
原因很简单:我觉得用户在浏览器里玩个游戏,没必要把自己的所有行为都交给第三方公司分析。
所以我们的统计就是自己写的极简脚本——只记录页面访问量和游戏点击量,不记录 IP、不记录设备指纹、不追踪用户行为路径。整个统计脚本只有 2KB。
这可能是一个"反潮流"的选择——现在的互联网产品,恨不得把用户的所有行为都记录下来,然后变现。但我总觉得,好的产品应该是"不打扰用户"的。
六、最后
这个项目做了半年,现在算是初具雏形了:100 多款游戏、支持 P2P 联机、首屏秒开、没有广告。
当然还有很多东西没做完:云存档、更多游戏、更完善的联机功能……但至少核心的技术架构已经跑通了。
写这篇文章,一方面是复盘一下这半年踩过的坑,另一方面也是想分享一下:浏览器的原生能力其实已经很强了,只要你愿意去挖掘,就能做出体验很好的产品。
如果你也对 WebRTC、Shadow DOM、或者网页游戏开发感兴趣,欢迎交流。
项目地址:https://www.omnigame.top/
欢迎体验,有问题也欢迎反馈。
浙公网安备 33010602011771号