现代浏览器为节省系统资源,对后台标签页实施了一套从轻微降频到彻底冻结的多层节流体系。这套机制在有效降低 CPU 和内存占用的同时,也会打破前端代码对"时间连续性"的隐含假设,引发 deltaTime 暴涨、动画瞬移、状态失同步等一系列问题。
一、问题本质:时间连续性被打破
在浏览器动画循环中,deltaTime(帧间隔时间)通常通过相邻两帧的时间戳差值计算:
ini
let lastTime = performance.now();
function loop(timestamp) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
requestAnimationFrame(loop);
}
performance.now() 底层依赖操作系统的单调时钟(monotonic clock) ------它永远不会回退、不受系统时间调整影响,且在进程挂起期间持续递增。这意味着,任何导致下一帧回调延迟执行的因素,都会被忠实记录为 deltaTime 的增大。
以下三类场景都会导致 deltaTime 变大,但其底层成因各不相同。
1.1 掉帧(Frame Drop)
浏览器的渲染主线程是一个单线程事件循环,requestAnimationFrame 回调被插入到 Style/Layout/Paint 之前执行,并由显示器的 VSync 信号驱动。如果上一帧的 JS 执行 + 渲染管线总耗时超过了 VSync 间隔(如 60Hz 下为 16.67ms),浏览器就会错过当前的 VSync 信号,必须等待下一个 VSync 才能开始新帧。
deltaTime 表现为离散的尖峰------从正常的 16ms 跳到 33ms、50ms 甚至更高。其本质是渲染管线的同步屏障迫使下一帧的对齐点向后推移了整整一个或多个 VSync 周期。 要理解掉帧现象,首先需要了解浏览器的"16.67ms 黄金预算"。现代显示器刷新率通常为 60Hz,这意味着浏览器必须在 16.67ms(1秒/60)内完成一帧画面的所有工作(包括执行 JS、计算样式、布局、绘制和合成)。一旦总耗时超过这个预算,就会发生"掉帧",导致用户感知到卡顿。
掉帧通常由以下三大类原因引起:
- JavaScript 执行过长: 主线程被复杂的计算、巨大的
for循环、大 JSON 解析或紧递归等 CPU 密集型任务占满。浏览器采用单主线程模型,所有的 DOM 操作、事件处理和脚本执行都在这个线程上运行。当一个 JavaScript 函数执行时间过长(例如超过 50ms 的长任务),主线程就会被完全堵死。在此期间,浏览器无法处理用户输入、无法推进定时器,渲染流水线也必须停下来等待该 JS 任务执行完毕,这就导致了严重的掉帧。 - 布局与重绘开销过大: 频繁修改 DOM 或 CSS 样式,引发大规模的"重排(Reflow)"和"重绘(Repaint)"。
- 硬件与系统瓶颈: 例如未开启 GPU 硬件加速导致 CPU 承担过多图形渲染任务、浏览器扩展占用过多资源、或设备散热不良导致 CPU/GPU 降频。
另外掉帧也会导致渲染管线耗时加长:
- 强制同步布局(布局抖动): 正常情况下,浏览器会智能批处理 DOM 修改。但如果你在修改 DOM 后立即读取几何属性(如
offsetHeight、getBoundingClientRect),浏览器为了返回准确值,会被迫中断异步流程,立即执行"布局"计算。如果在循环中交替进行读写,就会引发灾难性的"布局抖动(Layout Thrashing)",导致渲染管线耗时急剧增加。 - 复杂的绘制与合成: 如果页面存在过多的合成图层(如超过 1000 层),或者使用了极其复杂的 CSS 效果(如 3D 变换、视差、模糊),渲染管线中的"绘制(Paint)"和"合成(Composite)"阶段耗时就会大幅增加,甚至超过 16.67ms 的预算。
1.2 后台标签页休眠
当标签页进入后台,浏览器会主动降频甚至暂停 requestAnimationFrame 的调度。用户切回时,第一帧的 deltaTime 可能达到数秒甚至数分钟。这不是帧率下降,而是时间连续性被打破了------JS 执行环境的时钟读数与物理时间之间出现了"断层"。
1.3 GC 卡顿(Garbage Collection Pause)
JavaScript 引擎执行 Stop-The-World GC 时,所有 JS 执行线程必须在到达 Safe Point 时停下来。主线程完全无法执行任何代码,也无法处理 rAF 回调。deltaTime 表现为随机不规则的尖峰,暂停时长从几毫秒到数百毫秒不等。
三者可以统一到同一个模型中:
Δtobserved=Δtvsync+Tblock+Tsuspend+Tgc
| 分量 | 含义 | 对应场景 |
|---|---|---|
| Δtvsync | 正常 VSync 间隔(~16.67ms) | 基线 |
| Tblock | 主线程被阻塞,错过 VSync 的额外等待 | 掉帧 |
| Tsuspend | 进程/线程被挂起的真实时间 | 后台标签页 |
| Tgc | STW 暂停消耗的真实时间 | GC 卡顿 |
二、浏览器休眠节流的实现机制
浏览器对后台标签页的节流不是单一开关,而是一套分层递进的资源管控体系。从"轻微降频"到"彻底冻结"再到"直接卸载",每一层对应不同的触发条件和实现机制。
2.1 整体架构
javascript
页面进入后台
│
├─ 第1层:Timer 对齐节流(10秒后触发)
│ setTimeout/setInterval 最小间隔 → 1000ms
│
├─ 第2层:预算制节流(与第1层同期生效)
│ 每个后台页面分配 CPU 时间预算,用完即停
│
├─ 第3层:Intensive Wake Up Throttling(5分钟后触发)
│ JS 定时器唤醒频率 → 1次/分钟
│
├─ 第4层:Tab Freezing / 页面冻结(节能模式或内存压力时触发)
│ 暂停整个页面的 JS 执行、事件处理、Promise 解析
│
└─ 第5层:Tab Discarding / 标签页丢弃(内存极度紧张时触发)
完全卸载页面,释放所有内存,仅保留标签壳
2.2 第1层:Timer 对齐节流
触发条件: 页面变为不可见后 10 秒。
Chromium 的 Blink 调度器(MainThreadScheduler / PageSchedulerImpl)内部维护了一套 TaskQueue 优先级管理体系。每个页面对应一个 PageSchedulerImpl,每个局部帧对应一个 FrameSchedulerImpl。当页面进入后台时,调度器执行两个关键操作:
- 切换 TimeDomain: 将该页面所有
throttleable类型的 TaskQueue 的时间域从RealTimeDomain切换为ThrottledTimeDomain,将定时器的最小间隔强制对齐到 1000ms。 - 插入 Fence(栅栏): 在 TaskQueue 中插入一个 fence,阻塞所有 throttleable 任务的执行。fence 的位置由 BudgetPool(预算池)控制------只有当预算池判断有可用资源时,fence 才会被向后推移,允许一小批任务通过。
ini
TaskQueue: [Task1] [Task2] [Task3] |FENCE| [Task4] [Task5]
↑
预算池控制此位置
每次只放行一小批
底层代码路径如下:
rust
PageSchedulerImpl::SetPageVisible(false)
→ FrameSchedulerImpl::SetPageVisible(false)
→ TaskQueueThrottler::OnPageBackgrounded()
→ 对每个 throttleable TaskQueue:
1. task_queue->SetTimeDomain(&throttled_time_domain_)
2. budget_pool->AddQueue(task_queue)
3. task_queue->InsertFence(TaskQueue::Fence())
豁免条件: 页面正在播放音频(AudioContext 处于 running 状态)、持有活跃的 WebSocket/WebRTC 连接、正在使用 getDisplayMedia/getUserMedia、DevTools 正在调试等。
2.3 第2层:预算制节流
触发条件: 与第1层同时生效,页面进入后台 10 秒后开始。
这是 Chrome 57 引入的机制。每个后台页面被分配一个时间预算池(以秒为单位),定时器任务只有在预算非负时才能执行:
- 消耗: 每次定时器任务执行后,其运行时间从预算中扣除。
- 再生: 预算以 0.01 秒/秒的速率持续恢复(即每秒只能赚回 10ms 的执行时间)。
- 综合效果: 将后台标签页的 CPU 占用限制在约 1%。
makefile
预算变化示意:
初始预算: ████████████████████ 1.0s
执行任务: ██████████████░░░░░░ 0.7s (消耗0.3s)
1秒后: ████████████████░░░░ 0.71s (恢复0.01s)
持续执行: ████████░░░░░░░░░░░░ 0.4s ...
预算耗尽: ░░░░░░░░░░░░░░░░░░░░ 0.0s → 所有定时器被阻塞
如果一个后台页面持续运行大量定时器任务,预算会迅速耗尽,之后必须等待很长时间才能攒够执行下一次任务的预算。这就是为什么后台页面的 setInterval(fn, 100) 实际执行间隔远大于 100ms 的根本原因。
2.4 第3层:Intensive Wake Up Throttling
触发条件: 页面在后台持续隐藏 5 分钟后触发(Chrome 86+ 默认启用)。
这是在前两层之上的进一步压制。即使预算池恢复了少量预算,定时器唤醒频率也被硬性限制为每分钟最多 1 次。底层实现是在 TaskQueueThrottler 中增加了一个 wake-up 对齐窗口------所有到期的定时器不会在到期时立即执行,而是被延迟到下一个对齐窗口批量执行。
makefile
时间轴: ──|──────|──────|──────|──────|──────|──
↑ ↑ ↑ ↑
对齐点 对齐点 对齐点 对齐点
(每分钟一次,所有定时器被合并到这些时间点执行)
Google 的测试数据显示,这一机制可以减少 Chrome CPU 使用率最高 5 倍,在 36 个标签页场景下延长电池寿命约 28%(约 2 小时)。
Chrome 还在测试快速版本(Quick Intensive Throttling),将 5 分钟的宽限期缩短到 10 秒(仅针对在隐藏状态下完成加载的页面),进一步加速节能。
2.5 第4层:Tab Freezing / 页面冻结
触发条件: 节能模式(Energy Saver)激活时,对隐藏且静音超过 5 分钟的 CPU 密集型后台标签页执行冻结(Chrome 133+);系统内存紧张时浏览器主动冻结;Edge 的睡眠标签页默认 2 小时不活动后冻结(可配置为 30 秒 ~ 12 小时)。
冻结是比节流更激进的手段------不是"限制执行频率",而是完全暂停页面的任务执行:
- 暂停事件循环: 渲染进程中的主线程事件循环被停止,不再从 TaskQueue 中取出任务。
- 暂停所有子系统: JavaScript 定时器、
requestAnimationFrame回调、Promise 解析器、事件处理器全部冻结。 - 保留状态: 与 Tab Discarding 不同,冻结不释放内存。DOM 树、JS 堆、CSSOM 全部保留。
- dispatch
freeze事件: 页面冻结前会触发 Page Lifecycle API 的freeze事件,允许页面做清理工作。
ini
冻结前: [Event Loop Running] → [Task Queue Active] → [JS Heap Live]
冻结后: [Event Loop PAUSED ] → [Task Queue FROZEN] → [JS Heap Preserved]
解冻后: [Event Loop Running] → [Task Queue Active] → [JS Heap Live]
↑ 从断点继续,不丢失状态
豁免条件: 页面正在播放音频/视频、正在进行音视频会议(检测到麦克风/摄像头/screen capture/RTCPeerConnection)、使用 WebUSB、属于公司内网站点、DevTools 正在调试、用户手动加入白名单。
解冻流程: 用户切回冻结的标签页时,OS 恢复进程调度 → 浏览器 dispatch resume 事件 → 事件循环重新启动 → 冻结期间积压的任务被批量执行 → requestAnimationFrame 恢复,首帧 deltaTime 包含整个冻结时长。
2.6 第5层:Tab Discarding / 标签页丢弃
触发条件: 系统内存极度紧张,冻结仍不足以释放足够内存时。
这是最极端的手段------完全卸载页面:
- 释放所有资源: DOM 树、JS 堆、CSSOM、Canvas 缓冲区、WebGL 上下文全部销毁。
- 保留标签壳: 标签页的标题、URL、favicon 保留在浏览器 UI 中。
- 用户切回时: 等同于全新导航,重新发起网络请求、重新解析 HTML、重新执行 JS。
less
丢弃前: [Renderer Process] → [DOM] [JS Heap] [GPU Memory] [Network Connections]
丢弃后: [Tab Shell Only ] → title: "Example" url: "https://example.com"
切回时: [Full Reload ] → 重新走完整的页面加载流程
2.7 OS 层协同:进程优先级下调
浏览器不仅在应用层节流,还会请求操作系统配合降低后台进程的调度优先级:
| 操作系统 | 机制 | 效果 |
|---|---|---|
| Windows | SetProcessInformation(PROCESS_POWER_THROTTLING) + EcoQoS |
将后台渲染进程调度到能效核心(E-core),降低 CPU 频率 |
| macOS | setpriority() + QoS class 降为 UTILITY |
系统调度器减少该进程的时间片分配 |
| Linux | nice() / cgroups |
降低进程优先级,限制 CPU 配额 |
这意味着即使浏览器内部的节流机制被绕过,OS 层也不会给后台渲染进程足够的 CPU 时间来维持流畅执行。
2.8 分层节流全景
arduino
节流强度
↑
Tab Discarding ────┤ ████████████████████████ 完全卸载,不可恢复
Tab Freezing ──────┤ ████████████████████ 完全暂停,保留状态
Intensive Throttle ┤ ████████████████ 1次/分钟
Budget Throttle ───┤ ████████████ 预算耗尽即停
Timer Alignment ───┤ ████████ ≥1000ms 间隔
正常前台执行 ──────┤ ████ 无限制
└──────────────────────────→ 后台持续时间
0s 10s 5min 更长/内存压力
整个体系的设计哲学是渐进式降级:先尝试温和限制(对齐定时器),再尝试中等限制(预算管控),再尝试激进限制(每分钟一次),最后才动用冻结和丢弃。每一层都尽可能给页面留出"优雅降级"的空间,同时在最底层保留"核选项"来保护系统稳定性。
三、节流导致的渲染问题
浏览器休眠节流导致的渲染问题,本质上不是"渲染出了错误的画面",而是渲染管线的时间基准与物理世界脱节。
3.1 动画/物理"瞬移"与数值爆炸
现象: 切回标签页的瞬间,游戏角色瞬移到屏幕外、物体穿模、弹簧拉伸到无限长;基于时间的插值动画直接跳到终点;粒子系统一次性喷射出几千个粒子。
底层原理: 后台挂起期间 performance.now() 持续递增,但逻辑更新完全停止。当页面恢复时,第一帧的 deltaTime 可能是数秒甚至数分钟。如果物理/动画代码直接使用原始 deltaTime:
scss
// ❌ 危险代码
position += velocity * deltaTime; // deltaTime = 300,000ms → 瞬移
springForce = -k * (x - rest); // x 已经严重偏离 → 力巨大 → 下一帧更离谱
- 欧拉积分的不稳定性: 大多数实时物理使用半隐式欧拉或显式欧拉积分。这些方法有稳定性上限(通常要求 dt < 某个阈值)。当 dt 远超该阈值时,积分器从"近似模拟"变成"指数发散",数值在几帧内趋向无穷大。
- 碰撞检测穿透: 离散碰撞检测假设物体在两帧之间移动距离小于自身尺寸。巨大的 dt 使物体一步跨越整个场景,所有碰撞体都被跳过(Tunneling Effect)。
- 累加器溢出: 粒子发射器、定时器等使用
accumulator += deltaTime的模式,会在恢复瞬间触发成百上千次本应分散执行的逻辑。
3.2 状态不一致与视觉撕裂
现象: UI 显示"加载中"但内容已就绪;多个动画/子系统之间失去同步;CSS 过渡/动画卡在中间状态或突然跳变;Canvas/WebGL 画面闪烁、残影。
底层原理: 浏览器中存在多个独立的时间源,节流对它们的影响不同:
| 时间源 | 后台行为 | 恢复后状态 |
|---|---|---|
performance.now() |
✅ 持续递增(单调时钟) | 包含完整挂起时长 |
requestAnimationFrame timestamp |
⏸️ 暂停/极度降频 | 首帧跳跃 |
| CSS Animation/Transition | ⏸️ 暂停(timeline frozen) | 从暂停点继续,不补偿丢失时间 |
Web Audio currentTime |
✅ 持续递增(若 context running) | 与主线程时间产生偏移 |
Video currentTime |
⏸️ 暂停 | 不自动追赶 |
| JS 变量中的自定义时间戳 | ⏸️ 随 JS 执行一起冻结 | 停留在挂起前的值 |
核心矛盾在于:恢复后,performance.now() 认为过了 5 分钟,CSS 动画认为过了 0 秒,AudioContext 认为过了 5 分钟,而代码里的 lastFrameTime 还停在 5 分钟前。这些时钟再也无法自然对齐。
CSS 动画在后台被冻结,恢复后从断点继续;而 JS 用 performance.now() 计算的动画会瞬间跳到"当前应有的位置"。两者驱动的同一元素会出现明显错位。WebGL 渲染也可能不完整------GPU 命令队列在后台可能被清空或重置,恢复后首帧如果只提交了部分 draw call,就会出现半渲染画面。
3.3 资源加载与异步操作的"时间压缩"
现象: 切回后大量网络请求同时发出,导致瞬时带宽/CPU 尖峰;多个 Promise 在同一微任务批次中 resolve,触发连锁反应;IntersectionObserver/ResizeObserver 批量触发回调。
底层原理: 后台标签页不仅节流 rAF,还会延迟任务调度------setTimeout(fn, 100) 在后台可能被推迟到 ≥1000ms 才执行,且多个到期的定时器被合并到同一个事件循环 tick 中。部分浏览器限制后台标签页的并发连接数,请求被缓存在网络栈中,恢复时批量释放。观察者 API 在后台积累的变更通知也会在恢复时被打包成一个数组一次性回调。
这导致恢复瞬间出现一个人为制造的"负载尖峰",可能触发 GC、掉帧、甚至 OOM,形成恶性循环。
四、工程应对方案
4.1 防御性编程:基础防护
无论采用何种保活策略,以下措施都是必须的:
scss
// 1. 监听可见性变化,主动暂停/恢复逻辑
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
pauseSimulation();
} else {
resetDeltaTime(); // 关键:切回时重置时间基准
resumeSimulation();
}
});
// 2. 永远钳制 deltaTime
const MAX_DELTA = 100; // 最大允许 100ms
const safeDelta = Math.min(deltaTime, MAX_DELTA);
// 3. 使用固定时间步长(Fixed Timestep)
let accumulator = 0;
const FIXED_DT = 16; // 16ms 固定步长
function gameLoop(deltaTime) {
accumulator += Math.min(deltaTime, MAX_DELTA);
while (accumulator >= FIXED_DT) {
updatePhysics(FIXED_DT);
accumulator -= FIXED_DT;
}
render(accumulator / FIXED_DT); // 插值因子
requestAnimationFrame(gameLoop);
}
4.2 Wake Lock API:阻止系统级休眠
javascript
let wakeLock = null;
async function requestWakeLock() {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock released');
});
} catch (e) {
console.error('Wake Lock failed:', e);
}
}
// 必须由用户手势触发
document.getElementById('startBtn').addEventListener('click', requestWakeLock);
Wake Lock 能防止屏幕熄灭和系统休眠,在部分浏览器中也能减轻后台标签页的激进程度。但它不能保证 rAF 不被节流。
4.3 AudioWorklet:后台精确计时与逻辑执行
为什么有效
浏览器节流后台标签页的本质是"降低渲染优先级以节省 CPU/GPU"。但音频管线属于实时系统:
- 独立线程: AudioWorklet 运行在专用的 Audio Rendering Thread 上,与主线程完全隔离。主线程被挂起/节流不影响音频线程的调度。
- 硬件时钟驱动: 音频回调由声卡/DAC 的硬件中断驱动(通常每 128 samples @ 48kHz ≈ 2.67ms 触发一次),而非 VSync 或 JS 定时器。只要音频上下文处于
running状态,OS 就必须保证这个线程的时间片。 - 用户体验豁免: 浏览器厂商认为"后台音频突然中断/爆音"比"后台动画掉帧"的体验损害大得多,因此将活跃音频上下文列为高优先级豁免对象。
Worklet 处理器(timer-worklet.js)
kotlin
class TimerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.sampleCount = 0;
this.reportInterval = sampleRate; // 每 ~1秒 报告一次
}
process(inputs, outputs, parameters) {
this.sampleCount += inputs[0]?.[0]?.length || 128;
if (this.sampleCount >= this.reportInterval) {
this.sampleCount = 0;
// currentTime 是音频上下文的精确时间,不受主线程节流影响
this.port.postMessage({
type: 'tick',
audioTime: currentTime
});
}
return true; // 返回 true 保持节点存活
}
}
registerProcessor('timer-worklet', TimerWorklet);
主线程使用
ini
const ctx = new AudioContext();
await ctx.audioWorklet.addModule('timer-worklet.js');
const timerNode = new AudioWorkletNode(ctx, 'timer-worklet');
timerNode.port.onmessage = (e) => {
if (e.data.type === 'tick') {
// 即使标签页在后台,这个回调依然以 ~1s 间隔稳定触发
updateGameState(e.data.audioTime);
}
};
timerNode.connect(ctx.destination); // 必须连接到 destination
// 必须由用户手势触发
document.getElementById('startBtn').addEventListener('click', () => {
ctx.resume();
});
注意事项
- 不能访问 DOM/BOM: Worklet 内没有
window、document、setTimeout、fetch,只能做纯计算和消息传递。 - 不能分配内存:
process()中严禁创建对象/数组,否则会在音频线程触发 GC 导致爆音。使用预分配的环形缓冲区。 - connect(destination) 是必须的: 未连接到 destination 的节点可能被浏览器优化掉。即使不输出声音,也要连接。
4.4 OscillatorNode:轻量级保活
如果不需要在后台执行逻辑,只是想让 AudioContext 保持 running 状态从而获得豁免权:
ini
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0; // 完全静音
osc.connect(gain);
gain.connect(ctx.destination); // 必须连接到 destination
osc.start();
4.5 两种音频方案对比
| 特性 | OscillatorNode 保活 | AudioWorklet |
|---|---|---|
| 后台主线程 rAF 稳定性 | 改善但不保证 | 同样不保证 rAF 本身 |
| 后台精确计时能力 | ❌ 无 | ✅ 通过 port 消息实现 |
| 后台逻辑执行 | ❌ 无 | ✅ 在音频线程执行 |
| CPU 开销 | 极低 | 较低(因浏览器和硬件而异) |
| 复杂度 | 3 行代码 | 需要独立文件 + 消息协议 |
| 适用场景 | 防止 context 被 suspend | 后台游戏 tick、音乐同步、精确计时 |
4.6 重要澄清
AudioWorklet/OscillatorNode 并不能让后台标签页的 requestAnimationFrame 恢复 60FPS。它们保住的是:
- AudioContext 的
running状态不被浏览器自动 suspend - 音频渲染线程持续获得 CPU 时间片
- 通过 AudioWorklet 的
port.postMessage,获得一个替代 rAF 的后台定时信号源
正确的后台架构是:
scss
[AudioWorklet] ──postMessage──→ [主线程] ──→ 更新物理状态
↑ ↓
音频线程 存储状态
(不被节流) (等切回前台再渲染)
4.7 已失效的方案
| 做法 | 状态 | 原因 |
|---|---|---|
播放静音 <video> / <audio> |
❌ 已失效 | Chrome 90+ 已修复此漏洞,不再因媒体元素豁免后台节流 |
| 持续调用 ServiceWorker | ❌ 无效 | SW 有自己的生命周期,不影响主线程 rAF 调度 |
| WebSocket 保持连接 | ❌ 无效 | 网络层与渲染调度完全解耦 |
window.focus() 轮询 |
❌ 无效且有害 | 后台标签页无法获取焦点 |
4.8 浏览器配置层面(仅限开发/特定场景)
以下方法不适合生产环境,但在开发调试或企业内部应用中可用:
Chrome Flags:
chrome://flags/#calculate-native-win-occlusion→ Disabledchrome://flags/#intensive-wake-up-throttling→ Disabledchrome://flags/#tab-freezing→ Disabled
企业策略 / MDM:
json
{
"IntensiveWakeUpThrottlingEnabled": false,
"TabFreezingEnabled": false
}
适用于 Kiosk 模式、数字标牌、内部监控大屏等场景。
4.9 防御措施速查表
| 问题类别 | 防御措施 | 原理对应 |
|---|---|---|
| 瞬移/爆炸 | Math.min(dt, MAX_DT) + 固定时间步长 |
保证积分器稳定性 |
| 瞬移/爆炸 | visibilitychange 时重置所有时间基准 |
消除挂起期间的时间差 |
| 状态不一致 | 统一时间源,避免混用 CSS 动画与 JS 时间驱动 | 消除多时钟域失同步 |
| 状态不一致 | 恢复时主动 re-sync 所有子系统状态 | 手动重建一致性 |
| 资源压缩 | 恢复时分批/延迟重启异步操作 | 削峰,避免二次卡顿 |
| 资源压缩 | 使用 AbortController 取消后台过期请求 |
减少无效工作 |
五、选型决策树
你的需求是什么?
│
├─ 只是防止动画/物理爆炸 ──→ clamp deltaTime + visibilitychange 重置
│
├─ 需要后台保持精确计时 ──→ AudioWorklet / Web Worker
│
├─ 需要防止系统休眠 ──────→ Wake Lock API
│
├─ 内部应用/Kiosk ────────→ Chrome Flags / 企业策略
│
└─ 想在普通网页中完全禁用后台节流 ──→ ❌ 不可能,也不应该
六、总结
浏览器休眠节流导致的渲染问题,根因是时间连续性被打破。浏览器的节能策略在不同子系统间制造了不对称的时间流逝,而绝大多数前端代码隐含假设"时间是均匀、连续、各时钟同步的"。一旦这个假设被打破,所有依赖时间的系统都会产生未定义行为。
修复思路永远是:承认时间会断裂,在断裂点主动重建时间基准和系统状态,而不是期望浏览器替你保持一切正常。不要试图对抗浏览器的节能策略,而是适配它------把"后台不需要渲染"作为设计前提,用正确的 API 把需要后台运行的逻辑放到不受节流的上下文中去。
要不要我把 Page Lifecycle API 的 freeze/resume 事件在浏览器内部的触发和处理流程也整理进来?正好和 Tab Freezing 的实现直接对应。