當心 setInterval 因瀏覽器節流模式嚴重失準
| | 6 | | ![]() |
同事分享踩到的地雷一枚 - 某個用 setInterval 寫的閒置倒數功能(參考:ASP.NET 小技巧 - 防止 Session 逾時與網頁閒置偵測),被發現超過時限很久還沒啟動。
經過調查,問題發生在 Chrome 瀏覽器,操作使用者曾長期切到別的頁籤看其他網頁,並未一直停在網頁。後來查到,今年初(2021 年 1 月) Chrome 88 版加入的新功能,可能會讓 setInterval()/setTimeout() 不依預期時間間隔執行,導致結果嚴重失準。
依據 Chrome 開發人員 Jake Archibald 這篇 Heavy throttling of chained JS timers beginning in Chrome 88 的說明,當瀏覽器切換成其他網頁頁籤、視窗最小化,或被其他前景程式覆蓋時,網頁會被視為隱藏(Hidden,可透過 visibilitychange 事件偵測),此時瀏覽器基於減少 CPU 耗能及延長電池使用時間的考量,會降低 setInterval 或串連 setTimeout 組成定期呼叫等 Chained Timer 的執行頻率,稱之為 Chained Timers Throttling。
Throttling 分為三級:Minimal Throttling、Throttling、Intensive Throttling。前兩級做法過去就有,且如果你的間隔大於一秒,影響不大,Chrome 88 新加的 Intensive Throttling,一口氣將執行間隔拉長到一分鐘,原本一秒執行一次、5 秒一次、 30 秒一次的 setInterval 通通減量為一分鐘只跑一次,若是用 setInterval 計秒,一分鐘原本算 60 秒,Intensive Throttling 時一分鐘等於一秒。
這邊簡單整理三種 Throttling 的啟用時機。(文章的英文寫法有點拗口,我試著用改寫成較直述化,希望沒有曲解原意,如有疑義請參考原文)
針對間隔小於 4ms 且連續觸發次數大於 5 的 Timer,即使網頁非隱藏中(或網頁隱藏但過去 30 秒內有播放聲音),Chrome 將啟動 Minimal Throttling,將 Timer 間隔放大到 4ms。
當網頁處於隱藏狀態,網頁會進入 Throttling 或 Intensive Throttling 模式。
當網頁隱藏時間不足 5 分鐘、Timer 連續觸發次數小於 5,或有啟用 WebRTC (尤其是開啟 RTCDataChannel 或 MediaStreamTrack),Chrome 會先進入 Throttling 模式,每秒一次將到期的 Timer 集中起來一次執行,換言之,Timer 的精準度將下降到 1 秒。這並非 Chrome 的新設計,在許多瀏覽器上已行之有年,不過,若 setInterval 時間間隔大於 1 秒,原則上影響不大。
當以下條件都被滿足,Chrome 88 起增加了一個更猛的 Intensive Throttling 模式:
- 網頁隱藏超過五分鐘
- Timer 連續執行次數超過 5 次
- 過去 30 秒網頁未播放任何聲音
- 未使用 WebRTC
瀏覽器將改為每分鐘一次,蒐集到期的 Timer 一次執行。也就是,原本一分執行 300 次、60 次、10 次、5 次的 setInterval,通通會變成一分鐘只跑一次,最後可能產生非預期的結果。
這個設計是為了避免 Timer 消耗無謂的 CPU 跟電力,如果有類似需求,依應用情境官方建議採用更有效率的替代方案:
- State Polling
定期檢查偵測元素變化是最常被誤用的 Timer 用途,其實有 API 能更有效率搞定,例如:IntersectionObserver 可偵測元素是否進入 ViewPort、ResizeObserver 能感應元素尺寸變化、MutationObserver 或自訂元素生命週期 Callback 可測偵 DOM 改變、WebSocket/Server-Sent Events/Push Messages/Fetch Stream 可以即時接收 Server 通知。 - 動畫
網頁隱藏期間實在沒理由浪費資源處理動畫,requsetAnimationFrame API 可依裝置畫面刷新頻率決定執行時機,確保每個 Frame 只跑一次,並會在畫面隱藏時暫停執行。另外,改用 CSS 或 Web Anmiation API 做動畫,也是很好的選擇。
至於像最前面說的計時案例,改用 new Date() 取電腦時間也能輕鬆解決。
講了半天,沒實際驗證過心理不踏實,於是我設計了下面的實驗測試 Chrome 的 Throttling 及 Intensive Throttling 行為:
<!DOCTYPE html>
<html>
<head></head>
<body>
<div>
Expected Counter = <span id=ec></span>
</div>
<div>
Actual Counter = <span id=ac></span>
</div>
<div>
Estimated Diff = <span id=ed></span>
</div>
<div>
Actual Diff = <span id=ad></span>
</div>
<ul id=logs>
</ul>
<script>
var hiddenFrom;
var estDiff = 0;
var logs = document.getElementById('logs');
document.addEventListener("visibilitychange", function (e) {
if (document.visibilityState === 'visible') {
if (hiddenFrom) {
hiddenSecs = (new Date().getTime() - hiddenFrom.getTime()) / 1000;
logs.innerHTML += '<li>Hidden @' + hiddenFrom.toISOString().substr(14,5) + ' ' + hiddenSecs + 's</li>'
if (hiddenSecs < 300)
estDiff += Math.round(hiddenSecs);
else
{
estDiff += 300;
hiddenSecs -= 300;
estDiff += Math.floor(hiddenSecs / 60) * 119 + hiddenSecs % 60 * 2;
}
}
}
else {
hiddenFrom = new Date();
}
});
var count = 0;
var st = new Date().getTime();
var ec = document.getElementById('ec');
var ac = document.getElementById('ac');
var ad = document.getElementById('ad');
var ed = document.getElementById('ed');
setInterval(function () {
count++;
var expCnt = (new Date().getTime() - st) / 500;
ec.innerText = expCnt.toFixed();
ac.innerText = count.toFixed();
ad.innerText = (expCnt - count).toFixed();
ed.innerText = estDiff;
}, 500);
</script>
</body>
</html>
我設了一個每 0.5 秒跑一次的 setInterval,用 visibilitychange 事件捕捉網頁被隱藏的時機與時間長短。正常情況每分鐘 counter 應該增加 120,當隱藏時若不足五分鐘為 Throttling 模式,由每秒兩次降為每秒一次,超過五分鐘時進入 Intensive Throttling 模式,由每分鐘 120 次降為每分鐘一次,依據這個理論,我們可以由隱藏時間長短推算短少的次數,並與實際值做比較,每次隱藏可能有 0 ~ 1 次的誤差值但無妨。
隱藏 9 秒,預估少 9 次,實際少 8 次:
隱藏 1178.845s,預估少 2044 次,實際少 2042 次:
又學到一項知識。
Study of the intensive throttling behavior since Chrome 88.
Comments
# by Scott
可以試試 web worker
# by 布丁布丁吃布丁
實用。 所以「閒置倒數功能」功能必須考慮兩種情況: 1. 使用者正在看這個網頁的情況 2. 使用者離開這個網頁的情況 1的情況,可以mousemove和keypress來呼叫setTimeout,間隔一定時間檢查是否到閒置上限。 2的情況,可以用window.onfocus來檢查是否到閒置時間上限。 嗯嗯,細節很多呢
# by freeway
這個可以避免瀏覽器節流,推薦大家使用! https://github.com/turuslan/HackTimer
# by Mike
被這個 bug 搞了一個禮拜,終於找到解答了,萬分感謝 Orz
# by Kt
HackTimer測試沒有問題,讚
# by caocao
HackTimer 仍可用 感謝大神