網頁生命週期 API

瀏覽器支援

  • 68
  • 79
  • x
  • x

當系統資源受限時,新式瀏覽器有時會將頁面暫停或完全捨棄。未來瀏覽器會想要主動執行這項操作,以減少耗電量和記憶體用量。Page Lifecycle API 提供生命週期掛鉤,讓您的網頁可以安全地處理這些瀏覽器幹預作業,而不會影響使用者體驗。請查看 API,瞭解是否應該在應用程式中實作這些功能。

背景

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

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

雖然網路平台長久都有與生命週期狀態相關的事件 (例如 loadunloadvisibilitychange),但這些事件只允許開發人員回應使用者啟動的生命週期狀態變更。為了讓網頁在低耗電裝置上 (以及在所有平台上較注重資源) 上穩定運作,瀏覽器需要設法主動收回及重新分配系統資源。

事實上,瀏覽器目前已採取主動措施,針對背景分頁的網頁節省資源,且許多瀏覽器 (尤其是 Chrome) 也想做更多事情,以便減少整體資源用量。

問題是,開發人員無法針對這類系統啟動的介入措施做好準備,甚至不知道會發生什麼情況。這表示瀏覽器需要保守,否則網頁可能會破壞。

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

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

這項解決方案可讓網頁程式開發人員建構能因應系統介入情形的應用程式,並讓瀏覽器更積極地最佳化系統資源,最終讓所有網路使用者受益。

本文的其餘部分將介紹新的頁面生命週期功能,並探索這些功能與所有現有網路平台狀態和事件之間的關係。還會針對各狀態的開發人員應 (而非不應) 採取哪些工作類型,提供建議和最佳做法。

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

所有頁面生命週期狀態皆為獨立且互斥,這表示網頁一次只能處於一個狀態。此外,對於頁面生命週期狀態所做的大部分變更,通常可透過 DOM 事件觀測 (如要瞭解例外狀況,請參閱各狀態的開發人員建議)。

要解釋頁面生命週期狀態,以及指出頁面生命週期之間轉換的事件,最簡單的方式就是請參考圖表:

頁面生命週期 API 狀態和事件流程。以視覺化方式呈現本文件中說明的狀態與事件流程。

狀態

下表詳細說明瞭各種狀態。還會列出前後可能會出現的狀態,以及開發人員可用來觀察變更的事件。

狀態 說明
進行中

如果網頁顯示並具有輸入焦點,就處於「有效」狀態。

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

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

被動式

如果網頁顯示,且沒有輸入焦點,就會處於「被動」狀態。

可能的狀態:
有效 (透過 blur 事件)
隱藏 (透過 visibilitychange 事件)
凍結 (透過 resume 事件,然後 pageshow

可能的後續狀態:
有效 (透過 focus 事件)
隱藏 (透過 visibilitychange 事件)

隱藏

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

可能的狀態:
被動 (透過 visibilitychange 事件)
凍結 (透過 resume 事件,然後是 pageshow 事件)

可能的下一個狀態:
被動 (透過 visibilitychange 事件)
凍結 (透過freeze事件)
隱含 (未觸發任何事件)
已觸發

凍結

處於「凍結」狀態時,瀏覽器會暫停在頁面 工作佇列 可凍結 工作,直到頁面解除凍結為止。也就是說,JavaScript 計時器和擷取回呼等項目不會執行。已執行的工作可以完成 (最重要的是 freeze 回呼),但其功能和執行時間長度可能有限。

瀏覽器凍結網頁是為了節省 CPU/電池/數據用量,但同時也是為了加快 往返瀏覽的速度,因此不需要重新載入整頁。

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

可能的後續狀態:
有效 (透過 resume 事件,然後是 pageshow 事件)
被動 pageshow 事件)

2}

resume

已終止

網頁開始從記憶體中卸載並清除後,處於「已終止」狀態。沒有任何 新工作可以啟動此狀態,而進行中的工作如果執行時間過長,可能會遭終止。

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

可能的後續狀態:
NONE

已捨棄

網頁從瀏覽器卸載時,為了節省資源,網頁會處於「捨棄」狀態。任何種類的工作、事件回呼或 JavaScript 都無法在這個狀態下執行,因為捨棄作業通常會在資源限制下執行,而無法啟動新程序。

在「捨棄」狀態中,即使網頁已不存在,使用者通常仍可看到分頁本身 (包括分頁標題和網站小圖示 )。

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

可能的後續狀態:
NONE

活動

瀏覽器會發送大量事件,但其中只有一小部分表示網頁生命週期狀態可能發生變化。下表概略說明與生命週期相關的所有事件,並列出這些事件可能在哪些狀態下轉換或傳輸。

名稱 詳細說明
focus

DOM 元素收到焦點。

注意:focus 事件不一定表示狀態變更。只有在網頁先前沒有輸入焦點時,這個狀態才會發出狀態變更的信號。

可能先前的狀態:
被動

可能的目前狀態:
有效

blur

DOM 元素已失去焦點。

注意:blur 事件不一定表示狀態變更。只有在網頁不再有輸入焦點時 (也就是網頁並非只是將焦點從一項元素切換到另一個元素),系統才會發出狀態變更通知。

可能先前的狀態:
有效

可能的目前狀態:
被動

visibilitychange

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

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

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

freeze *

網頁剛剛凍結。頁面工作佇列中的任何 可凍結工作並不會啟動。

可能先前的狀態:
已隱藏

可能的目前狀態:
凍結

resume *

瀏覽器已恢復凍結網頁。

可能先前的狀態:
凍結

可能的目前狀態:
active (如果後面接著 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 一節。

可能先前的狀態:
已隱藏

可能的目前狀態:
已終止

* 表示 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 物件現在也包含 wasDiscarded 屬性。如要判斷網頁是否已在隱藏分頁中遭到捨棄,您可以在頁面載入時檢查這個屬性的值 (注意:系統必須重新載入捨棄的網頁才能再次使用)。

if (document.wasDiscarded) {
  // Page was previously discarded by the browser while in a hidden tab.
}

如要瞭解 freezeresume 事件中有哪些重要措施,以及如何處理和準備捨棄網頁,請參閱「各狀態的開發人員建議」。

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

觀察程式碼中的網頁生命週期狀態

主動被動隱藏狀態中,您可以執行 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

「主動」狀態是對使用者來說最重要的時間,因此決定網頁是否 回應使用者輸入內容最重要的時間。

凡是可能會封鎖主執行緒的非 UI 工作,都應優先降低為 閒置期間 卸載至網路工作站

Passive

處於「被動」狀態中,使用者並未與網頁互動,但使用者仍可看見網頁。這表示 UI 更新和動畫應仍保持流暢,但執行更新的時間較不重要。

當頁面從「active」變更為「被動」時,是保留未儲存應用程式狀態的好時機。

Hidden

當頁面從「被動」變更為「隱藏」時,使用者可能要重新載入頁面,才能再次與網頁互動。

此外,轉換為「隱藏」狀態通常也是開發人員可放心觀察的最後一個狀態變更 (特別是在行動裝置上,因為使用者可以關閉分頁或瀏覽器應用程式本身,但在這種情況下,系統不會觸發 beforeunloadpagehideunload 事件)。

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

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

Frozen

處於「凍結」狀態時, 工作佇列中的 可凍結工作會暫停,直到頁面解除凍結為止,而且可能永遠不會發生 (例如捨棄網頁)。

因此,當網頁從「已隱藏」變更為「凍結」時,請務必停止所有計時器或拆解任何連線,即使連線凍結,也可能會影響同一來源中開啟的其他分頁,或影響瀏覽器將頁面放進 往返快取的功能。

請特別注意以下幾點:

此外,如果您之後捨棄網頁並重新載入,應將所有動態檢視狀態 (例如捲動位置) 保留在 sessionStorage (或透過 commit() 建立索引的資料庫) 以便還原。

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

Terminated

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

由於使用者動作導致頁面卸載後,一律會經過隱藏狀態才進入「已終止」狀態,因此「隱藏」狀態是指工作階段結束邏輯 (例如持續顯示應用程式狀態並回報至數據分析)。

另請注意,如「隱藏」狀態的建議所述,開發人員必須瞭解,在許多情況下 (特別是行動裝置),系統無法穩定偵測到轉換至「已終止」狀態的開發人員,因此仰賴終止事件 (例如 beforeunloadpagehideunload) 的開發人員可能會失去資料。

Discarded

如果捨棄網頁,開發人員無法觀察「捨棄」狀態。這是因為網頁通常會在資源限制下遭到捨棄,而為了回應捨棄事件而取消凍結網頁,單純是不可能達成這個目標的指令碼。

因此,您應做好準備,從「已隱藏」變更為「凍結」的原因可能會捨棄變更,然後在頁面載入時檢查 document.wasDiscarded,針對已捨棄的網頁還原這項操作。

同樣地,由於生命週期事件的可靠性和順序在所有瀏覽器中並未一致實作,因此按照上表建議最簡單的方式,就是使用 PageLifecycle.js

避免使用舊版生命週期 API

卸載事件

許多開發人員會將 unload 事件視為保證回呼,並將該事件當做工作階段結束信號,以便儲存狀態及傳送數據分析資料,但這種事件極為不可靠,特別是在行動裝置上!許多一般卸載的情況下不會觸發 unload 事件,包括透過行動裝置的分頁切換工具關閉分頁,或是透過應用程式切換器關閉瀏覽器應用程式。

因此,建議您一律使用 visibilitychange 事件來判斷工作階段結束時間,並考量儲存應用程式和使用者資料的最後一個可靠時間

此外,如果只有已註冊的 unload 事件處理常式 (透過 onunloadaddEventListener()),瀏覽器就無法將網頁放入往返快取,以加快往返載入的速度。

在所有新版瀏覽器中,建議您一律使用 pagehide 事件來偵測可能的網頁卸載 (即「已終止」狀態),而非 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);
});

常見問題

為什麼沒有出現「載入中」狀態?

頁面生命週期 API 將狀態定義為獨立且互斥的狀態。由於網頁可以在「有效」、「被動」或「隱藏」狀態下載入,而且由於網頁可能會在載入完成前變更狀態,甚至可能終止,因此這種模式並不合理。

我的網頁在隱藏後確實有作用,該如何阻止網頁凍結或遭到捨棄?

有許多合理原因可能導致網頁在隱藏狀態執行時不應凍結。最顯而易見的應用程式就是會播放音樂的應用程式。

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

Chrome 會在目前捨棄網頁時較為保守,而且只在確定不會影響使用者的情況下才會這麼做。舉例來說,如果系統在隱藏狀態下觀察到有以下行為的網頁,除非有極度資源限制,否則系統不會捨棄網頁:

  • 正在播放音訊
  • 使用 WebRTC
  • 更新表格標題或網站小圖示
  • 顯示快訊
  • 傳送推播通知

如要瞭解目前用於判斷分頁是否安全凍結或捨棄的清單功能,請參閱 Chrome 的凍結與捨棄經驗法則

什麼是往返快取?

「往返快取」這個術語是用來描述某些瀏覽器實作的導覽最佳化,可以加快使用返回和前進按鈕的速度。

當使用者離開某個網頁時,這些瀏覽器會凍結該網頁的版本,當使用者使用返回或向前按鈕返回瀏覽時,瀏覽器會凍結該網頁版本。請記住,新增 unload 事件處理常式會造成無法執行最佳化作業

無論是何種意圖和用途,這種凍結功能與為了節省 CPU/電池用量相同,執行凍結瀏覽器的效果都相同;因此,此凍結功能被視為「凍結」生命週期狀態的一部分。

如果無法在凍結或已終止的情況下執行非同步 API,如何將資料儲存至索引資料庫?

在凍結和終止狀態下,頁面工作佇列中的可凍結工作會暫停,這表示無法穩定使用非同步和回呼式 API (例如索引資料庫)。

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

但對於目前需要執行的程式碼,開發人員可採用以下兩種方式:

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

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

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

Chrome 捨棄 UI

這可讓您確保自己的網頁正確處理 freezeresume 事件,以及在網頁捨棄後重新載入的 document.wasDiscarded 旗標。

摘要

開發人員若想遵循使用者裝置的系統資源,在建構應用程式時應將頁面生命週期狀態納入考量。網頁未消耗過多系統資源 是非常重要的事

隨著開發人員開始導入新的 Page Lifecycle API,瀏覽器越安全,也會捨棄未使用的頁面。這表示瀏覽器會耗用較少記憶體、CPU、電池和網路資源,這樣對使用者而言可大有好處。