同事分享踩到的地雷一枚 - 某個用 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 模式:

  1. 網頁隱藏超過五分鐘
  2. Timer 連續執行次數超過 5 次
  3. 過去 30 秒網頁未播放任何聲音
  4. 未使用 WebRTC

瀏覽器將改為每分鐘一次,蒐集到期的 Timer 一次執行。也就是,原本一分執行 300 次、60 次、10 次、5 次的 setInterval,通通會變成一分鐘只跑一次,最後可能產生非預期的結果。

這個設計是為了避免 Timer 消耗無謂的 CPU 跟電力,如果有類似需求,依應用情境官方建議採用更有效率的替代方案:

  1. State Polling
    定期檢查偵測元素變化是最常被誤用的 Timer 用途,其實有 API 能更有效率搞定,例如:IntersectionObserver 可偵測元素是否進入 ViewPort、ResizeObserver 能感應元素尺寸變化、MutationObserver 或自訂元素生命週期 Callback 可測偵 DOM 改變、WebSocket/Server-Sent Events/Push Messages/Fetch Stream 可以即時接收 Server 通知。
  2. 動畫
    網頁隱藏期間實在沒理由浪費資源處理動畫,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來檢查是否到閒置時間上限。 嗯嗯,細節很多呢

Post a comment


80 - 21 =