網頁生命週期 API

瀏覽器支援

  • Chrome:68。
  • Edge:79。
  • Firefox:不支援。
  • Safari:不支援。

在系統資源受限的情況下,現今的新式瀏覽器有時會暫停網頁,或完全捨棄網頁。日後,瀏覽器會主動執行這項作業,以便減少耗電量和記憶體用量。Page Lifecycle API 提供生命週期掛鉤,可讓您的網頁安全地處理這些瀏覽器介入措施,而不會影響使用者體驗。請查看這個 API,確認是否應在應用程式中實作這些功能。

背景

應用程式生命週期是現代化作業系統管理資源的重要方式。在 Android、iOS 和近期的 Windows 版本中,作業系統可隨時啟動及停止應用程式。如此一來,這些平台就能在對使用者最有幫助的情況下簡化及重新分配資源。

在網路上,過去並沒有這類生命週期,應用程式可以無限期保持運作。大量網頁執行時,記憶體、CPU、電池和網路等重要系統資源可能會超訂,導致使用者體驗不佳。

雖然網頁平台早已提供與生命週期狀態相關的事件,例如 loadunloadvisibilitychange,但這些事件只允許開發人員回應使用者啟動的生命週期狀態變更。為了讓網頁在低耗電裝置上運作無虞 (並在所有平台上更注重資源),瀏覽器需要能夠主動回收及重新分配系統資源。

事實上,目前的瀏覽器已針對背景分頁中的網頁採取積極措施來節省資源,許多瀏覽器 (尤其是 Chrome) 都希望能做得更多,以減少整體資源足跡。

問題是,開發人員無法為這類系統啟動的介入措施做好準備,甚至無法知道這類情況發生了。這表示瀏覽器必須保守或有破壞網頁的風險。

Page Lifecycle API 會嘗試透過下列方式解決這個問題:

  • 在網路上介紹及標準化生命週期狀態的概念。
  • 定義系統啟動的全新狀態,讓瀏覽器限制隱藏或閒置分頁可使用的資源。
  • 建立新的 API 和事件,讓網頁開發人員能夠回應這些系統啟動的狀態轉換。

這個解決方案可為網路開發人員提供可預測性,讓他們建構出可抵禦系統干預的應用程式,並讓瀏覽器更積極地最佳化系統資源,最終造福所有網路使用者。

本篇文章的其餘部分將介紹新的網頁生命週期功能,並探討這些功能與所有現有網路平台狀態和事件的關聯。同時也會針對開發人員在各種狀態下應 (或不應該) 處理的工作類型提供建議和最佳做法。

頁面生命週期狀態和事件總覽

所有網頁生命週期狀態都是獨立且互斥的,也就是說,網頁一次只能處於一種狀態。此外,頁面生命週期狀態的大部分變更通常可以透過 DOM 事件觀察 (例外狀況請參閱開發人員建議以瞭解各狀態)。

也許要解釋頁面生命週期狀態,以及發出信號轉換的事件,最簡單的方法就是使用圖表:

以視覺化方式呈現本文件所說明的狀態和事件流程。
Page Lifecycle API 狀態和事件流程。

下表詳細說明每個狀態。並列出可能發生在前後的狀態,以及開發人員可用來觀察變更的事件。

說明
進行中

如果頁面可見且已取得輸入焦點,則處於「active」狀態。

可能的先前狀態:
被動 (透過 focus 事件)
已凍結 (透過 resume 事件,然後透過 pageshow 事件)

可能的下一個狀態:
被動 (透過 blur 事件)

被動式

如果網頁可見且沒有輸入焦點,則處於passive 狀態。

可能的先前狀態:
啟用 (透過 blur 事件)
隱藏 (透過 visibilitychange 事件)
凍結 (透過 resume 事件,然後是 pageshow 事件)

可能的下一個狀態:
有效 (透過 focus 事件)
隱藏 (透過 visibilitychange 事件)

隱藏

如果頁面未顯示 (且尚未凍結、捨棄或終止),狀態會處於隱藏狀態。

可能的先前狀態:
passive (透過 visibilitychange 事件)
frozen (透過 resume 事件,然後透過 pageshow 事件)

可能的後續狀態:
被動 (透過 visibilitychange 事件)
凍結 (透過 freeze 事件)
捨棄 (未觸發任何事件)
已終止的事件 (未觸發任何事件)

凍結

在「已凍結」狀態下,瀏覽器會暫停執行頁面工作佇列中的任務,直到頁面解凍為止。這代表 JavaScript 計時器和擷取回呼等作業無法執行。正在執行的工作 (最重要的是 freeze 回呼),但可能限制執行的作業和執行時間長度。

瀏覽器會凍結網頁來保留 CPU/電池/數據用量,也會藉此加快 往返瀏覽速度,避免系統必須重新載入完整頁面。

可能的先前狀態:
隱藏 (透過 freeze 事件)

可能的下一個狀態:
active (透過 resume 事件,然後是 pageshow 事件)
passive (透過 resume 事件,然後是 pageshow 事件)
hidden (透過 resume 事件)
discarded (未觸發事件)

已終止

網頁在瀏覽器開始卸載並從記憶體中清除後,就會處於「terminated」狀態。在這個狀態下,系統無法啟動新任務,且如果進行中的任務執行時間過長,可能會遭到終止。

可能的先前狀態:
隱藏 (透過 pagehide 事件)

可能的下一個狀態:
NONE

已捨棄

頁面為了節省資源,而在瀏覽器卸載時,會處於「捨棄」狀態。沒有任何工作、事件回呼或任何類型的 JavaScript 都能在此狀態下執行,因為捨棄通常會發生在資源限制下,因此無法啟動新程序。

在「捨棄」狀態中,即使頁面不再顯示,使用者通常還是會看到分頁本身,包括分頁標題和網站小圖示。

可能的先前狀態:
隱藏 (未觸發任何事件)
凍結 (未觸發任何事件)

可能的下一個狀態:
NONE

活動

瀏覽器會調度許多事件,但只有其中的一小部分會表示網頁生命週期狀態可能發生變更。下表概略說明與生命週期相關的所有事件,並列出這些事件可轉換到哪些狀態。

名稱 詳細資料
focus

DOM 元素已收到焦點。

注意:focus 事件不一定會傳送狀態變更信號。只有在網頁先前沒有輸入焦點時,才會指示狀態變更。

可能的先前狀態:
被動

可能的目前狀態:
active

blur

DOM 元素已失去焦點。

注意:blur 事件不一定會傳送狀態變更信號。只有在頁面不再有輸入焦點 (也就是頁面並未將焦點從一個元素切換至另一個元素) 時,才會發出狀態變更信號。

可能的先前狀態:
active

可能的目前狀態:
passive

visibilitychange

文件的 visibilityState 值已變更。當使用者前往新頁面、切換分頁、關閉分頁、最小化或關閉瀏覽器,或是在行動作業系統上切換應用程式時,就可能發生這種情況。

可能的先前狀態:
被動
已隱藏

可能的目前狀態:
passive
hidden

freeze *

頁面剛剛已凍結。系統不會啟動頁面工作佇列中的任何可凍結工作。

可能的先前狀態:
隱藏

可能的目前狀態:
凍結

resume *

瀏覽器已恢復「凍結」的頁面。

可能的先前狀態:
frozen

可能的目前狀態:
有效 (如果後面接著 pageshow 事件)
被動 (如果後面接著 pageshow 事件)
隱藏

pageshow

正在掃遍工作階段記錄項目。

這可能是全新的網頁載入作業,也可能是從往返快取中擷取的網頁。如果網頁是從往返快取中擷取,事件的 persisted 屬性為 true,否則為 false

可能的先前狀態:
已凍結 (系統也會觸發 resume 事件)

可能的目前狀態:
有效
被動
隱藏

pagehide

系統正在掃遍工作階段記錄項目。

如果使用者前往其他網頁,且瀏覽器能夠將目前網頁加入前往/返回快取,以便日後重複使用,則事件的 persisted 屬性為 true。當 true 時,頁面會進入「已凍結」狀態,否則會進入「已終止」狀態。

可能的先前狀態:
已隱藏

可能的目前狀態:
已凍結 (event.persisted 為 true,接著是 freeze 事件)
已終止 (event.persisted 為 false,接著是 unload 事件)

beforeunload

視窗、文件及其資源即將卸載。 文件仍會顯示,且事件仍可在此時取消。

重要事項:beforeunload 事件只能用來通知使用者有尚未儲存的變更。儲存這些變更後,系統應會移除活動。請勿在頁面中無條件加入這項屬性,因為這麼做可能會在某些情況下影響效能。詳情請參閱「舊版 API」一節。

可能的先前狀態:
隱藏

可能的目前狀態:
已終止

unload

系統正在卸載頁面。

警告:我們不建議使用 unload 事件,因為這不僅不可靠,有時可能會導致效能降低。詳情請參閱舊版 API 一節

可能的先前狀態:
隱藏

可能的目前狀態:
terminated

* 表示由 Page Lifecycle API 定義的新事件

Chrome 68 新增功能

上一個圖表顯示兩種系統啟動的狀態,而非使用者啟動的狀態:凍結捨棄。如先前所述,目前的瀏覽器會不時凍結並捨棄隱藏的分頁 (視情況而定),但開發人員無法得知何時會發生這種情況。

在 Chrome 68 中,開發人員現在可以透過監聽 document 上的 freezeresume 事件,觀察隱藏分頁何時凍結和解凍。

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.
}

如需 freezeresume 事件中的重要操作建議,以及如何處理及準備遭到捨棄的網頁,請參閱各個狀態的開發人員建議

接下來的幾個章節將概略說明這些新功能如何融入現有的網路平台狀態和事件。

如何在程式碼中觀察網頁生命週期狀態

在「active」、「passive」 和「hidden」 狀態下,您可以執行 JavaScript 程式碼,藉此透過現有的網路平台 API 判斷目前的網頁生命週期狀態。

const getState = () => {
  if (document.visibilityState === 'hidden') {
    return 'hidden';
  }
  if (document.hasFocus()) {
    return 'active';
  }
  return 'passive';
};

另一方面,當狀態變更時,您只能在各自的事件監聽器 (freezepagehide) 中偵測到凍結終止狀態。

如何觀察狀態變更

您可以利用先前定義的 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}。原因如下:

  • 並非所有網頁生命週期事件都具有相同的目標。pagehidepageshow 會在 window 上觸發,visibilitychangefreezeresume 則是在 document 上觸發,focusblur 則會在其各自的 DOM 元素上觸發。
  • 這些事件大多沒有泡泡,也就是說,無法將非擷取的事件監聽器新增至共同的祖系元素,也無法觀察所有事件。
  • 擷取階段會在目標或對話框階段之前執行,因此在該階段新增事件監聽器,確保在其他程式碼可以取消之前執行。

各州的開發人員建議

開發人員必須瞭解頁面生命週期狀態,「並且」瞭解如何在程式碼中觀察這些狀態,因為您應 (與不應該) 執行的工作類型,主要取決於網頁目前狀態。

舉例來說,如果網頁處於隱藏狀態,向使用者顯示暫時性通知顯然不合理。儘管這個範例看起來很明顯,但還有其他不太明顯值得參考的建議。

開發人員建議
Active

active 狀態是使用者最關鍵的時間,也是網頁回應使用者輸入內容的關鍵時間。

任何可能阻斷主執行緒的非 UI 工作,都應將優先順序降至閒置期間,或卸載至網路背景工作程式

Passive

處於「被動」狀態時,使用者並未與網頁互動,但仍然可以查看網頁。也就是說,UI 更新和動畫仍應流暢,但這些更新的時間點則較不重要。

當頁面從「活動」變更為「非活動」時,正是儲存未儲存應用程式狀態的好時機。

Hidden

當頁面從「無主動操作」變更為「隱藏」時,使用者可能會在重新載入前,不會再與該頁面互動。

轉換為「隱藏」通常也是開發人員可可靠觀察到的最後狀態變更 (在行動裝置上尤其如此,因為使用者可以關閉分頁或瀏覽器應用程式本身,且在這些情況下不會觸發 beforeunloadpagehideunload 事件)。

也就是說,您應將「隱藏」狀態視為使用者工作階段可能結束的狀態。換句話說,請保留所有未儲存的應用程式狀態,並傳送任何未傳送的數據分析資料。

此外,您也應該停止更新 UI,因為使用者不會再看到更新作業,因此建議您停止任何不想在背景執行的工作。

Frozen

在「凍結」狀態下,工作佇列中的可凍結工作會在頁面解凍前暫停,但頁面可能永遠不會解凍 (例如頁面遭到捨棄)。

也就是說,當網頁從「隱藏」變更為「凍結」時,您必須停止任何計時器或卸除任何連線,否則如果凍結,可能會影響其他相同來源開啟的分頁,或影響瀏覽器是否能將網頁放入 往返快取

請特別注意以下事項:

此外,如果頁面遭到捨棄並重新載入,而您想要還原的動態檢視畫面狀態 (例如無限清單檢視畫面中的捲動位置) 至 sessionStorage (或透過 commit() 建立索引的 IndexedDB),也建議您保留這些狀態。

如果頁面從「凍結」變回「隱藏」,您可以重新開啟所有已關閉的連線,或重新啟動先前凍結頁面時停止的輪詢。

Terminated

網頁轉換為「已終止」狀態時,您通常不需要採取任何行動。

由於因使用者動作而卸載頁面,一律會先經過隱藏狀態,再進入終止狀態,因此「隱藏」狀態是執行工作階段結束邏輯 (例如持續保留應用程式狀態,以及向數據分析回報) 的位置。

此外,如同狀態的建議所述,開發人員必須瞭解,在許多情況下 (尤其是在行動裝置上),系統無法可靠地偵測轉換至已終止狀態,因此依賴終止事件 (例如 beforeunloadpagehideunload) 的開發人員可能會遺失資料。

Discarded

開發人員無法在頁面遭到捨棄時觀察到「已捨棄」狀態。這是因為網頁通常會在資源受限的情況下遭到捨棄,在大多數情況下,如果只是為了讓指令碼回應捨棄事件而解凍網頁,是不可能的。

因此,您應準備好因應從「隱藏」變更為「凍結」時,可能會捨棄的情況,然後檢查 document.wasDiscarded,以便在網頁載入時回應捨棄網頁的還原作業。

再次提醒,由於生命週期事件的可靠性和順序並未在所有瀏覽器中一致實作,因此要遵循表格中的建議,最簡單的方法就是使用 PageLifecycle.js

應避免的舊版 Lifecycle API

請盡可能避免下列事件。

卸載事件

許多開發人員會將 unload 事件視為保證回呼,並使用該事件做為工作階段結束信號來儲存狀態及傳送分析資料,但這樣做非常不可靠,特別是行動裝置使用者!unload 事件不會在許多常見的卸載情況下觸發,包括從行動裝置的分頁切換器關閉分頁,或從應用程式切換器關閉瀏覽器應用程式。

因此,建議您一律依賴 visibilitychange 事件來判斷工作階段的結束時間,並將隱藏狀態視為「儲存應用程式和使用者資料的最可靠時間」。

此外,只要有註冊的 unload 事件處理常式 (透過 onunloadaddEventListener()),就可能會導致瀏覽器無法將網頁放入往返快取,進而加快前後載入作業。

在所有新式瀏覽器中,建議您一律使用 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 事件,這會進一步影響事件的可靠性。

beforeunloadunload 之間的一個差異是,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);
});

常見問題

為什麼沒有顯示「載入中」狀態?

Page Lifecycle API 定義的狀態是彼此互斥的獨立狀態。由於頁面可在活動中、被動或隱藏狀態載入,而且可以在完成載入前變更狀態,甚至終止,因此不合理。

我的頁面在隱藏時會執行重要工作,如何避免系統將其凍結或捨棄?

有許多正當原因,說明網頁在隱藏狀態下執行時不應凍結。最明顯的例子是播放音樂的應用程式。

在某些情況下,Chrome 捨棄網頁的風險會增加,例如網頁含有未提交的使用者輸入內容表單,或是含有 beforeunload 處理常式,會在網頁卸載時發出警告。

目前,Chrome 會採取保守策略來捨棄網頁,只有在確定不會影響使用者的情況下才採取這種做法。例如,系統觀察到在隱藏狀態中執行下列任一操作的網頁不會遭到捨棄,除非有極大資源限制:

  • 播放音訊
  • 使用 WebRTC
  • 更新資料表標題或 favicon
  • 顯示快訊
  • 傳送推播通知

如要瞭解目前用於判斷分頁是否可安全凍結或捨棄的清單功能,請參閱 Chrome 中的凍結與捨棄的最佳化準則

什麼是往返快取?

「返回/前進快取」一詞是用來描述某些瀏覽器實作的導覽最佳化功能,可加快使用返回和前進按鈕的速度。

當使用者離開網頁時,這些瀏覽器會將該網頁的版本凍結,以便在使用者使用返回或前進按鈕時快速恢復。請注意,新增 unload 事件處理常式會導致這項最佳化無法執行

無論目的為何,此凍結功能的運作方式都與瀏覽器為了節省 CPU/電池電量而執行的凍結功能相同,因此被視為凍結生命週期狀態的一部分。

如果無法在凍結或終止狀態下執行非同步 API,如何將資料儲存到 IndexedDB?

在凍結和終止狀態下,頁面 工作佇列中的可凍結工作會暫停,這表示無法可靠地使用 IndexedDB 等非同步和回呼 API。

日後,我們會IDBTransaction 物件中新增 commit() 方法,讓開發人員能執行有效無需回呼的僅限寫入交易。換句話說,如果開發人員只是將資料寫入 IndexedDB,而非執行包含讀取和寫入作業的複雜交易,commit() 方法就能在工作佇列暫停前完成 (假設 IndexedDB 資料庫已開啟)。

不過,如果程式碼需要在現階段運作,開發人員有兩種做法:

  • 使用工作階段儲存空間:工作階段儲存空間 是同步的,且會在網頁丟棄時保留。
  • 透過 Service Worker 使用 IndexedDB:當頁面終止或捨棄後,Service Worker 就能將資料儲存在 IndexedDB 中。在 freezepagehide 事件監聽器中,您可以透過 postMessage() 將資料傳送至 Service Worker,而服務工作站可以處理儲存資料。

在凍結和捨棄狀態測試應用程式

如要測試應用程式在凍結和捨棄狀態中的行為,您可以前往 chrome://discards 實際凍結或捨棄任何開啟的分頁。

Chrome 捨棄 UI
Chrome 捨棄 UI

這可讓您確保網頁在棄用後重新載入時,正確處理 freezeresume 事件,以及 document.wasDiscarded 標記。

摘要

如果開發人員想尊重使用者裝置的系統資源,應在建構應用程式時考量頁面生命週期狀態。網頁在使用者意料之外的情況下,不得消耗過多的系統資源

越多開發人員開始實作新的網頁生命週期 API,瀏覽器凍結及捨棄未使用的網頁就會越安全。這表示瀏覽器會耗用較少的記憶體、CPU、電池和網路資源,對使用者來說是雙贏。