无头环境下 setInterval 被节流:停留计时任务永远拿不到分的排查
无头环境下 setInterval 被节流:停留计时任务永远拿不到分的排查
页面可见性判定与 CDP 焦点模拟的冲突,会同时造成两个假象:计时器在跑但计数不动,以及"页面明明渲染正常却说自己是 hidden"。
症状
一个按"停留时长"给奖励的任务:打开详情页,要求保持浏览 30 秒。自动化脚本把页面打开后,每 5 秒滚动一次模拟阅读,持续 42 秒,服务端返回的进度字段始终是 0:
{"task_key":"case-view-daily","evaluator":"dwell","need_seconds":30,
"max_gap_ms":15000,"today_status":"todo","elapsed_seconds":0}
同时页面截图完全正常——导航、正文、侧栏都渲染出来,说明不是白屏也不是加载失败。
第一个判据:影子 DOM 之外,先看 visibilityState
Runtime.evaluate: JSON.stringify({vis:document.visibilityState, hidden:document.hidden})
-> {"vis":"hidden","hidden":true}
document.hasFocus() 却返回 true。这两个字段的含义不同:hasFocus() 只说当前文档在所属窗口内是活动文档,visibilityState 才反映窗口有没有真正呈现给用户。
第二个判据:计时器是否被节流
页面已渲染、hidden=true,接下来要确认 JS 定时器有没有被降频。在页面上下文里跑一个 100ms 的 setInterval,做 1 秒,数实际拍数:
new Promise(res => {
let n = 0;
const iv = setInterval(() => { n++; }, 100);
setTimeout(() => { clearInterval(iv); res(n); }, 1000);
})
正常应该接近 10。实测返回 2。10 倍节流——Chromium 对隐藏页面会把 setInterval 压到最低每秒 1 次(后台再久会进一步降到每分钟 1 次)。
这条把"服务端不认"和"前端没计时"彻底分开了:不是接口拒收,是计时根本走不动。
为什么窗口"看起来没最小化"却还是隐藏
查 Win32 层窗口状态:
IsWindowVisible(hwnd) -> 1
IsIconic(hwnd) -> 0
GetWindowRect(hwnd) -> (0, 0, 1280, 860)
窗口可见、未最小化、坐标也在屏幕内。但 SetForegroundWindow 返回 0,GetForegroundWindow() 指向的是别的窗口,标题只有 11。
结论:会话没有可交互的桌面。远程/服务化的 Windows 会话里,进程能在无头环境下跑 GUI,窗口在 Win32 意义上存在,但合成器不把它当作"正在呈现",Chromium 据此判定 hidden。Browser.setWindowBounds 改成 normal、Page.bringToFront、调整窗口坐标,全部无效——它们都在改 Win32 属性,改不动合成器的呈现判定。
解法:用 CDP 的焦点模拟绕过呈现判定
Emulation.setFocusEmulationEnabled { enabled: true }
这一条是唯一生效的。执行后立刻:
{"vis":"visible","hidden":false}
注意它模拟的是 focus,副作用是 visibilityState 一并变成 visible,渲染管线也照常出画面(截图正常)。
两个配套细节:
- 导航后要重新打开。
Page.navigate会重置部分 Emulation 状态,必须在新文档加载完再调一次,否则计时器又回到 hidden。 - 轮询里顺带保持。计时任务通常每 5 秒往服务端汇报一次累计时长,在每轮轮询前重发一次
setFocusEmulationEnabled成本可以忽略,能避免页面因长时间无交互被重新降级。
计时器的真实实现决定能不能救
拿到站点混淆后的前端代码,找计时逻辑:
function M(){
if (P || !S || !i.value) return; // 未开始 / 已完成 / 未运行
if (document.hidden || A) return; // <- 隐藏就直接放弃本轮汇报
A = true;
const E = await Yt(S); // 按 session_id 汇报
v.value = E.accumulated_ms; // 服务端返回累计毫秒
if (E.completed) { /* 发奖 */ }
A = false;
}
function $(){ i.value = true; q = window.setInterval(M, 5000); M(); }
document.addEventListener('visibilitychange', function(){ !document.hidden && i.value && M(); });
三个可直接用于判断的设计点:
- 累计时长由服务端持有,前端只负责按
session_id定期"打卡"。所以改客户端变量没有意义,必须真的把请求发出去。 - 汇报间隔是 5 秒,而任务要求
max_gap_ms是 15000。打卡间隔被节流到 1 秒 1 次还是 5 秒 1 次无所谓,但一旦页面隐藏、定时器被压到每分钟 1 次,间隔立刻超过 15 秒,累计时长会被服务端重置。这才是"滚了 42 秒还是 0"的完整因果链。 document.addEventListener('visibilitychange', ...)里那句!document.hidden && M()说明站点自己也意识到隐藏期间不该计时——它是设计意图,不是 bug。
修复后同一页面、同样流程:
+5s {"el":0,"st":"todo","vis":"visible"}
+31s {"el":0,"st":"done","vis":"visible"}
31 秒时 today_status 变为 done,completed_at 有真实时间戳。
排查顺序小结
- 截图正常 + 服务端进度为 0 → 先怀疑前端计时,不要先怀疑接口。
- 读
document.visibilityState,不要读hasFocus()。 - 用一个 100ms 定时器数 1 秒内的拍数,确认节流倍数。
- 窗口属性(
IsWindowVisible/IsIconic/GetWindowRect)全部正常但SetForegroundWindow返回 0 → 无交互桌面,改窗口属性无用。 - 用
Emulation.setFocusEmulationEnabled(true),并在每次导航后重开。 - 找到计时器源码确认汇报间隔与
max_gap_ms的关系——如果汇报间隔大于服务端容忍间隔,节流就等于归零,不是"慢一点"。
- 点赞
- 收藏
- 关注作者
评论(0)