1. 程式人生 > >新一代網頁生命週期API

新一代網頁生命週期API

背景

在 Android,iOS 和較新的 Windows 平臺上,作業系統對 App 有啟動和運用的許可權,這些平臺會合理地為應用分配資源。在 Web 平臺,很早就有了生命週期的概念(如 load,unload,visibilitychange),但還不是很完善。現代瀏覽器正在逐步的優化當中。

概覽網頁生命週期和狀態

注:Chrome 68 已經引入了新的生命週期特性了。

狀態包括:

  • active:頁面獲取焦點時的狀態。
  • passive:頁面失去焦點時的狀態。
  • hidden:切換到別的 tab 頁時的狀態
  • frozen:瀏覽器停止執行可凍結的事件,比如 js 計時器和 fetch 的回撥,都不會再進行了。這是一種節約資源的手段。
  • terminated:頁面一旦開始 unload,並從記憶體中被瀏覽器清理掉,就是被 terminated(終結)了。
  • discarded:丟棄

事件包括(橙色為新出的 api):

  • load:網頁載入觸發的事件
  • focus:網頁元素獲取焦點觸發的事件
  • blur:網頁元素失去焦點觸發的事件
  • visibilitychange:網頁空間狀態變更觸發的事件。
  • freeze:任務不會再執行。
  • resume:瀏覽器重新啟動了一個凍結的頁面。
  • pageshow:網頁被顯示
  • pagehide:網頁隱藏
  • beforeunload:網頁解除安裝之前觸發的事件。僅僅用於提醒使用者別忘了儲存,不可濫用!
  • unload:網頁解除安裝觸發的事件。永遠不要使用這個事件。
document.addEventListener('freeze', (event) => {
  // The page is now frozen.
});
document.addEventListener('resume', (event) => {
  // The page has been unfrozen.
});
if (document.wasDiscarded) {
  // Page was previously discarded by the browser while in a hidden tab.
}

上面這些事件中,load / pageshow / beforeunload  / pagehide / unload  在 window 上觸發;visibilitychange / freeze / resume 在 document 上觸發;focus / blur 在對應的 dom 元素上觸發。

檢測生命週期

可以封裝一個函式來判斷網頁當前的生命週期狀態。

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

監聽生命週期事件

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

    let state = getState();
  
    const logStateChange = (nextState, type) => {
        const prevState = state;
        if (nextState !== prevState) {
            console.log(`type: ${type}, State change: ${prevState} >>> ${nextState}`);
            state = nextState;
        }
    };
   
    ['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach((type) => {
        window.addEventListener(type, () => logStateChange(getState(), type), {capture: true});
    });
   
    window.addEventListener('freeze', () => {
        logStateChange('frozen');
    }, {capture: true});
    window.addEventListener('pagehide', (event) => {
        if (event.persisted) {
            logStateChange('frozen');
        } else {
            logStateChange('terminated');
        }
    }, {capture: true});

上述程式碼為什麼要在捕獲階段監聽呢?主要有以下幾個原因:

  • 這些事件沒有共同的事件觸發物件。
  • 大部分事件都不會冒泡。
  • 捕獲階段在 target / 冒泡階段之前,所以在這裡加入監聽保證了它們會在其它可能取消這一事件的程式碼前執行。

跨瀏覽器差異

各種瀏覽器對上述 API 的實現還存在差異,例如:

  • 一些瀏覽器在切換標籤頁的時候不會觸發 blur 事件。這意味著一個頁面可能直接由 active 狀態變為了 hidden 狀態,跳過了 passive 狀態。
  • freeze 和 resume 事件沒有被完全支援。
  • IE10 不支援 visibilitychange 事件。
  • 以前的瀏覽器,visibilitychange 在 pagehide 之後觸發,而 Chrome 無視了 document 在 unload 的可見狀態,先觸發 visibilitychange 事件,再觸發 pagehide 事件。

這一切都可以通過一個 js 庫來解決:PageLifecycle.js

開發者應該在各個 state 做什麼事

active:響應使用者輸入行為的最重要時機。任何會阻礙主執行緒的非UI行為應該放到這之後來做。

passive: 在 passive 狀態使用者沒有跟頁面互動,但頁面仍然可見。這意味著UI的更新和動畫仍然應該流暢進行,但更新的時機就沒那麼重要了。頁面從 active 變到 passive 也是去儲存應用狀態的最佳時機。

hidden:這可能是開發者能可靠地檢測到的最後一次狀態改變了,因為使用者可能直接關閉了瀏覽器或應用。諸如 beforeunload,pagehide,unload 事件,在這種情況下都不會被觸發了。因此應該把 hidden 狀態當做使用者 session 的結束點。換句話說,持久化那些未被儲存的應用狀態,併發送調查資料。停止UI更新和任何使用者不希望在後臺執行的任務。

frozen:可以被凍結的任務都會被暫停,直到頁面解凍(可能永遠都不會解凍)。應該阻止任何的計時器,切斷可能會影響其他開啟的同源Tab的連線。具體來說,需要:

  • 關閉所有開啟的 IndexedDB 的連線。
  • 關閉所有開啟的 BroadcasrChannel的連線。
  • 關閉所有啟用態的webRTC連線。
  • 關閉所有的Web Socket連線。
  • 釋放所有可能拿著的Web Locks。
  • 持久化動態的檢視狀態(如滾動高度)到sessionStorage或IndexedDB。

當頁面從凍結態返回到 hidden 狀態時,重連上述連線。

terminated:不做任何事!beforeunload,pagehide,unload 都不能被可靠地監聽到。

discarded:對開發者不可見。可以在一個被丟棄的頁面重新載入的時候檢測 document.wasDiscarded。

避免使用廢棄的生命週期 API

unload:宜用 visibilitychange 事件取代來判斷何時 session 終止,用 hidden 狀態作為最後儲存應用和使用者資料的可靠之機。

beforeunload:和 unload 事件有同樣的問題,會阻止瀏覽器在 page navigation cache 中快取頁面。僅當提示使用者還有未儲存的變化時呼叫,並且在儲存後立即移除。

正確操作:

const beforeUnloadListener = (event) => {
  event.preventDefault();
  return event.returnValue = 'Are you sure you want to exit?';
};
// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
  addEventListener('beforeunload', beforeUnloadListener, {capture: true});
});
// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
  removeEventListener('beforeunload', beforeUnloadListener, {capture: true});
});

注:PageLifecycle.js 庫已經提供了addUnsavedChanges() 和 removeUnsavedChanges() 方法。

測試你的網頁或app的frozen和discarded狀態

開啟chrome://discards/來真正嘗試一下凍結和丟棄開啟的標籤頁是怎麼回事兒吧~

同時還可以看看document.wasDiscarded的值是否跟預期一致。

FAQs

1. 我的頁面要在hidden時仍然工作,怎麼阻止它被frozen或者discarded呢(比如音樂類APP)?

chrome只會在確保安全時凍結或丟棄它。在有以下資源使用時則不會:

  • 播放音視訊
  • 使用WebRTC
  • 更新表頭或favicon
  • 彈alert
  • 傳送push notificatoins

2. 什麼是 page navigation cache(頁面導航快取)?

這是一個通用名詞,用來描述瀏覽器對頁面導航的優化,讓前進後退按鈕更加快捷。webkit把它叫做page cache,火狐則將其稱之為Back-Forwards Cache。當導航離開時這些瀏覽器會凍結當前頁面以節約cpu和電量,因此在前進後退再進入這個頁面的時候,可以重新resume。新增beforeunload和unload事件監聽器都會阻止瀏覽器所做的優化。

3. 如果我不能在凍結態和終止態去執行非同步的api,那我怎麼把資料存到IndexedDB呢?

這確實是個問題。在frozen和terminated狀態,可凍結的任務會被暫停,所以非同步的回撥都不能保證可靠。

未來會在IDBTransaction加入commit()方法,保證開發者可以執行不需要回調的只寫型事務。也就是說,如果不需要讀,commit方法可以在任務佇列被暫停前完成。

目前,開發者還有這兩種選擇:

  • 使用session storage,這是同步的,頁面被丟棄也會持久化。
  • 用service worker寫入IndexedDB。可以在freeze/pagehide事件監聽器上通過postMessage()給service worker傳送資料,讓後者來完成。但當記憶體壓力較大的時候,不建議使用後者。