瀏覽器支援
在系統資源受限的情況下,現今的新式瀏覽器有時會暫停網頁,或完全捨棄網頁。日後,瀏覽器會主動執行這項作業,以便減少耗電量和記憶體用量。Page Lifecycle API 提供生命週期鉤子,讓網頁能夠安全地處理這些瀏覽器介入行為,而不影響使用者體驗。請查看 API,瞭解是否應在應用程式中實作這些功能。
背景
應用程式生命週期是新型作業系統管理資源的重要方式。在 Android、iOS 和近期的 Windows 版本中,作業系統可隨時啟動及停止應用程式。這可讓這些平台精簡及重新分配資源,為使用者帶來最大效益。
在網頁上,過去並沒有這類生命週期,應用程式可以無限期保持運作。大量網頁執行時,記憶體、CPU、電池和網路等重要系統資源可能會超訂,導致使用者體驗不佳。
雖然網頁平台早已提供與生命週期狀態相關的事件,例如 load
、unload
和 visibilitychange
,但這些事件只允許開發人員回應使用者啟動的生命週期狀態變更。為了讓網頁在低耗電裝置上穩定運作 (並在所有平台上更注重資源),瀏覽器需要能夠主動回收及重新分配系統資源。
事實上,目前的瀏覽器已針對背景分頁中的網頁採取積極措施來節省資源,許多瀏覽器 (尤其是 Chrome) 都希望能做得更多,以減少整體資源足跡。
問題是,開發人員無法為這類系統啟動的介入措施做好準備,甚至不知道這些措施正在執行。這表示瀏覽器必須採取保守做法,否則可能會導致網頁中斷。
Page Lifecycle API 會透過以下方式嘗試解決這個問題:
- 在網路上引入並標準化生命週期狀態的概念。
- 定義系統啟動的全新狀態,讓瀏覽器限制隱藏或閒置分頁可使用的資源。
- 建立新的 API 和事件,讓網頁開發人員回應這些系統啟動的狀態轉換。
這個解決方案可為網路開發人員提供可預測性,讓他們建構出可抵禦系統干預的應用程式,並讓瀏覽器更積極地最佳化系統資源,最終造福所有網路使用者。
本篇文章的其餘部分將介紹新的網頁生命週期功能,並探討這些功能與所有現有網路平台狀態和事件的關聯。系統也會針對開發人員在各狀態下應 (或不應) 執行的工作類型,提供最佳做法和建議。
網頁生命週期狀態和事件總覽
所有網頁生命週期狀態都是獨立且互斥的,也就是說,網頁一次只能處於一種狀態。而且,大部分的網頁生命週期狀態變更通常可透過 DOM 事件觀察到 (如有例外狀況,請參閱各狀態的開發人員建議)。
如要說明網頁生命週期狀態,以及在這些狀態之間轉換的事件,最簡單的方法或許是使用圖表:
州
下表詳細說明每個狀態。並列出可能發生在前後的狀態,以及開發人員可用來觀察變更的事件。
州 | 說明 |
---|---|
進行中 |
如果頁面可見且已取得輸入焦點,則處於「active」狀態。
可能的先前狀態: |
被動 |
如果網頁可見且未取得輸入焦點,則處於passive 狀態。
可能的先前狀態:
可能的下一個狀態: |
隱藏 |
如果網頁未顯示 (且未遭到凍結、捨棄或終止),則處於「隱藏」狀態。
可能的先前狀態:
可能的下一個狀態: |
凍結 |
在「已凍結」狀態下,瀏覽器會暫停執行頁面
工作佇列中的
任務,直到頁面解凍為止。這表示 JavaScript 計時器和擷取回呼等項目不會執行。已執行的工作可以完成 (最重要的是
瀏覽器會凍結網頁,以節省 CPU/電池/資料用量;此外,這也是加快 前進/後退瀏覽的一種方式,可避免需要重新載入整個網頁。
可能的下一個狀態: |
已終止 |
網頁在瀏覽器開始卸載並從記憶體中清除後,就會處於「terminated」狀態。在這個狀態下,系統無法啟動 新任務,且如果進行中的任務執行時間過長,可能會遭到終止。
可能的下一個狀態: |
已捨棄 |
當瀏覽器為了節省資源而卸載網頁時,該網頁就會處於「discarded」狀態。在這種狀態下,任何工作、事件回呼或 JavaScript 都無法執行,因為資源限制通常會導致棄用,而無法啟動新程序。 在已捨棄狀態下,即使網頁已消失,使用者通常仍可看到分頁本身 (包括分頁標題和 favicon)。
可能的先前狀態:
可能的下一個狀態: |
活動
瀏覽器會調度許多事件,但只有其中的一小部分會表示網頁生命週期狀態可能發生變更。下表概略說明與生命週期相關的所有事件,並列出這些事件可能的轉換狀態。
名稱 | 詳細資料 |
---|---|
focus
|
DOM 元素已收到焦點。
注意:
可能的先前狀態:
目前可能的狀態: |
blur
|
DOM 元素已失去焦點。
注意:
可能的先前狀態:
可能的目前狀態: |
visibilitychange
|
文件的
|
freeze
*
|
系統剛剛已將網頁凍結。頁面工作佇列中任何 可凍結的工作都不會啟動。
可能的先前狀態:
可能的目前狀態: |
resume
*
|
瀏覽器已恢復「凍結」的網頁。
可能的先前狀態:
可能的目前狀態: |
pageshow
|
正在檢查工作階段記錄項目。 這可能是全新的網頁載入作業,也可能是從往返快取中擷取的網頁。如果網頁是從往返快取中擷取,事件的 |
pagehide
|
工作階段記錄項目的檢索來源。 如果使用者前往其他網頁,且瀏覽器能夠將目前網頁加入前進/後退快取,以便日後重複使用,則事件的
可能的先前狀態:
可能的目前狀態: |
beforeunload
|
視窗、文件及其資源即將卸載。 文件仍會顯示,且事件仍可在此時取消。
重要事項:
可能的先前狀態:
可能的目前狀態: |
unload
|
系統正在卸載頁面。
警告:我們絕不建議使用
可能的先前狀態:
可能的目前狀態: |
* 表示由 Page Lifecycle API 定義的新事件
Chrome 68 新增功能
上一個圖表顯示兩種系統啟動的狀態,而非使用者啟動的狀態:凍結和捨棄。如先前所述,目前的瀏覽器偶爾會凍結並捨棄隱藏的分頁 (視情況而定),但開發人員無法得知何時會發生這種情況。
在 Chrome 68 中,開發人員現在可以透過監聽 document
上的 freeze
和 resume
事件,觀察隱藏分頁何時遭到凍結和解凍。
document.addEventListener('freeze', (event) => {
// The page is now frozen.
});
document.addEventListener('resume', (event) => {
// The page has been unfrozen.
});
自 Chrome 68 起,document
物件現在會在 Chrome 電腦版中加入 wasDiscarded
屬性 (Android 支援功能正在這個問題中追蹤)。如要判斷網頁是否在隱藏分頁中遭到捨棄,您可以在網頁載入時檢查這個屬性的值 (請注意:必須重新載入遭到捨棄的網頁才能再次使用)。
if (document.wasDiscarded) {
// Page was previously discarded by the browser while in a hidden tab.
}
如需 freeze
和 resume
事件中的重要操作建議,以及如何處理及準備遭到捨棄的網頁,請參閱各個狀態的開發人員建議。
接下來的幾個章節將概略說明這些新功能如何融入現有的網路平台狀態和事件。
如何在程式碼中觀察網頁生命週期狀態
在「active」、「passive」 和「hidden」 狀態下,您可以執行 JavaScript 程式碼,藉此透過現有的網路平台 API 判斷目前的網頁生命週期狀態。
const getState = () => {
if (document.visibilityState === 'hidden') {
return 'hidden';
}
if (document.hasFocus()) {
return 'active';
}
return 'passive';
};
另一方面,已凍結和已終止狀態只能在狀態變更時,透過各自的事件事件監聽器 (freeze
和 pagehide
) 偵測。
如何觀察狀態變更
您可以利用先前定義的 getState()
函式,透過以下程式碼觀察所有頁面生命週期狀態變更。
// Stores the initial state using the `getState()` function (defined above).
let state = getState();
// Accepts a next state and, if there's been a state change, logs the
// change to the console. It also updates the `state` value defined above.
const logStateChange = (nextState) => {
const prevState = state;
if (nextState !== prevState) {
console.log(`State change: ${prevState} >>> ${nextState}`);
state = nextState;
}
};
// Options used for all event listeners.
const opts = {capture: true};
// These lifecycle events can all use the same listener to observe state
// changes (they call the `getState()` function to determine the next state).
['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach((type) => {
window.addEventListener(type, () => logStateChange(getState()), opts);
});
// The next two listeners, on the other hand, can determine the next
// state from the event itself.
window.addEventListener('freeze', () => {
// In the freeze event, the next state is always frozen.
logStateChange('frozen');
}, opts);
window.addEventListener('pagehide', (event) => {
// If the event's persisted property is `true` the page is about
// to enter the back/forward cache, which is also in the frozen state.
// If the event's persisted property is not `true` the page is
// about to be unloaded.
logStateChange(event.persisted ? 'frozen' : 'terminated');
}, opts);
這段程式碼會執行三項作業:
- 使用
getState()
函式設定初始狀態。 - 定義可接受下一個狀態的函式,如果有變更,就將狀態變更記錄到控制台。
- 為所有必要的生命週期事件新增擷取事件監聽器,這些事件監聽器會依序呼叫
logStateChange()
,並傳遞下一個狀態。
關於這段程式碼,有一件值得注意的事是,所有事件監聽器都會新增至 window
,且都會傳遞 {capture: true}
。原因如下:
- 並非所有網頁生命週期事件都具有相同的目標。
pagehide
和pageshow
會在window
上觸發;visibilitychange
、freeze
和resume
會在document
上觸發;focus
和blur
會在各自的 DOM 元素上觸發。 - 這些事件大多不會冒泡,也就是說,您無法將非擷取事件事件監聽器新增至共同的祖系元素,並觀察所有事件。
- 擷取階段會在目標或泡泡階段之前執行,因此在該階段新增事件監聽器,有助於確保事件監聽器在其他程式碼取消前執行。
各狀態的開發人員建議
開發人員必須瞭解頁面生命週期狀態,並瞭解如何在程式碼中觀察這些狀態,因為您應 (或不應) 執行的工作類型,很大程度上取決於頁面處於何種狀態。
舉例來說,如果網頁處於隱藏狀態,向使用者顯示暫時性通知顯然不合理。雖然這個範例相當明顯,但還有其他不那麼明顯的建議值得列舉。
州 | 開發人員建議 |
---|---|
Active |
active 狀態是使用者最關鍵的時間,也是網頁 回應使用者輸入內容的關鍵時間。 任何可能阻斷主執行緒的非 UI 工作,應將優先順序降至 閒置期間,或 卸載至網路背景工作程式。 |
Passive |
在「passive」狀態下,使用者不會與網頁互動,但仍可查看網頁。也就是說,UI 更新和動畫仍應流暢,但這些更新的時間點則較不重要。 當頁面從「活動」變更為「非活動」時,正是儲存未儲存應用程式狀態的好時機。 |
當網頁從「無主動操作」變更為「隱藏」時,使用者可能會在重新載入前,不會再與該網頁互動。 轉換為「隱藏」通常也是開發人員可可靠觀察到的最後狀態變更 (在行動裝置上尤其如此,因為使用者可以關閉分頁或瀏覽器應用程式本身,且在這些情況下不會觸發 也就是說,您應將「隱藏」狀態視為使用者工作階段可能結束的狀態。換句話說,保留所有未儲存的應用程式狀態,並傳送所有未傳送的數據分析資料。 您也應停止進行 UI 更新 (因為使用者不會看到這些更新),並停止任何使用者不希望在背景執行的工作。 |
|
Frozen |
在「凍結」狀態下, 工作佇列中的 可凍結工作會在頁面解凍前暫停,但頁面可能永遠不會解凍 (例如頁面遭到捨棄)。 也就是說,當網頁從「隱藏」變更為「凍結」時,請務必停止任何計時器或拆除任何連線,因為這些連線在凍結時,可能會影響同一個來源中其他已開啟的分頁,或影響瀏覽器將網頁放入 back/forward cache 的能力。 請特別注意以下事項:
您也應將任何動態檢視畫面狀態 (例如無限清單檢視畫面中的捲動位置) 儲存至
如果頁面從「凍結」轉換回「隱藏」,您可以重新開啟所有已關閉的連線,或重新啟動在頁面初始凍結時停止的任何輪詢。 |
Terminated |
網頁轉換為「已終止」狀態時,您通常不需要採取任何行動。 由於因使用者操作而卸載的頁面在進入「已終止」狀態前,一律會經過「隱藏」狀態,因此應在「隱藏」狀態下執行工作階段結束邏輯 (例如持續應用程式狀態,並向 Analytics 回報)。 此外,如狀態的建議所述,開發人員必須瞭解,在許多情況下 (尤其是在行動裝置上),系統無法可靠地偵測轉換至已終止狀態,因此依賴終止事件 (例如 |
Discarded |
開發人員無法在頁面遭到捨棄時觀察到「已捨棄」狀態。這是因為網頁通常會在資源受限的情況下遭到捨棄,在大多數情況下,如果只是為了讓指令碼回應捨棄事件而解凍網頁,是不可能的。 因此,您應準備好因應從「隱藏」變更為「凍結」時,可能會捨棄的情況,然後檢查 |
再次提醒,由於生命週期事件的可靠性和順序並未在所有瀏覽器中一致實作,因此要遵循表格中的建議,最簡單的方法就是使用 PageLifecycle.js。
應避免使用的舊版生命週期 API
請盡可能避免下列事件。
卸載事件
許多開發人員會將 unload
事件視為保證回呼,並將其用作工作階段結束信號,以便儲存狀態並傳送分析資料,但這麼做極不可靠,特別是在行動裝置上!在許多典型的卸載情況下,unload
事件不會觸發,包括在行動裝置上透過分頁切換器關閉分頁,或透過應用程式切換器關閉瀏覽器應用程式。
因此,建議您一律使用 visibilitychange
事件判斷工作階段結束的時間,並將隱藏狀態視為儲存應用程式和使用者資料的最後可靠時間。
此外,只要註冊 unload
事件處理常式 (透過 onunload
或 addEventListener()
),即可防止瀏覽器將網頁放入往返快取,以便加快往返載入作業。
在所有新式瀏覽器中,建議您一律使用 pagehide
事件偵測可能的網頁卸載 (又稱為 terminated 狀態),而非使用 unload
事件。如果您需要支援 Internet Explorer 10 以下版本,請偵測 pagehide
事件,並僅在瀏覽器不支援 pagehide
時使用 unload
:
const terminationEvent = 'onpagehide' in self ? 'pagehide' : 'unload';
window.addEventListener(terminationEvent, (event) => {
// Note: if the browser is able to cache the page, `event.persisted`
// is `true`, and the state is frozen rather than terminated.
});
beforeunload 事件
beforeunload
事件與 unload
事件的問題類似,因為在過去,beforeunload
事件的存在會導致頁面無法使用往返快取。新式瀏覽器則沒有這項限制。不過,為防萬一,部分瀏覽器在嘗試將網頁放入前進/後退快取時,不會觸發 beforeunload
事件,這表示該事件無法做為工作階段結束信號。此外,部分瀏覽器 (包括 Chrome) 會要求使用者在網頁上進行互動,才能觸發 beforeunload
事件,這會進一步影響事件的可靠性。
beforeunload
和 unload
之間的一個差異是,beforeunload
有合法的用途。舉例來說,如果您想警告使用者,如果他們繼續卸載頁面,未儲存的變更會遺失,您可以這麼做。
由於使用 beforeunload
有其正當理由,建議您僅在使用者有未儲存的變更時,才加入 beforeunload
事件監聽器,並在儲存後立即移除。
換句話說,請勿這麼做 (因為會無條件新增 beforeunload
事件監聽器):
addEventListener('beforeunload', (event) => {
// A function that returns `true` if the page has unsaved changes.
if (pageHasUnsavedChanges()) {
event.preventDefault();
// Legacy support for older browsers.
return (event.returnValue = true);
}
});
請改用以下做法 (因為這只會在需要時新增 beforeunload
監聽器,並在不需要時移除):
const beforeUnloadListener = (event) => {
event.preventDefault();
// Legacy support for older browsers.
return (event.returnValue = true);
};
// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
addEventListener('beforeunload', beforeUnloadListener);
});
// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
removeEventListener('beforeunload', beforeUnloadListener);
});
常見問題
為何沒有「loading」狀態?
Page Lifecycle API 定義的狀態是彼此互斥的獨立狀態。由於頁面可在活動、被動或隱藏狀態下載入,且在載入完成前可變更狀態,甚至終止,因此在這個架構中,單獨的載入狀態就沒有意義。
我的頁面在隱藏時會執行重要工作,如何避免系統將其凍結或捨棄?
有許多正當原因,說明網頁在隱藏狀態下執行時不應凍結。最明顯的例子是播放音樂的應用程式。
在某些情況下,Chrome 捨棄網頁的風險會提高,例如網頁含有未提交的使用者輸入內容表單,或是含有 beforeunload
處理常式,會在網頁卸載時發出警告。
目前,Chrome 在捨棄網頁時會採取保守做法,只有在確信不會影響使用者時才會捨棄。舉例來說,如果系統偵測到網頁在隱藏狀態下執行下列任何操作,除非資源受限嚴重,否則系統不會捨棄該網頁:
- 播放音訊
- 使用 WebRTC
- 更新資料表標題或 favicon
- 顯示快訊
- 傳送推播通知
如要瞭解目前用於判斷分頁是否可安全凍結或捨棄的清單功能,請參閱 Chrome 中的凍結與捨棄的最佳化準則。
「返回/前進快取」一詞是用來描述某些瀏覽器實作的導覽最佳化功能,可加快使用返回和前進按鈕的速度。
當使用者離開網頁時,這些瀏覽器會將該網頁的版本凍結,以便在使用者使用返回或前進按鈕返回時,快速恢復網頁。請注意,新增 unload
事件處理常式會導致這項最佳化無法執行。
無論目的為何,此凍結功能的運作方式都與瀏覽器為了節省 CPU/電池而執行的凍結功能相同,因此被視為凍結生命週期狀態的一部分。
如果無法在凍結或終止狀態下執行非同步 API,如何將資料儲存到 IndexedDB?
在凍結和終止狀態下,頁面 工作佇列中的可凍結工作會暫停,這表示無法可靠地使用 IndexedDB 等非同步和回呼 API。
日後,我們會在 IDBTransaction
物件中新增 commit()
方法,讓開發人員執行不需要回呼的純寫入交易。換句話說,如果開發人員只是將資料寫入 IndexedDB,而非執行包含讀取和寫入作業的複雜交易,commit()
方法就能在工作佇列暫停前完成 (假設 IndexedDB 資料庫已開啟)。
不過,如果程式碼需要在現階段運作,開發人員有兩種做法:
- 使用工作階段儲存空間: 工作階段儲存空間 是同步的,且會在網頁丟棄時保留。
- 透過服務工作站使用 IndexedDB:服務工作站可以在網頁終止或遭到捨棄後,將資料儲存在 IndexedDB 中。在
freeze
或pagehide
事件監聽器中,您可以透過postMessage()
將資料傳送至服務工作者,而服務工作者可以處理資料儲存作業。
在凍結和捨棄狀態下測試應用程式
如要測試應用程式在凍結和捨棄狀態下的行為,您可以前往 chrome://discards
,實際凍結或捨棄任何已開啟的分頁。
這可讓您確保網頁在棄用後重新載入時,正確處理 freeze
和 resume
事件,以及 document.wasDiscarded
標記。
摘要
如果開發人員想尊重使用者裝置的系統資源,應在建構應用程式時考量頁面生命週期狀態。網頁在使用者意料之外的情況下,不得消耗過多的系統資源
越多開發人員開始實作新的網頁生命週期 API,瀏覽器凍結及捨棄未使用的網頁就會越安全。這表示瀏覽器會耗用較少的記憶體、CPU、電池和網路資源,對使用者來說是雙贏。