ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebRTC + Shadow DOM实践:网页游戏平台实时同步与组件隔离方案

WebRTC + Shadow DOM实践:网页游戏平台实时同步与组件隔离方案 1. 项目复盘为什么这个网页小游戏平台要换成 WebRTC Shadow DOM去年我接了一个挺有意思的活儿一个网页小游戏平台不是那种门户网站上挂着一排小游戏链接而是像“游戏房间”一样几个人进同一个房间能对局、能语音、能实时看到对方操作。平台本身不跑重度游戏都是轻量的 HTML5 小游戏但用户对“秒开”“不崩”“一个页面里同时挂很多个游戏入口”的要求非常高。一开始方案也是俗套的游戏页面用 iframe 嵌进来多人同步走 WebSocket 长连接。结果上线不到两周问题全暴露了。iframe 嵌五六个小游戏页面还好一旦同时打开十几个浏览器内存和切换延迟肉眼可见。WebSocket 连接在公网弱网环境下的丢包重传也是个麻烦棋牌类、对战类小游戏对状态同步的实时性要求很高长连接在这个场景下做得再精细也掩盖不了 TCP 重传带来的卡顿。也就是从那时起我开始认真考虑 WebRTC Shadow DOM 这套组合。这个组合解决的是两个独立问题WebRTC 负责端到端实时数据传输Shadow DOM 负责把每个小游戏封装成真正隔离的组件。前者让玩家之间可以直接建立低延迟的数据通道后者让平台页面的样式、脚本和小游戏之间不再互相污染。如果你手头也在做类似的网页游戏聚合页、互动白板、在线桌游室或者只是想把一堆第三方小游戏组件安全地嵌进同一个页面这篇复盘应该能帮你少踩几个坑。1.1 平台要解决的三个核心问题先把我当时要解决的问题摆出来。这三个问题其实是这类平台的共性不只是我这个项目特有。第一个是启动速度。用户点进一个游戏房间不能说等三秒才看到画面。iframe 的缺点在于每个嵌页都是一个独立 document、独立 JS 执行环境初始化一个 iframe 的成本远远高于创建一个自定义元素。虽然浏览器对 iframe 有复用优化但游戏平台的目标是“大厅里一屏能看到十几个可预览的小游戏卡片”每个卡片都挂一个 iframe 显然不现实。第二个是多人实时同步。小游戏的玩法不同但底层的实时需求很像A 玩家移动了棋子B 玩家要立刻看到C 玩家语音说了一句话其他玩家得听清。WebSocket 用 TCP传输可靠但丢包重传会造成延迟抖动用 WebRTC DataChannel 可以走 UDP 通道并且自己控制消息的可靠性和重传次数这是它被我用上的核心原因。第三个是组件隔离。平台要把第三方开发者写的游戏包嵌进来这些游戏包可能用了不同的 CSS 框架、不同版本的全局库直接塞进宿主页面基本是灾难。Shadow DOM 能把样式和 DOM 树隔离起来但又不像 iframe 那样切割出一个独立的 window性能和体验更接近原生组件。我们最终用 Web Component Shadow DOM 作为游戏包的运行沙箱。1.2 为什么 WebRTC 比 WebSocket 更适合做游戏实时同步WebSocket 不是不能用很多线上桌游、棋牌平台也跑得好好的。但我在这个项目里对比过一组数据一个房间 4 个人每秒钟每个人要广播 10 条左右的状态消息共用一条信令通道。高峰期单条消息经历“客户端 → 服务器 → 目标客户端”的链路延迟大约在 50 到 120ms 之间波动。打休闲游戏够用但用户稍微密集操作比如连续移动、快速出牌就会感觉“慢半拍”。WebRTC 的连接模型是 PeerConnection两端建立连接后数据可以直接在浏览器之间传输不需要每次都绕服务器。即使实际网络里必须走 TURN 中继链路也比“先到业务服务器再转发”短而且 DataChannel 给了两个关键选项ordered 和 maxRetransmits。我可以把棋牌房间里的出牌消息配置成可靠传输把鼠标移动、转盘动画这类高频低重要性的消息配置成不可靠快速传输这是 WebSocket 很难做到的。WebSocket 也有二进制帧、可以自己实现序号和重传逻辑但那就等于在 TCP 之上再造一个 UDP 协议开发成本高效果还不如原生 DataChannel。当然 WebRTC 也有代价。它引入了一整套信令流程、ICE 候选、NAT 穿透调试起来比 WebSocket 复杂得多。要不要用取决于你的业务对延迟和带宽敏感到什么程度。像视频会议、实时协作、多人小游戏这类场景值得如果是聊天室、或者消息量很小的物联网控制用 WebSocket 反而更省心。1.3 为什么 Shadow DOM 比 iframe 和前端框架隔离更合适我看到很多团队在“嵌入第三方页面”时第一反应还是 iframe。iframe 确实是浏览器级别最强的隔离JavaScript 上下文、样式、全局变量全部隔离但代价也明显每个 iframe 都是一份完整的浏览器环境内存开销高、通信只能靠 postMessage、跨域场景还得配置 frame-ancestors、 CSP frame-src。在游戏平台大厅这种密集实例场景里iframe 不适合用来做“可预览的游戏卡片”。Shadow DOM 则不同。它不创建新的 window所有节点仍然属于宿主 document但样式作用域被限制在 shadow tree 内部。外部页面的 CSS 选择器基本选不进 shadow root内部样式也不会跑到全局去。这意味着我可以把游戏的 HTML、CSS、部分 JavaScript 封装成一个自定义元素game-card宿主页面只管摆放和传数据不需要担心样式覆盖。通信也简单自定义元素本身就是天然的事件边界用 CustomEvent 抛事件就好比 postMessage 那一套直观得多。当然 Shadow DOM 不是万能沙箱。它不隔离 JavaScript 执行环境游戏代码如果主动访问 window、document或者在 shadow root. 内写入恶意脚本宿主页面依然有风险。所以对不可信第三方代码我们仍然会上 CSP 限制脚本来源必要时配合 iframe 或独立的 Web Worker 来做更严格的执行隔离。Shadow DOM 解决的是“样式和 DOM 结构不互相污染”这个高频问题不是安全边界。2. WebRTC 实战信令、DataChannel 和小游戏的帧同步开始写 WebRTC 代码之前我建议你先想明白一件事WebRTC 本身不管“谁在哪个房间”。PeerConnection 的建立需要两端先交换元数据这个交换动作叫信令WebRTC 规范里不限定信令怎么实现。你项目里已经有的 WebSocket、MQTT、甚至 HTTP 轮询都能干这个活。我最后选了 WebSocket 做信令因为小游戏平台本身需要一个房间状态推送通道把信令消息和系统消息混在这条连接上少维护一套连接。2.1 最小信令服务只需要做一件事转发 JSON很多人第一次接触 WebRTC 会被 SDP、ICE candidate 这些名词吓到。拆开看其实很简单发起方 createOffer 之后得到一段描述自身能力的 SDP通过信令发给接收方接收方 createAnswer 之后把自己的 SDP 回传然后两边不停交换 ICE candidate告诉对方“我这个端口通了你可以尝试连过来”。信令服务器就是把这些 JSON 原文转发给目标用户。我当时用 Node.js 写了一个非常薄的信令服务核心逻辑大概是这样的const WebSocket require(ws); const wss new WebSocket.Server({ port: 8787 }); const rooms new Map(); function sendTo(ws, payload) { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify(payload)); } } wss.on(connection, (ws, req) { ws.on(message, (raw) { const msg JSON.parse(raw); if (msg.type join) { // roomId 房间号userId 当前用户 if (!rooms.has(msg.roomId)) rooms.set(msg.roomId, new Map()); rooms.get(msg.roomId).set(msg.userId, ws); ws.roomId msg.roomId; ws.userId msg.userId; } if (msg.type signal) { // 转发 SDP 或 ICE candidate const target rooms.get(msg.roomId)?.get(msg.to); sendTo(target, { type: signal, from: msg.from, data: msg.data, }); } }); ws.on(close, () { rooms.get(ws.roomId)?.delete(ws.userId); }); });这段代码没有任何业务逻辑仅仅是房间注册和消息转发。注意一个问题房间里的每个用户都要维护“谁是谁的 PeerConnection”这张映射表信令消息里必须带上 from 和 to。我当时因为省略 to 字段踩过坑两个人同时加入房间信令串线SDP 乱套排查了半天。生产环境下不建议用公共 STUN 服务器官方公共 STUN 只适合测试。我后来搭了 coturn 作为 STUN/TURN 服务TURN 的中继端口范围、鉴权方式都配置到一个独立域名下面。虽然多数局域网和轻度 NAT 环境用不上 TURN但一旦用户处在对称 NAT 或者公司防火墙后面没有 TURN 就只能干等连接超时。2.2 PeerConnection 连接建立的六个关键步骤信令服务有了客户端连接逻辑其实可以归纳成六步。第一步创建 RTCPeerConnection第二步如果是发起方调用 createDataChannel第三步创建 offer 并 setLocalDescription第四步通过信令把 SDP 发给对方第五步接收方 setRemoteDescription 后创建 answer 并回传第六步监听 onicecandidate把 candidate 发出去。顺序不能乱尤其是 setLocalDescription 和发送 SDP 的先后。setLocalDescription 之后ICE 才会开始收集候选onicecandidate 才会触发。一个带 DataChannel 的建立流程伪代码如下const pc new RTCPeerConnection({ iceServers: [ { urls: stun:your-turn-domain:3478 }, { urls: turn:your-turn-domain:3478, username: game, credential: secret, }, ], }); let channel; function setupDataChannel() { channel pc.createDataChannel(game-state, { ordered: false, maxRetransmits: 0, }); channel.onopen () console.log(data channel open); channel.onmessage handleGameMessage; } pc.onicecandidate (event) { if (event.candidate) { sendSignal({ type: candidate, to: remoteId, data: event.candidate }); } }; pc.ondatachannel (event) { channel event.channel; channel.onmessage handleGameMessage; }; async function createAndSendOffer() { setupDataChannel(); const offer await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ type: offer, to: remoteId, data: pc.localDescription }); } async function handleRemoteDescription(data, from) { if (data.type offer) { const answer await pc.createAnswer(); await pc.setLocalDescription(answer); sendSignal({ type: answer, to: from, data: pc.localDescription }); } else if (data.type candidate) { await pc.addIceCandidate(data); } }这里有几个非常容易踩坑的点。第一个是 DataChannel 必须在 createOffer 之前创建至少发起方要这样。如果连 offer 都发完了再去 createDataChannel部分浏览器会不生成 mapplication 字段导致接收方收不到 ondatachannel。第二个是 answer 和 candidate 的顺序接收方最好先把 remote SDP set 了再批量添加已经收到的 ICE candidate否则浏览器会报 “ICE candidate added before remote description”。第三个是共享逻辑因为发起方和接收方都会收到 ondatachannel 或者 createDataChannel要设计成同一套消息处理函数避免两边逻辑分叉。2.3 DataChannel 的可靠性与延迟取舍DataChannel 最让我满意的是它支持按消息配置可靠性。RTCDataChannel 创建时可以设置 ordered、maxRetransmits、maxPacketLifeTime。orderedtrue 表示严格按照发送顺序交付适合回合制操作orderedfalse 可以乱序交付适合高频状态覆盖。maxRetransmits0 表示不重传丢一条就算了适合那些“新状态会覆盖旧状态”的同步消息。在我那个平台里我开了两条 DataChannel而不是所有消息混在一条通道里。一条叫state配置成 ordered: false, maxRetransmits: 0用来传玩家位置、动画状态、指针坐标这类高频低价值消息。另一条叫command配置成 ordered: true, maxRetransmits: 15用来传出牌、落子、胜负判定等关键操作。这样做的原因是把可靠消息和不可靠消息混在一起一旦通道的 backpressure 加剧可靠消息的重传会阻塞后面的不可靠消息等于把 UDP 的实时性拖回 TCP 的泥潭。还要注意 DataChannel 的消息类型。你可以传 DOMString也可以传 ArrayBuffer。网络消息我统一用 JSON 字符串只有大块二进制数据比如游戏头像缩略图、语音片段才走 ArrayBuffer。不要一上来就用二进制协议除非你已经做了严格的基准测试。小游戏平台的协议首要任务是可读、可调试JSON 足够。2.4 带宽占用和背压处理WebRTC DataChannel 没有像 TCP 那样复杂的拥塞控制它底层走的是 SCTP over DTLS over UDP有自己的流控但在移动端弱网场景下如果你持续高频发数据照样会出现队列堆积、延迟升高。要处理背压最简单的方法是写一个发送队列当 channel.bufferedAmount 超过阈值时暂停发送等 bufferedamountlow 事件触发再继续。我当时给每个 DataChannel 都加了监控channel.onbufferedamountlow () { if (pendingMessages.length 0) { sendPending(); } }; function safeSend(data) { if (channel.bufferedAmount 256 * 1024) { pendingMessages.push(data); return; } channel.send(data); }这个 256KB 阈值是根据游戏状态大小估出来的。每个状态消息几百字节阈值定太低位会频繁触发发送暂停定太高又会导致瞬时延迟变大。另外语音如果走 WebRTC 的 audio track占用的带宽和 DataChannel 是分开计算的但仍然共享同一个上行带宽。后期我们发现语音和游戏数据同时跑满时可以动态降低语音码率把带宽优先让给游戏状态。3. Shadow DOM 实战把游戏封装成“真组件”WebRTC 解决了平台多人同步的传输层接下来要处理的是游戏本身的承载方式。我选的是 Web Component 里的自定义元素 Shadow DOM对象是每个小游戏包。一个游戏包不再是一个 iframe URL而是一组 HTML 模板、CSS 和脚本最后注册成一个标签例如popgame-chess。3.1 游戏宿主与游戏包的约定为了让第三方游戏包能统一接入我和开发者定了几个接口约定。每个游戏包必须导出一个自定义元素类元素内部使用 attachShadow 创建 shadow root游戏运行所需的 DOM 都挂在 shadow tree 里。宿主页面只负责把这个元素放到游戏容器里然后通过属性或方法传入游戏配置。比如一个双人棋类游戏宿主代码大概是这样的customElements.define(popgame-chess, class extends HTMLElement { connectedCallback() { const shadow this.attachShadow({ mode: open }); shadow.innerHTML style :host { display: block; width: 100%; height: 100%; } .board { background: #f0e0b0; } /style div classboard/div ; } startGame(config) { this.shadowRoot.querySelector(.board).dataset.gameId config.gameId; } });这个接口约定让所有游戏包变成了“输入配置输出可交互节点”的标准组件。平台大厅里的预览卡片直接放一个popgame-chess真实游戏房间也复用同一个元素区别只是传进去的配置不同。一个元素实例可以同时出现在多个位置但每个实例的 shadow root 都是独立的不会出现共享 DOM 状态。3.2 样式隔离与样式穿透的边界Shadow DOM 的样式隔离不是“完全隔绝”。外部普通选择器进不了 shadow root比如.list .game-card这种写法影响不到 shadow tree 内部的.board。但有两样东西是例外的CSS 自定义属性以及:host相关规则。CSS 自定义属性会从宿主向下穿透到 shadow tree所以设计主题时我建议把颜色、字体、间距都定义为自定义属性而不是直接写死。:host { --brand-color: #f60; --brand-bg: #fff8f0; }shadow tree 内部可以用var(--brand-color)读取这样既保持了隔离又留出了主题定制口子。还有::part()这个东西也可以用来精准穿透到 shadow tree 内部标注的 part比如游戏棋盘想暴露给宿主做样式覆盖可以写shadowRoot.querySelector(.board)?.setAttribute(part, board)然后宿主用popgame-chess::part(board)去覆盖。这比一刀切把样式关掉好得多。我实际项目中遇到的样式问题不是“盖不住”而是“影子里面也被外部影响”。原因是浏览器默认样式。shadow tree 内部不会继承外部文档的字体、颜色、行高等属性但表单控件、button 这类元素的默认样式依然来自浏览器不是来自你的全局 CSS。所以在游戏包的基础样式里必须显式重置这些元素不然在 Firefox 和 Chrome 里看起来会不一样。3.3 事件穿透机制和检索技巧Shadow DOM 的事件传播是另一个容易踩坑的地方。当用户点击 shadow tree 内部元素时事件会向外传播但默认情况下事件对象对宿主页面暴露的 target 是 shadow host而不是内部真实元素。这个机制叫事件重定向。听起来是细节但在做全局事件统计和点击热力图时特别致命。举个例子宿主页面想统计所有游戏卡片的点击坐标如果你监听document.addEventListener(click, ...)拿到的event.target是popgame-chess不是内部那个棋盘 div。想拿到真实点击元素必须用event.composedPath()。composedPath 返回事件传播路径上的完整节点数组包括 shadow tree 内部的元素。document.addEventListener(click, (event) { const path event.composedPath(); const inner path[0]; console.log(inner); });如果你想在游戏内部阻止事件冒泡也不是用event.stopPropagation()就万事大吉。Shadow DOM 里应该用event.stopImmediatePropagation()或者自己判断event.composedPath()的范围。我一开始没有处理这个结果全局快捷键托管在 document 上玩家在游戏内部按了空格页面也收到了空格事件导致页面滚动和游戏跳跃冲突。后来我规定游戏包内部的快捷键必须通过composed: false的自定义事件传递或者宿主监听时检查path里是否有游戏容器才算彻底解决。4. 性能优化一屏几十个游戏实例不卡的三个关键点架构换完之后最大的收益不是功能多了而是“大厅预览页”的性能稳住了。一个页面里同时出现 20 个游戏卡片每个卡片都是一个 Shadow DOM 包裹的自定义元素整体启动速度比 iframe 版本快了接近一半内存涨幅也小得多。但组件化不是银弹性能还得靠具体手段抠。4.1 减少 iframe 带来的内存和上下文压力iframe 的每个实例都有自己的 window、document、全局对象即使加载的是空白页也要付出不小的内存代价。而同一个 document 下的 Shadow DOM 自定义元素本质上只是一个普通的 DOM 树维护成本JS 运行环境还是宿主那一个。这是它在密集列表场景下压倒性的优势。但必须说清楚Shadow DOM 不解决 WebGL 上下文数量限制。浏览器对 WebGL context 数量有硬限制一般 8 到 16 个超过之后会创建失败。如果小游戏是 2D CanvasShadow DOM 方案没问题如果将来有 3D 小游戏就不能“每个实例一个 WebGL 上下文”这么玩更合理的做法是共享一个 WebGL 上下文把不同游戏的渲染结果绘制到离屏缓冲再提交到同一块 canvas。我们当时平台里的游戏都是 2D Canvas 和小体量 DOM 交互所以没有撞这个上限但你在设计架构时要提前知道。4.2 用统一的 rAF 调度和 IntersectionObserver 控制生命周期很多 Web Component 的教程都告诉你 connectedCallback 里启动游戏循环disconnectedCallback 里清理循环。但当成百个元素同时挂在页面上每个实例一个 requestAnimationFrame 循环是灾难浏览器会因为同时调度太多 rAF 产生明显的帧率不稳。我把所有游戏实例的动画循环统一交给一个全局调度器管理。调度器的逻辑并不复杂每个游戏组件把自身的 update 函数注册到调度器由调度器在每一帧里遍历执行。配合 IntersectionObserver当游戏卡片滚出视口时调度器就把对应的 update 函数移除游戏内部停止刷新滚回来时再恢复。这样页面上虽然挂着 20 个卡片实际上同一时间只有 5、6 个在跑动画。const frameScheduler { callbacks: new Set(), add(fn) { this.callbacks.add(fn); }, remove(fn) { this.callbacks.delete(fn); }, loop(ts) { this.callbacks.forEach((fn) fn(ts)); requestAnimationFrame(this.loop.bind(this)); }, }; frameScheduler.loop();有一点需要注意游戏内部如果使用了 setTimeout 或者独立 rAF调度器无法统一回收导致页面切后台后仍然有定时器在跑。所以约定所有游戏包都必须把“每帧刷新”逻辑暴露成 update 方法不能自己偷偷开 rAF。我在接入规范里明确写了这一条并且在上线前用脚本扫描游戏包里的requestAnimationFrame调用发现一个就打回一个。4.3 ResizeObserver DPR 缩放别把 resize 当重绘小游戏平台最常见的交互是玩家拖拽调整窗口大小或者把大厅预览卡片放大成完整游戏。我见过不少团队在 resize 事件里直接重建画布这非常慢。正确做法是用 ResizeObserver 观察游戏容器尺寸然后只更新 canvas 的 style 尺寸和绘图缓冲尺寸。Canvas 高分屏适配有个细节canvas.width和canvas.height代表绘图缓冲区像素数canvas.style.width和canvas.style.height代表显示尺寸。要让画面清晰绘图缓冲区应该是显示尺寸乘以 devicePixelRatio。如果只是把 style 尺寸改大而 buffer 不变画布会被拉伸字都是糊的。function resizeCanvas(canvas, containerWidth, containerHeight) { const dpr window.devicePixelRatio || 1; canvas.width Math.round(containerWidth * dpr); canvas.height Math.round(containerHeight * dpr); canvas.style.width ${containerWidth}px; canvas.style.height ${containerHeight}px; }因为 Shadow DOM 内部样式天然隔离canvas 的 CSS 不会受到外部布局影响这让 resize 的处理比 iframe 简单非常多。iframe 里改尺寸往往要等 iframe 自己的 onload跨域情况下甚至拿不到内部内容尺寸Shadow DOM 组件就在宿主 document 里ResizeObserver 可以精准观察尺寸计算不会有跨域限制。5. 常见问题与排查技巧实录最后写几个我们上线后经常遇到的问题。这些不是教科书上会写的但每个都真实到能让你加班到凌晨。5.1 ICE/连接失败排查速查表WebRTC 连接失败时最常见的表现是两个玩家一直显示“连接中”然后超时。我的排查顺序是这样的先看两端的网络环境再看信令日志最后看 ICE candidate。现象可能原因处理方式本地局域网也连不上SDP 顺序错误 / DataChannel 创建时机不对检查 createOffer 前是否已 createDataChannel一方能连另一方不能信令转发缺少 from/to检查房间映射是否串线日志里有 srflx 但没有 relay对称 NAT 或防火墙部署 TURN并确认 TURN 端口对外开放添加 ICE candidate 时报错remoteDescription 未设置确保先处理 SDP 再 addIceCandidateDataChannel onopen 很慢网络带宽拥塞 / 通道可靠性配置过高尝试降低 maxRetransmits 或改用 unordered5.2 用户搜“webrtc怎么关闭”时我在排查什么我们的平台有语音功能所以经常收到用户反馈说浏览器总提示“是否允许使用麦克风”或者担心 WebRTC 会暴露本地 IP。网上搜“webrtc怎么关闭”的人很多并不是真想永久禁掉 WebRTC而是想解决隐私或者权限弹窗问题。这里我先说结论WebRTC 的 DataChannel 本身不会调用摄像头和麦克风只有 getDisplayMedia 或 getUserMedia 才会触发设备权限。如果你的游戏平台只用 DataChannel 传状态是不需要麦克风权限的如果你加了语音聊天弹权限提示是正常行为用户拒绝后游戏仍然可以玩只是听不到语音。至于“关闭 WebRTC”这个需求我给出的建议是Firefox 用户可以在地址栏输入about:config搜索media.peerconnection.enabled把值改成false这是最简单直接的方法。Chrome 没有给普通用户提供一键开关主要靠企业策略或启动参数来禁用日常使用中很少有人会真的去关。禁用 WebRTC 之后多人游戏和视频通话功能会失效所以不建议为省一点隐私顾虑把整个能力关掉。更好的做法是在应用层明确告知用户“语音数据只用于房间内点对点传输不会上传服务器”缓解顾虑。我们后来在登录页面加了隐私说明用户咨询率明显下降。5.3 Shadow DOM 常见坑重复 attach、事件 target 不准、样式不生效自定义元素的生命周期方法很容易用错。最经典的是在 connectedCallback 里每次都执行attachShadow但一个元素只能 attach 一次 shadow root重复 attach 会直接抛异常。正确做法是先判断this.shadowRoot是否存在或者把初始化逻辑放到第一次 connectedCallback 时执行。connectedCallback() { if (!this.shadowRoot) { this.attachShadow({ mode: open }); this.render(); } }事件 target 不准的问题我在前面已经说过这里再补一个实际案例平台有个全局失败提示的控件点击遮罩层要关闭弹窗。游戏内部弹窗也在 shadow tree 里宿主监听 click 关闭时因为事件重定向它会把游戏内部弹窗当成宿主节点导致误关闭。最后我们用event.composedPath()判断点击路径里是否包含.modal-backdrop才把问题解决。样式不生效则要区分两种情况如果外部全局 CSS 想覆盖 shadow tree 内部样式必须使用自定义属性或者::part()如果游戏内部样式没有生效先检查是不是 marquee、input 这类自带默认样式的元素或者有没有在 shadow root 外创建style标签。把style放在 shadowRoot 内部和外部行为完全不同。5.4 音频流和数据流互相抢带宽平台上线语音功能后又遇到一个问题语音正常但游戏状态开始卡顿。原因是语音 track 和 DataChannel 共享同一个上行带宽但语音数据可以被压缩得更狠。我后来做了一套简单的动态码率控制检测到 DataChannel 的 bufferedAmount 持续偏高时把音频编码码率从 32kbps 降到 24kbps游戏对局结束时再恢复。这个调整在 4G 网络下尤其明显整体延迟降低了大约 30%。如果你也在做一个带语音的 WebRTC 小游戏平台建议把音频发送和游戏状态发送设计成两个可以独立调度的模块而不是混在一个对象里。不要在每次状态同步时都重新协商带宽成本太高合理的做法是每 5 到 10 秒用 RTCRtpSender 的setParameters调整一下码率或者在 sender 上设置一个初始 maxBitrate。这个参数我在浏览器里调试了很多轮每个浏览器的行为略有差异但至少比不做限制要强得多。做这个项目最大的体会是WebRTC 和 Shadow DOM 都不是什么新东西但组合在一起能解决真实的小游戏平台痛点。一个管传输一个管承载二者没有直接关系却在前端工程里形成了很自然的互补。如果你也打算做类似的架构我的建议是从最小的信令服务和单个游戏组件开始不要一开始就追求几十个实例的性能优化先跑通一局游戏再逐步把大厅、预览、语音这些功能叠上去。很多问题真的只有跑到真实网络环境里才会冒出来。
返回列表