你不知道的 web 生命週期

原文連接javascript

背景

最近作 web 性能採集分析,一直以爲跟用戶交互無關的採集都放在 onLoadDOMContentLoaded 中很不合理。 一番搜索,發現 web 頁面也是有生命週期的。一番研究,解決了如何避免干擾用戶採集信息的困惑。 W3C 最新的規範 Page Lifecycle, 提供了一系列的生命週期鉤子函數,方便開發者可以在不干擾用戶交互的狀況下監聽處理一些操做。html

問題:如何利用生命週期優雅的處理上報分析數據,既能保證在某些場景下不漏報,又能儘量少的干擾用戶?java

概要

應用程序生命週期是現代操做系統管理資源的關鍵方法。在移動iOS、Android和最新的桌面系統中, apps 在任什麼時候候都能被 OS 啓動或關閉,生命週期使得這些系統 streamline(流線型,使增產節約),從新分配資源更加合理高效,極大的優化了用戶的體驗。git

歷史上,web 並無生命週期的概念,致使 web 應用能夠一直存活佔用系統資源。瀏覽器打開大量的 Tab 頁, 關鍵系統資源如內存、CPU、電池和網絡被過分佔用而沒法釋放,致使系統卡頓。例如老版本的 Chrome 雖然性能 在當時的瀏覽器單頁執行對比中一直是翹楚,但開多了頁面,特別吃內存,得益於生命週期,能夠合理的回收內存。github

而 web 平臺長期以來都有與生命週期狀態相關的事件,如 loadunloadvisibilitychange, 這些事件容許開發者監聽生命週期狀態的改變。對於移動設備特別是一些低端機型,瀏覽器須要一種主動回收內存和從新分配內存的方式。web

事實上,如今的瀏覽器已經採起了積極的措施來節省後臺標籤頁的資源,許多瀏覽器但願作更多的事情來減小它們的資源佔用。ajax

問題是開發人員目前沒有辦法爲這些類型的系統啓動干預作好準備,甚至沒法知道它們正在發生。這意味着瀏覽器須要保守,不然就有可能破壞網頁。chrome

Page Lifecycle API 試圖經過如下方式解決這些問題:api

  1. 在web上引入並標準化生命週期狀態的概念。
  2. 定義新的系統啓動狀態,容許瀏覽器限制隱藏或非激活選項卡可以使用的資源。
  3. 建立新的 APIs 和事件,容許 web 開發人員響應這些新的系統啓動狀態之間的轉換。

該解決方案提供了web開發人員構建對系統干預具備彈性的應用程序所需的可預測性,並容許瀏覽器更積極地優化系統資源,最終使全部web用戶受益。瀏覽器

本文的將介紹新的頁面生命週期特性,並探討它們與全部現有web平臺狀態和事件的關係。它還將爲開發人員在每一個狀態下應該(和不該該)作的工做類型提供建議和最佳實踐。

生命週期狀態與事件

全部頁面生命週期狀態都是離散和互斥的,這意味着一個頁面一次只能處於一個狀態。頁面生命週期狀態的大多數更改一般均可以經過DOM事件觀察到(關於異常,請參見開發人員對每一個狀態的建議)。

生命週期狀態轉變以及觸發的事件

page-lifecycle-api-state-event-flow

狀態

狀態 描述 可能前一個的狀態(觸發事件) 可能下一個狀態(觸發事件)
Active 頁面可見document.visibilityState === 'visible' 而且有 input focus 1. passive (focus) 1. passive (blur)
Passive 頁面可見且沒有input 處於 focus 1. active (blur)
2. hidden (visibilitychange)
1. active (focus)
2. hidden (visibilitychange)
Hidden 頁面不可見document.visibilityState === 'hidden'且不被凍結 1. passive (visibilitychange) 1. passive (the visibilitychange)
2. frozen (freeze)
3. terminated (pagehide)
Frozen frozen狀態瀏覽器會掛起任務隊列中可凍結任務的執行,這意味着例如 JS timerfetch回調不會執行。正在執行的任務能被完成,可是可執行的操做和運行的時間會被限制。

瀏覽器凍結是爲了節約 CPU、內存、電量的消耗。同時使前進後退更加快速,避免從網絡從新加載全量頁面
1. hidden (freeze) 1. active (resume -> pageshow)
2. passive (resume -> pageshow)
3. hidden (resume)
Terminated terminated狀態表示瀏覽器已卸載頁面並回收了資源佔用,不會有新的任務執行,已運行的長任務可能會被清除。 1. hidden (pagehide)
Discarded discarded狀態發生在系統資源受限,瀏覽器會主動卸載頁面釋放內存等資源用於新進/線程。該狀態下任何任務、事件回調或任何類型的JS都沒法執行。儘管頁面不在了,但瀏覽器 Tab 頁的標籤名和 favicon用戶仍可見 1. frozen (no events fired)

事件

下面描述了與生命週期相關的全部事件,並列出了它們可能轉換的狀態。

focus

  • 描述:DOM元素獲取焦點
  • 前一個可能狀態
  1. passive
  • 當前可能狀態
  1. active
  • 注意:focus 事件並不總觸發生命週期狀態改變,只有在頁面以前並無聚焦纔會發生改變。

blur

  • 描述:DOM元素失去焦點
  • 前一個可能狀態
  1. active
  • 當前可能狀態
  1. passive
  • 注意:blur 事件並不總觸發生命週期狀態改變,只有在頁面再也不獲取焦點纔會發生改變。例如在頁面元素之間切換焦點就不會。

visibilitychange

  • 描述:document.visibilityState 值變化。觸發場景:
  1. 刷新或導航到新頁面
  2. 切換到新 Tab 頁面
  3. 關閉 Tab、最小化、或關閉瀏覽器
  4. 移動端切換 app,如按了 Home 鍵,點擊頭部通知切換等
  • 前一個可能狀態
  1. passive
  2. hidden
  • 當前可能狀態
  1. passive
  2. hidden

freeze *

  • 描述:頁面被凍結,任務隊列中的可凍結任務都不會執行。
  • 前一個可能狀態
  1. hidden
  • 當前可能狀態
  1. frozen

resume *

  • 描述:瀏覽器重啓了一個被凍結的頁面
  • 前一個可能狀態
  1. frozen
  • 當前可能狀態
  1. active (if followed by the pageshow event)
  2. passive (if followed by the pageshow event)
  3. hidden

pageshow

  • 描述:檢索頁面導航緩存是否存在,存在則從緩存中取出,不然加載一個全新的頁面。 若是頁面是從導航緩存中取出,則事件屬性 persisted 爲 true,反之爲 false。
  • 前一個可能狀態
  1. frozen (此時 resume 事件也會觸發)
  • 當前可能狀態
  1. active
  2. passive
  3. hidden

pagehide

  • 描述:頁面會話是否可以存入導航緩存。若是用戶導航到另外一個頁面,而且瀏覽器可以將當前頁面添加到頁面導航緩存以供之後重用 ,則事件屬性 persisted 爲true。若是爲true,則頁面將進入 frozen 狀態,不然將進入 terminated 狀態。
  • 前一個可能狀態
  1. hidden
  • 當前可能狀態
  1. frozen (event.persisted is true, freeze event follows)
  2. terminated (event.persisted is false, unload event follows)

beforeunload

  • 描述:當前頁面即將被卸載。此時當前頁面文檔內容仍然可見,關閉頁面能夠在該階段取消。
  • 前一個可能狀態
  1. hidden
  • 當前可能狀態
  1. terminated
  • 警告:監聽 beforeunload 事件,僅用來提醒用戶有未保存的數據改變,一旦數據保存完成,該監聽事件回調應該移除。 不該該無條件地將它添加到頁面中,由於這樣作在某些狀況下會損害性能。

unload

  • 描述:頁面正在被卸載。
  • 前一個可能狀態
  1. hidden
  • 當前可能狀態
  1. terminated
  • 警告:不建議監聽使用 unload 事件,由於它不可靠,在某些狀況下可能會影響性能。
  • 表示生命週期定義的新事件。

新特性

frozen 和 discarded 是系統行爲而不是用戶主動行爲,現代瀏覽器在標籤頁不可見事,可能會主動凍結或廢棄當前頁。 開發人員並不能知道這二者的發生過程。

Chrome 68+ 中提供了freeze、resume 事件,當頁面從 hidden 狀態轉變爲凍結和非凍結狀態,開發人員能夠監聽 document 得知。

document.addEventListener('freeze', (event) => {
  // The page is now frozen.
});

document.addEventListener('resume', (event) => {
  // The page has been unfrozen.
});
複製代碼

而且提供了 document.wasDiscarded 屬性來獲取當前加載的頁面,以前是否非可見時被廢棄過。

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

代碼觀察生命週期狀態

獲取 activepassivehidden

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

frozenterminated 狀態須要監聽 freezepagehide 事件獲取。

// 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;
  }
};

// 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()), {capture: true});
});

// 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');
}, {capture: true});

window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    // If the event's persisted property is `true` the page is about
    // to enter the page navigation cache, which is also in the frozen state.
    logStateChange('frozen');
  } else {
    // If the event's persisted property is not `true` the page is
    // about to be unloaded.
    logStateChange('terminated');
  }
}, {capture: true});
複製代碼

上面代碼作了三件事:

  1. getState() 初始化狀態
  2. 定義 logStateChange 函數接收下一個狀態,如改變則 console
  3. 監聽 捕獲階段 事件,一次調用 logStateChange ,傳入狀態改變。

注意:上述 console 打印的順序在不一樣的瀏覽器中可能不一致。

  • 爲何經過傳入第三個參數 {capture: true} 且都在 window 上監聽事件
  1. 並非全部生命週期事件都有相同的 target
    1. pagehidepageshowwindow 上觸發
    2. visibilitychange, freeze, resumedocument 上觸發
    3. focusblur 在相應的 DOM 元素上觸發
  2. 大多數事件並不會冒泡,這意味着在冒泡階段,只經過監聽 window 沒法實現
  3. 捕獲階段發生在 target 階段和冒泡階段,這意味着捕獲階段事件不會被其餘冒泡事件取消

跨瀏覽器兼容

因爲生命週期API剛剛被引入,新的事件和DOM api並無在全部瀏覽器中實現。此外,全部瀏覽器實現並不一致。 例如:

  1. 一些瀏覽器切換 Tab 時,不會觸發 blur 事件,意味着 active 狀態不通過 passive 狀態而直接變成了 hidden
  2. 一些瀏覽器雖然實現了 page navigation cachePage Lifecycle API 把緩存的頁面分類爲凍結狀態, 可是尚未實現freezeresume 等最新的 API,雖然非/凍結狀態也能夠經過 pageshowpagehide 事件監聽到。
  3. IE 10 以及如下版本未實現 pagehide 事件
  4. pagehidevisibilitychange 觸發順序已改變。 當頁面正在被卸載時,若是頁面可見,會先觸發 pagehide 在觸發 visibilitychange。 最新版本的 Chrome ,不管頁面是否可見都會先觸發 visibilitychange 在觸發 pagehide
  5. Safari 關閉 Tab 頁可能不會觸發 pagehidevisibilitychange。須要監聽 beforeunload 來作兼容, beforeunload 須要在冒泡階段結束才能知道狀態是否變成 hidden,所以容易被其餘事件取消。

推薦使用PageLifecycle.js,確保跨瀏覽器的一致性。

每一個狀態的建議

做爲開發人員,理解頁面生命週期狀態並知道如何在代碼中觀察它們很重要,由於您應該(也不該該)執行的工做類型在很大程度上取決於您的頁面處於什麼狀態。

例如,若是頁面處於不可見狀態,則向用戶顯示臨時通知顯然沒有意義。雖然這個例子很明顯,但還有一些不太明顯的建議值得列舉。

狀態 建議
Active 該狀態是對用戶來講最重要的階段,此時最重要的就是響應用戶輸入。長時間阻塞主線程的非no-UI任務能夠交給idle時期或web worker處理
Passive 該狀態下,用戶沒有與頁面交互,可是他們仍然能夠看到它。這意味着UI更新和動畫應該仍然是平滑的,可是這些更新發生的時間不那麼關鍵。當頁面從 active 變爲 passive 時,是存儲未保存數據的好時機。
Hidden passive 轉變爲 hidden,用戶頗有可能再也不與頁面交互直到從新加載。

hidden 狀態每每是開發人員能夠信賴的最後狀態,尤爲在移動端,例如切換 APP 時beforeunloadpagehideunload 事件都不會觸發。

這意味着,對於開發人員應該把 hidden 狀態當成是頁面會話的最終狀態。在此時應該持久化未保存的應用數據,採集上報分析數據。

同時,你應該中止UI更新,由於用戶已經看不到了。也該中止那些用戶並不想在後臺執行的任務,節省電量等資源。
Frozen frozen 狀態,任務隊列可凍結的任務會被掛起,直到頁面解凍(也許永遠不會發生,例如頁面被廢棄discarded)。

此時有必要中止全部的timer和關閉鏈接(IndexedDBBroadcastChannelWebRTCWeb Socket connections。釋放Web Locks),不該該影響其餘打開的同源頁面或影響瀏覽器把頁面存入緩存(page navigation cache)。

你也應該持久化動態視圖信息(例如無限滑動列表的滑動位置)到 sessionStorage或IndexedDB via commit(),以便discarded 和 reloaded以後重用。

當狀態從新變回 hidden 時您能夠從新打開任何關閉的鏈接,或從新啓動最初凍結頁面時中止的任何輪詢。
Terminated 當頁面變成 terminated 狀態,開發人員通常不須要作任何操做。由於用戶主動卸載頁面時總會在 terminated 以前經歷 hidden 狀態(頁面刷新和跳轉時不必定會觸發 visibilitychange,少部分瀏覽器實現了,大部分可能須要 pagehide 甚至beforeunloadunload 來彌補這些場景),你應該在 hidden 狀態執行頁面會話的結束邏輯(持久化存儲、上報分析數據)。

開發人員必須認識到,在許多狀況下(特別是在移動設備上),沒法可靠地檢測到終止狀態,所以依賴終止事件(例如beforeunloadpagehideunload)可能會丟失數據。
Discarded 開發人員沒法觀察到被廢棄的狀態。由於一般在系統資源受限下被廢棄,在大多數狀況下,僅僅爲了容許腳本響應discard事件而解凍頁面是不可能的。所以,不必從hidden更改成frozen時作處理,能夠在頁面加載時檢查 document.wasDiscarded,來恢復以前被廢棄的頁面。

避免使用老舊的生命週期API

  • unload,不要在現代瀏覽器中使用
  1. 不少開發人員會把 unload 事件當作頁面結束的信號來保存狀態或上報分析數據,但這樣作很是不可靠,特別是在移動端。 unload 在許多典型的卸載狀況下不會觸發,例如經過移動設備的選項卡切換、關閉頁面或系統切換器切換、關閉APP。
  2. 所以,最好依賴 visibilitychange 事件來肯定頁面會話什麼時候結束,並將 hidden 狀態視爲最後保存應用和用戶數據的可靠時間。
  3. unload 會阻止瀏覽器把頁面存入緩存(page navigation cache),影響瀏覽器前進後退的快速響應。
  4. 在現代瀏覽器(包括IE11),推薦使用 pagehide 事件代替 onload 監測頁面卸載(terminated)。onload 最多用來兼容IE10。
const terminationEvent = 'onpagehide' in self ? 'pagehide' : 'unload';

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.
}, {capture: true});
複製代碼
  • beforeunload,和 unload 有相似的問題,僅僅用來提醒用戶關閉或跳轉頁面時有未保存的數據,一旦保存當即清除。
// bad:無條件使用
addEventListener('beforeunload', (event) => {
  // A function that returns `true` if the page has unsaved changes.
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = 'Are you sure you want to exit?';
  }
}, {capture: true});
複製代碼
// good
const beforeUnloadListener = (event) => {
  event.preventDefault();
  return event.returnValue = 'Are you sure you want to exit?';
};
const unsavedChanges = [];
/** * @param {Symbol|Object} id A unique symbol or object identifying the *. pending state. This ID is required when removing the state later. */
function addUnsavedChanges(id) {
  if(unsavedChanges.indexOf(id) > -1) return; // 重複退出
  if (unsavedChanges.length === 0) { // 首次監聽
    addEventListener('beforeunload', onbeforeunload);
  }
  unsavedChanges.push(id);
}
/** * @param {Symbol|Object} id A unique symbol or object identifying the *. pending state. This ID is required when removing the state later. */
function removeUnsavedChanges(id) {
  const idIndex = unsavedChanges.indexOf(id);
  if (idIndex > -1) {
    unsavedChanges.splice(idIndex, 1);
    // If there's no more pending state, remove the event listener.
    if (unsavedChanges.length === 0) {
      removeEventListener('beforeunload', onbeforeunload);
    }
  }
}
複製代碼

FAQs

  • 頁面不可見(hidden)時有重要的任務在執行,如何阻止頁面被凍結(frozen)或廢棄(discarded)?

有不少合理的理由在頁面不可見(hidden)狀態不凍結(frozen)頁面,例如APP正在播放音樂。

對於有些場景,瀏覽器放棄頁面也存在風險,例如用戶有未提交的輸入或開發人員監聽了beforeunload事件以便提醒用戶。

所以,瀏覽器策略會趨於保守,只有在明確不會影響用戶的時候纔會放棄頁面。例如如下場景不會廢棄頁面(除非受到設備的資源限制)。

  1. Playing audio
  2. Using WebRTC
  3. Updating the table title or favicon
  4. Showing alerts
  5. Sending push notifications

注意:對於更新標題或favicon以提醒用戶未讀通知的頁面,建議使用 service worker,這將容許Chrome凍結或放棄頁面,但仍然顯示對選項卡標題或favicon的更改。

  • 什麼是頁面導航緩存(page navigation cache)?

頁面導航緩存是一個通用術語,用於優化後退和前進按鈕導航,利用緩存快速恢復先後頁面。Webkit 稱 Page CacheFirefoxBack-Forwards Cache (bfcache)。

凍結是爲了節省CPU/電池/內存,而緩存是爲了重載時快速恢復,二者配合才能相得益彰。所以,該緩存被視爲凍結生命週期狀態的一部分。

注意:beforeunloadunload 會阻止該項優化。

  • 爲何生命週期裏沒有 load、DOMContentLoaded 事件?

頁面生命週期狀態定義爲離散和互斥的。因爲頁面能夠在activepassivehidden 狀態下加載,所以單獨的加載狀態沒有意義, 而且因爲 loadDOMContentLoaded 事件不表示生命週期狀態更改,所以它們與生命週期無關。

  • frozen 或 terminated 狀態如何使用異步請求

在這兩個狀態,任務可能被掛起不執行,例如異步請求、基於回調的API等一樣不會被執行。如下是一些建議

  1. sessionStorage,方法是同步的,且在廢棄狀態仍然能持久化數據。
  2. service worker,在 terminateddiscarded 狀態時經過監聽freeze or pagehide 經過 postMessage() 用來保存數據。 (受限與設備資源,可能喚起service worker 會加劇設備負擔)
  3. navigator.sendBeacon 函數運行頁面關閉時仍然能夠發送異步請求。

chrome-discards

對分析型數據採集時機的啓發

兼容性分析

lifecycle-events-testing

  1. 避免在 loadDOMContentLoadedbeforeunloadunload 中處理上報採集數據。
  2. 監聽 visibilitychange 在各類切換APP、息屏時處理採集信息。
  3. 監聽 pagehide 收集頁面刷新導航跳轉場景。
  4. 僅僅使用 beforeunload 兼容 Safari 關閉 Tab 和IE11如下版本的場景。
  5. 注意一旦收集信息當即銷燬全部採集事件,避免重複上報。
function clear(fn) {
  ['visibilitychange', 'pagehide', 'beforeunload']
    .forEach(event => window.removeEventListener(event, fn));
}

function collect() {
  const data = { /* */ };
  const str = JSON.stringify(data);
  if('sendBeacon' in window.navigator) {
    if( window.navigator.sendBeacon(url, str) ) {
      clear(collect);
    } else {
      // 異步發請求失敗
    }
  } else {
    // todo 同步 ajax
    clear(collect);
  }
}

const isSafari = typeof safari === 'object' && safari.pushNotification;
const isIE10 = 'onpagehide' in window;

window.addEventListener(`visibilitychange`, collect, true);
!isIE10 && window.addEventListener(`pagehide`, collect, true);

if(isSafari || isIE10) {
  window.addEventListener(`beforeunload`, collect, true);
}
複製代碼

總結

對於性能有極致追求的開發人員,開發時都應該考慮到頁面的生命週期。在不須要的狀況下不消耗設備資源對用戶來講是很是重要的。

此外越多的開發人員開始使用生命週期 APIs,瀏覽器處理凍結或廢棄再也不使用的頁面就越安全。 這意味着瀏覽器將會消耗更少的內存、CPU、電量、網絡資源,這都將有利於用戶。

參考

  1. WebKit Page Cache
  2. Firefox Back-Forward Cache
  3. Page Lifecycle W3C
  4. Page Lifecycle API
  5. Don't lose user and app state, use Page Visibility
  6. page-lifecycle
  7. PageLifecycle.js
  8. Lifecycle events with Page Visibility + Beacon API
  9. Why does visibilitychange fire after pagehide in the unload flow?
相關文章
相關標籤/搜索