開發紀錄
hidden tab、reduced-motion、resize 與 pagehide 要一起處理嗎
以目前掛載的 Canvas 工程網格為例,區分暫停、靜態重繪、離頁清理和 BFCache 恢復,並記錄本機分頁、媒體偏好與歷史返回的實際驗證範圍。
- Canvas
- 動畫生命週期
- reduced-motion
- BFCache
背景動畫在頁面打開時能動,不代表切走、縮放或返回時仍然正確。
隱藏分頁需要停下持續更新,降低動態效果的偏好需要靜態畫面,視窗改變後需要重新配置 Canvas,離開頁面則要釋放這一輪建立的控制。這些事件會碰到同一個動畫迴圈,卻不能全部用「停止」兩個字處理。
這次我沿著目前實際掛載的工程網格背景,讀取初始化與清理路徑,再用本機瀏覽器檢查不同狀態。先前的流線文章保留當時的實作與量測;本文不把仍留在專案裡的舊背景元件,當成目前頁面正在執行的效果。
先分清楚暫停、靜態與釋放
現在的網格控制器分別記錄 isPaused、prefersReducedMotion、disposed 和下一個 animationFrameId。它們回答不同問題:
| 狀態 | 需要保留什麼 | 主要處理 |
|---|---|---|
| 正常可見且允許動態 | 同一輪動畫的資料與繪製控制 | 每次 rAF 更新並繪製,再排下一次 |
| 分頁隱藏 | 既有控制器和資料 | 取消後續動畫排程,等待可見性改變 |
| 降低動態效果 | 可讀的靜態背景 | 停止持續動畫,必要時重繪 |
| 視窗尺寸改變 | 動態偏好與目前控制器 | 重建畫布尺寸及網格,依狀態繪製 |
| 離開頁面 | 若要恢復,需由外層重新建立控制 | 移除本輪監聽、取消 rAF、釋放 context 引用 |
如果把隱藏分頁直接當成永久釋放,回到分頁時可能沒有控制器可以繼續工作。反過來,離頁時只把動畫暫停,監聽器和引用也沒有因此完成清理。
所以我會先決定狀態代表什麼,再安排事件,不先把每個事件都塞進相同的 stop 函式。
隱藏時暫停,回來時重設時間基準
MDN 的 Page Visibility API 說明,分頁可見性改變時可透過 visibilitychange 與文件狀態判斷;多數瀏覽器也會停止向背景分頁提供 rAF callback。[2]
瀏覽器有自己的排程策略,仍不能代替應用程式對狀態的安排。目前的控制器在隱藏時取消已排定的 rAF;回到可見狀態後,再依動態偏好決定畫一次靜態背景,或繼續動畫。
恢復時也會重設 previousRenderTime,不把離開畫面的整段時間,當成下一格需要追趕的移動距離。正常 render 另外限制單次使用的時間差。這是視覺更新的取捨,不是精確追蹤背景中所有經過時間的模擬。
本機測試中,我建立另一個分頁並切換到它,先確認原頁的 document.hidden 確實變成 true,才比較計數。該段觀察期間,callback 與 draw 都沒有繼續增加;回到原分頁後,繪製恢復。
只手動送出一個 visibilitychange 事件,卻沒有確認文件實際狀態,不能算成這種分頁切換驗證。另外,這次沒有在隱藏期間同時切換其他設定,因此不能把計數穩定擴大成所有事件組合都絕不觸發重繪。
reduced-motion 不是「把同一個動畫跑慢一點」
prefers-reduced-motion 表達使用者希望降低非必要動態效果的偏好;它本身不替 Canvas 改寫 render loop。[3]
目前網格在降低動態效果時,會取消持續 rAF,畫出靜態網格,不再推進掃過節點的動態高亮。這和把正常動畫限制成較低 FPS,是不同決定。
控制器不只在初始化讀一次媒體查詢,也監聽後續的 change。因此,偏好在頁面打開之後改變時,仍有切換路徑;恢復允許動態時,則在頁面可見且沒有已排程影格的情況下重新開始。
這次透過瀏覽器的媒體偏好模擬切成 reduce,等切換事件處理完,再分兩次讀取計數,確認持續更新已停止。改回 no-preference 後,draw 再次增加。這證明的是該次瀏覽器模擬下的行為,不是每一種作業系統設定介面的實測。
也因此,我不會拿降低動態效果模式下的總 callback 與總 draw 是否相等,判斷正常模式有沒有丟影格:有些靜態重繪由事件直接觸發,本來就不需要新的 rAF callback。計數必須在同一種狀態下比較增量。
靜態背景仍然需要處理 resize
停止持續動畫,不代表畫布可以永遠維持第一次的尺寸。
目前實作會依視窗尺寸與裝置像素比設定 Canvas,同時重建網格資料與靜態圖層。動態模式由接下來的 render 繪製;降低動態效果且頁面可見時,resize 會直接重繪一次,避免留下舊尺寸的畫面。
這次在 reduce 狀態,將模擬視窗從 1280 × 900 改成 900 × 700,裝置像素比設為 1。畫布尺寸跟著更新,draw 增加一次,之後的觀察期間沒有繼續增加。
這裡確認的是尺寸與排程關係,不是手機視覺、所有像素比或效能。若只看到畫面不動,就認為 reduced-motion 已完成,會漏掉視窗改變後仍要有正確靜態輸出的責任。
pagehide 清理,還要接上 BFCache 恢復
目前背景元件持有控制器回傳的 cleanup。pagehide 時呼叫它;pageshow 若帶有 persisted,則先整理舊控制,再重新初始化背景。
這個 cleanup 會移除該輪的 resize、visibility 與媒體偏好監聽,取消待執行的 rAF,並釋放 Canvas context 引用。disposed 也讓重複呼叫清理不必再次完成同一份工作。外層用來接收離頁與恢復的監聽,則繼續負責銜接下一輪控制器。
MDN 說明,使用瀏覽器前進或後退、從 BFCache 恢復頁面,都在 pageshow 的情境內。[5] 但返回不保證一定命中 BFCache,不能只因為按了上一頁,就宣稱測到了快取恢復。
這次實際離開關於頁後,再透過瀏覽器歷史返回,記錄到 pageshow.persisted === true,背景繪製也重新增加。這是一次真正命中 BFCache 的觀察,不是自行派送一個同名事件。
另外,Canvas 控制和頁面既有按鈕不必採相同的清理政策。關於頁的主要互動監聽在離頁事件的 persisted 分支保留,背景則重新建立自己的繪製控制。該次返回後,頭像仍能開啟、按 Escape 關閉並還原焦點;我沒有為了恢復背景而強制 reload 整頁。
這些結果仍不能證明所有返回次數與所有瀏覽器都沒有重複監聽。MDN 也提醒,pagehide 並非在所有行動裝置離開情境都可靠觸發。[4] 對這個裝飾性背景而言,它是生命週期處理的一部分,不應被說成資料保存或遠端送出的可靠保證。
把每個狀態的觀察條件一起留下
本輪在本機 Edge 無頭瀏覽器執行,核心結果如下。媒體偏好與視窗大小由瀏覽器模擬;分頁可見性與 BFCache 則先讀到原生狀態,再判斷結果。
| 檢查 | 這次的觀察 |
|---|---|
| 正常可見、允許動態 | 約 600 毫秒的取樣中,callback 與 draw 各增加 37 次 |
切成 reduce 並等待事件完成 |
後續約 500 毫秒內,兩種計數不再增加 |
reduce 中改變視窗尺寸 |
畫布更新並重繪一次,之後保持靜態 |
| 恢復允許動態 | draw 再次增加 |
| 切至其他分頁,確認原頁隱藏 | 該段觀察的 callback 與 draw 保持不變 |
| 切回原分頁 | 繪製繼續 |
| 原生歷史返回且命中 BFCache | 收到持久化恢復事件,背景恢復,頭像互動仍可使用 |
這組測試沒有量測耗電、GPU 成本、所有裝置刷新率或長時間穩定性,也沒有涵蓋狀態轉換的所有排列。觀察到靜態模式停下更新,不代表已經證明省下多少資源。
先前的背景流線篇處理正常模式的更新頻率,本文補的是另一個問題:動畫何時該繼續、何時只重繪一次,以及離開後由誰接回控制。不能為了讓它一直動,就把所有狀態都當成正常可見頁面。
Sources
[2] https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API [3] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion [4] https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event [5] https://developer.mozilla.org/en-US/docs/Web/API/Window/pageshow_event