原文連接javascript
最近作 web 性能採集分析,一直以爲跟用戶交互無關的採集都放在 onLoad
或 DOMContentLoaded
中很不合理。 一番搜索,發現 web 頁面也是有生命週期的。一番研究,解決了如何避免干擾用戶採集信息的困惑。 W3C 最新的規範 Page Lifecycle, 提供了一系列的生命週期鉤子函數,方便開發者可以在不干擾用戶交互的狀況下監聽處理一些操做。html
問題:如何利用生命週期優雅的處理上報分析數據,既能保證在某些場景下不漏報,又能儘量少的干擾用戶?java
應用程序生命週期是現代操做系統管理資源的關鍵方法。在移動iOS、Android和最新的桌面系統中, apps 在任什麼時候候都能被 OS 啓動或關閉,生命週期使得這些系統 streamline
(流線型,使增產節約),從新分配資源更加合理高效,極大的優化了用戶的體驗。git
歷史上,web 並無生命週期的概念,致使 web 應用能夠一直存活佔用系統資源。瀏覽器打開大量的 Tab 頁, 關鍵系統資源如內存、CPU、電池和網絡被過分佔用而沒法釋放,致使系統卡頓。例如老版本的 Chrome 雖然性能 在當時的瀏覽器單頁執行對比中一直是翹楚,但開多了頁面,特別吃內存,得益於生命週期,能夠合理的回收內存。github
而 web 平臺長期以來都有與生命週期狀態相關的事件,如 load
、unload
、visibilitychange
, 這些事件容許開發者監聽生命週期狀態的改變。對於移動設備特別是一些低端機型,瀏覽器須要一種主動回收內存和從新分配內存的方式。web
事實上,如今的瀏覽器已經採起了積極的措施來節省後臺標籤頁的資源,許多瀏覽器但願作更多的事情來減小它們的資源佔用。ajax
問題是開發人員目前沒有辦法爲這些類型的系統啓動干預作好準備,甚至沒法知道它們正在發生。這意味着瀏覽器須要保守,不然就有可能破壞網頁。chrome
Page Lifecycle API
試圖經過如下方式解決這些問題:api
該解決方案提供了web開發人員構建對系統干預具備彈性的應用程序所需的可預測性,並容許瀏覽器更積極地優化系統資源,最終使全部web用戶受益。瀏覽器
本文的將介紹新的頁面生命週期特性,並探討它們與全部現有web平臺狀態和事件的關係。它還將爲開發人員在每一個狀態下應該(和不該該)作的工做類型提供建議和最佳實踐。
全部頁面生命週期狀態都是離散和互斥的,這意味着一個頁面一次只能處於一個狀態。頁面生命週期狀態的大多數更改一般均可以經過DOM事件觀察到(關於異常,請參見開發人員對每一個狀態的建議)。
生命週期狀態轉變以及觸發的事件
狀態 | 描述 | 可能前一個的狀態(觸發事件) | 可能下一個狀態(觸發事件) |
---|---|---|---|
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 timer 或fetch 回調不會執行。正在執行的任務能被完成,可是可執行的操做和運行的時間會被限制。瀏覽器凍結是爲了節約 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) | 無 |
下面描述了與生命週期相關的全部事件,並列出了它們可能轉換的狀態。
document.visibilityState
值變化。觸發場景:persisted
爲 true,反之爲 false。persisted
爲true。若是爲true,則頁面將進入 frozen
狀態,不然將進入 terminated
狀態。beforeunload
事件,僅用來提醒用戶有未保存的數據改變,一旦數據保存完成,該監聽事件回調應該移除。 不該該無條件地將它添加到頁面中,由於這樣作在某些狀況下會損害性能。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.
}
複製代碼
獲取 active
、passive
、 hidden
const getState = () => {
if (document.visibilityState === 'hidden') {
return 'hidden';
}
if (document.hasFocus()) {
return 'active';
}
return 'passive';
};
複製代碼
像 frozen
和 terminated
狀態須要監聽 freeze
、pagehide
事件獲取。
// 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});
複製代碼
上面代碼作了三件事:
getState()
初始化狀態logStateChange
函數接收下一個狀態,如改變則 console捕獲階段
事件,一次調用 logStateChange
,傳入狀態改變。注意:上述 console 打印的順序在不一樣的瀏覽器中可能不一致。
{capture: true}
且都在 window
上監聽事件target
pagehide
、pageshow
在 window
上觸發visibilitychange
, freeze
, resume
在 document
上觸發focus
、blur
在相應的 DOM 元素上觸發window
沒法實現因爲生命週期API剛剛被引入,新的事件和DOM api並無在全部瀏覽器中實現。此外,全部瀏覽器實現並不一致。 例如:
blur
事件,意味着 active
狀態不通過 passive
狀態而直接變成了 hidden
page navigation cache
,Page Lifecycle API
把緩存的頁面分類爲凍結狀態, 可是尚未實現freeze
,resume
等最新的 API,雖然非/凍結狀態也能夠經過 pageshow
,pagehide
事件監聽到。pagehide
事件pagehide
、visibilitychange
觸發順序已改變。 當頁面正在被卸載時,若是頁面可見,會先觸發 pagehide
在觸發 visibilitychange
。 最新版本的 Chrome ,不管頁面是否可見都會先觸發 visibilitychange
在觸發 pagehide
。pagehide
或 visibilitychange
。須要監聽 beforeunload
來作兼容, beforeunload
須要在冒泡階段結束才能知道狀態是否變成 hidden
,所以容易被其餘事件取消。推薦使用PageLifecycle.js,確保跨瀏覽器的一致性。
做爲開發人員,理解頁面生命週期狀態並知道如何在代碼中觀察它們很重要,由於您應該(也不該該)執行的工做類型在很大程度上取決於您的頁面處於什麼狀態。
例如,若是頁面處於不可見狀態,則向用戶顯示臨時通知顯然沒有意義。雖然這個例子很明顯,但還有一些不太明顯的建議值得列舉。
狀態 | 建議 |
---|---|
Active | 該狀態是對用戶來講最重要的階段,此時最重要的就是響應用戶輸入。長時間阻塞主線程的非no-UI任務能夠交給idle 時期或web worker 處理 |
Passive | 該狀態下,用戶沒有與頁面交互,可是他們仍然能夠看到它。這意味着UI更新和動畫應該仍然是平滑的,可是這些更新發生的時間不那麼關鍵。當頁面從 active 變爲 passive 時,是存儲未保存數據的好時機。 |
Hidden | 當 passive 轉變爲 hidden ,用戶頗有可能再也不與頁面交互直到從新加載。hidden 狀態每每是開發人員能夠信賴的最後狀態,尤爲在移動端,例如切換 APP 時beforeunload 、pagehide 和 unload 事件都不會觸發。這意味着,對於開發人員應該把 hidden 狀態當成是頁面會話的最終狀態。在此時應該持久化未保存的應用數據,採集上報分析數據。同時,你應該中止UI更新,由於用戶已經看不到了。也該中止那些用戶並不想在後臺執行的任務,節省電量等資源。 |
Frozen | 在 frozen 狀態,任務隊列中可凍結的任務會被掛起,直到頁面解凍(也許永遠不會發生,例如頁面被廢棄discarded )。此時有必要中止全部的 timer 和關閉鏈接(IndexedDB、BroadcastChannel、WebRTC、Web Socket connections。釋放Web Locks),不該該影響其餘打開的同源頁面或影響瀏覽器把頁面存入緩存(page navigation cache)。你也應該持久化動態視圖信息(例如無限滑動列表的滑動位置)到 sessionStorage或IndexedDB via commit(),以便discarded 和 reloaded以後重用。 當狀態從新變回 hidden 時您能夠從新打開任何關閉的鏈接,或從新啓動最初凍結頁面時中止的任何輪詢。 |
Terminated | 當頁面變成 terminated 狀態,開發人員通常不須要作任何操做。由於用戶主動卸載頁面時總會在 terminated 以前經歷 hidden 狀態(頁面刷新和跳轉時不必定會觸發 visibilitychange ,少部分瀏覽器實現了,大部分可能須要 pagehide 甚至beforeunload 或unload 來彌補這些場景),你應該在 hidden 狀態執行頁面會話的結束邏輯(持久化存儲、上報分析數據)。開發人員必須認識到,在許多狀況下(特別是在移動設備上),沒法可靠地檢測到終止狀態,所以依賴終止事件(例如 beforeunload 、pagehide 和unload )可能會丟失數據。 |
Discarded | 開發人員沒法觀察到被廢棄的狀態。由於一般在系統資源受限下被廢棄,在大多數狀況下,僅僅爲了容許腳本響應discard 事件而解凍頁面是不可能的。所以,不必從hidden 更改成frozen 時作處理,能夠在頁面加載時檢查 document.wasDiscarded ,來恢復以前被廢棄的頁面。 |
unload
事件當作頁面結束的信號來保存狀態或上報分析數據,但這樣作很是不可靠,特別是在移動端。 unload
在許多典型的卸載狀況下不會觸發,例如經過移動設備的選項卡切換、關閉頁面或系統切換器切換、關閉APP。visibilitychange
事件來肯定頁面會話什麼時候結束,並將 hidden
狀態視爲最後保存應用和用戶數據的可靠時間。unload
會阻止瀏覽器把頁面存入緩存(page navigation cache),影響瀏覽器前進後退的快速響應。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});
複製代碼
// 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);
}
}
}
複製代碼
有不少合理的理由在頁面不可見(hidden)狀態不凍結(frozen)頁面,例如APP正在播放音樂。
對於有些場景,瀏覽器放棄頁面也存在風險,例如用戶有未提交的輸入或開發人員監聽了beforeunload
事件以便提醒用戶。
所以,瀏覽器策略會趨於保守,只有在明確不會影響用戶的時候纔會放棄頁面。例如如下場景不會廢棄頁面(除非受到設備的資源限制)。
注意:對於更新標題或favicon以提醒用戶未讀通知的頁面,建議使用 service worker
,這將容許Chrome凍結或放棄頁面,但仍然顯示對選項卡標題或favicon的更改。
頁面導航緩存是一個通用術語,用於優化後退和前進按鈕導航,利用緩存快速恢復先後頁面。Webkit 稱 Page Cache
,Firefox
稱 Back-Forwards Cache
(bfcache)。
凍結是爲了節省CPU/電池/內存,而緩存是爲了重載時快速恢復,二者配合才能相得益彰。所以,該緩存被視爲凍結生命週期狀態的一部分。
注意:beforeunload
、unload
會阻止該項優化。
頁面生命週期狀態定義爲離散和互斥的。因爲頁面能夠在active
、passive
或 hidden
狀態下加載,所以單獨的加載狀態沒有意義, 而且因爲 load
和 DOMContentLoaded
事件不表示生命週期狀態更改,所以它們與生命週期無關。
在這兩個狀態,任務可能被掛起不執行,例如異步請求、基於回調的API等一樣不會被執行。如下是一些建議
terminated
、discarded
狀態時經過監聽freeze
or pagehide
經過 postMessage()
用來保存數據。 (受限與設備資源,可能喚起service worker 會加劇設備負擔)兼容性分析
load
、DOMContentLoaded
、beforeunload
、unload
中處理上報採集數據。visibilitychange
在各類切換APP、息屏時處理採集信息。pagehide
收集頁面刷新導航跳轉場景。beforeunload
兼容 Safari
關閉 Tab 和IE11如下版本的場景。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、電量、網絡資源,這都將有利於用戶。