页面生命周期
导读
性能,一直都是前端孜孜不倦探讨的话题。说到生命周期想必大家都不陌生,这都是 React 、Vue 等框架设计的核心理念,它大致都提供了数据初始化、DOM 加载、资源释放等时机钩子供大家合理使用,例如 React shouldComponentUpdate 对某些场景下性能优化起着重要的作用。同理,生命周期的设计理念已被现代浏览器引入并实现,理解最新标准规范对前端理解性能优化的方向就变得很重要。本文主要介绍的内容有浏览器 Tab 页生命周期相关API,一些建议和对数据上报采集的实践等。
生命周期概述
好奇
谷歌等浏览器曾经被吐槽过是内存的吞鲸巨兽,但你有没有发现目前已被改善,当某个 Tab 长期未被激活,再次打开时发现页面会重新刷新。占用的资源是什么时候被回收的?
在移动设备上发现,浏览器被切换到后台时,网页上的一些任务会被降频(如 timer),甚至停止运行。浏览器是如何优化来减少内存、CPU、电量、网络资源的使用的?开发的哪些行为会干扰优化呢?
要弄懂这些问题就需要请出今天的主角—— W3C 最新的规范 Page Lifecycle。
解决的问题
Page Lifecycle API 试图通过以下方式解决性能瓶颈:
在 Web 上引入并标准化生命周期状态的概念。 定义新的系统启动状态,允许浏览器限制隐藏或非激活选项卡可使用的资源。 创建新的 APIs 和事件,允许 Web 开发人员响应这些新的系统启动状态之间的转换。 该解决方案提供了Web 开发人员构建对系统干预具有弹性的应用程序所需的可预测性,并允许浏览器更积极地优化系统资源。
生命周期状态与事件
所有页面生命周期状态都是离散和互斥的,这意味着一个页面一次只能处于一个状态。
- 生命周期状态转变以及触发的事件
状态 | 描述 | 可能前一个的状态(触发事件) | 可能下一个状态(触发事件) |
---|---|---|---|
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状态[浏览器](https://www.w3cdoc.com)会挂起任务队列中可冻结任务的执行,这意味着例如 JS timer或fetch回调不会执行。正在执行的任务能被完成,但是可执行的操作和运行的时间会被限制。 [浏览器](https://www.w3cdoc.com)冻结是为了节约 CPU、内存、电量的消耗。同时使前进后退更加快速,避免从网络重新加载全量页面 | 1. hidden (freeze) | 1. active (resume -> pageshow) 2. passive (resume -> pageshow) 3. hidden (resume) |
Terminated | terminated状态表示[浏览器](https://www.w3cdoc.com)已卸载页面并回收了资源占用,不会有新的任务执行,已运行的长任务可能会被清除。 | 1. hidden (pagehide) | 无 |
Discarded | discarded状态发生在系统资源受限,[浏览器](https://www.w3cdoc.com)会主动卸载页面释放内存等资源用于新进/线程。该状态下任何任务、事件回调或任何类型的JS都无法执行。尽管页面不在了,但[浏览器](https://www.w3cdoc.com) Tab 页的标签名和 favicon用户仍可见 | 1. frozen (no events fired) | 无 |
事件
下面描述了与生命周期相关的所有事件,并列出了它们可能转换的状态。
- focus
描述:DOM元素获取焦点 前一个可能状态 passive 当前可能状态 active 注意:focus 事件并不总触发生命周期状态改变,只有在页面之前并没有聚焦才会发生改变。
- blur
描述:DOM元素失去焦点 前一个可能状态 active 当前可能状态 passive 注意:blur 事件并不总触发生命周期状态改变,只有在页面不再获取焦点才会发生改变。例如在页面元素之间切换焦点就不会。
- visibilitychange
描述:document.visibilityState 值变化。触发场景: 刷新或导航到新页面 切换到新 Tab 页面 关闭 Tab、最小化、或关闭浏览器 移动端切换 app,如按了 Home 键,点击头部通知切换等 前一个可能状态 passive hidden 当前可能状态 passive hidden
- freeze
描述:页面被冻结,任务队列中的可冻结任务都不会执行。 前一个可能状态 hidden 当前可能状态 frozen
- resume
描述:浏览器重启了一个被冻结的页面 前一个可能状态 frozen 当前可能状态 active (if followed by the pageshow event) passive (if followed by the pageshow event) hidden
- pageshow
描述:检索页面导航缓存是否存在,存在则从缓存中取出,否则加载一个全新的页面。 如果页面是从导航缓存中取出,则事件属性 persisted 为 true,反之为 false。 前一个可能状态 frozen (此时 resume 事件也会触发) 当前可能状态 active passive hidden
- pagehide
描述:页面会话是否能够存入导航缓存。如果用户导航到另一个页面,并且浏览器能够将当前页面添加到页面导航缓存以供以后重用 ,则事件属性 persisted 为true。如果为true,则页面将进入 frozen 状态,否则将进入 terminated 状态。 前一个可能状态 hidden 当前可能状态 frozen (event.persisted is true, freeze event follows) terminated (event.persisted is false, unload event follows)
- beforeunload
描述:当前页面即将被卸载。此时当前页面文档内容仍然可见,关闭页面可以在该阶段取消。 前一个可能状态 hidden 当前可能状态 terminated 警告:监听 beforeunload 事件,仅用来提醒用户有未保存的数据改变,一旦数据保存完成,该监听事件回调应该移除。 不应该无条件地将它添加到页面中,因为这样做在某些情况下会损害性能。
- unload
描述:页面正在被卸载。 前一个可能状态 hidden 当前可能状态
- 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.
}
监听一些生命周期状态的方式
获取 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 无法实现; 捕获阶段发生在 target 阶段和冒泡阶段,这意味着捕获阶段事件不会被其他冒泡事件取消。
跨浏览器兼容
由于生命周期API刚刚被引入,新的事件和DOM api并没有在所有浏览器中实现。此外,所有浏览器实现并不一致。 例如:
- 一些浏览器切换 Tab 时,不会触发 blur 事件,意味着 active 状态不经过 passive 状态而直接变成了 hidden
- 一些浏览器虽然实现了 page navigation cache,Page Lifecycle API 把缓存的页面分类为冻结状态,但是还没有实现freeze,resume 等最新的 API,虽然非/冻结状态也可以通过 pageshow,pagehide 事件监听到。
- IE 10 以及以下版本未实现 pagehide 事件
- pagehide、visibilitychange 触发顺序已改变。当页面正在被卸载时,如果页面可见,会先触发 pagehide 在触发 visibilitychange。最新版本的 Chrome ,无论页面是否可见都会先触发 visibilitychange 在触发 pagehide。
- Safari 关闭 Tab 页可能不会触发 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),不应该影响其他打开的同源页面或影响[浏览器](https://www.w3cdoc.com)把页面存入缓存(page navigation cache)。 你也应该持久化动态视图信息(例如无限滑动列表的滑动位置)到 sessionStorage或IndexedDB via commit(),以便discarded 和 reloaded之后重用。 当状态重新变回 hidden 时您可以重新打开任何关闭的连接,或重新启动最初冻结页面时停止的任何轮询。 |
Terminated | 当页面变成 terminated 状态,开发人员一般不需要做任何操作。因为用户主动卸载页面时总会在 terminated 之前经历 hidden 状态(页面刷新和跳转时不一定会触发 visibilitychange,少部分[浏览器](https://www.w3cdoc.com)实现了,大部分可能需要 pagehide 甚至beforeunload或unload 来弥补这些场景),你应该在 hidden 状态执行页面会话的结束逻辑(持久化存储、上报分析数据)。 开发人员必须认识到,在许多情况下(特别是在移动设备上),无法可靠地检测到终止状态,因此依赖终止事件(例如beforeunload、pagehide和unload)可能会丢失数据。 |
Discarded | 开发人员无法观察到被废弃的状态。因为通常在系统资源受限下被废弃,在大多数情况下,仅仅为了允许脚本响应discard事件而解冻页面是不可能的。因此,没必要从hidden更改为frozen时做处理,可以在页面加载时检查 document.wasDiscarded,来恢复之前被废弃的页面。 |
避免使用老旧的生命周期API
- unload,不要在现代浏览器中使用
- 很多开发人员会把 unload 事件当做页面结束的信号来保存状态或上报分析数据,但这样做非常不可靠,特别是在移动端。 unload 在许多典型的卸载情况下不会触发,例如通过移动设备的选项卡切换、关闭页面或系统切换器切换、关闭APP。
- 因此,最好依赖 visibilitychange 事件来确定页面会话何时结束,并将 hidden 状态视为最后保存应用和用户数据的可靠时间。
- unload 会阻止浏览器把页面存入缓存(page navigation cache),影响浏览器前进后退的快速响应。
- 在现代浏览器(包括IE11),推荐使用 pagehide 事件代替 onload 监测页面卸载(terminated)。onload 最多用来兼容IE10。
- beforeunload,和 unload 有类似的问题,仅仅用来提醒用户关闭或跳转页面时有未保存的数据,一旦保存立即清除。
数据采集上报启发与实践
避免在 load、DOMContentLoaded、beforeunload、unload 中处理上报采集数据。 监听 visibilitychange 在各种切换APP、息屏时处理采集信息。 监听 pagehide 收集页面刷新导航跳转场景。 仅仅使用 beforeunload 兼容 Safari 关闭 Tab 和IE11以下版本的场景。 注意一旦收集信息立即销毁所有采集事件,避免重复上报。
function clear(fn) {
['visibilitychange', 'pagehide', 'beforeunload']
.forEach(event => window.removeEventListener(event, fn, true));
}
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);
}
兼容性报告
FAQs
页面不可见(hidden)时有重要的任务在执行,如何阻止页面被冻结(frozen)或废弃(discarded)?
有很多合理的理由在页面不可见(hidden)状态不冻结(frozen)页面,例如APP正在播放音乐。
因此,浏览器策略会趋于保守,只有在明确不会影响用户的时候才会放弃页面。例如以下场景不会废弃页面(除非受到设备的资源限制)。
Playing audio Using WebRTC Updating the table title or favicon Showing alerts Sending push notifications Tips:对于更新标题或favicon以提醒用户未读通知的页面,建议使用 service worker,这将允许Chrome冻结或放弃页面,但仍然显示对选项卡标题或favicon的更改。
什么是页面导航缓存(page navigation cache)?
页面导航缓存是一个通用术语,用于优化后退和前进按钮导航,利用缓存快速恢复前后页面。Webkit 称 Page Cache,Firefox 称 Back-Forwards Cache (bfcache)。
冻结是为了节省CPU/电池/内存,而缓存是为了重载时快速恢复,两者配合才能相得益彰。因此,该缓存被视为冻结生命周期状态的一部分。
注意:beforeunload、unload 会阻止该项优化。
为什么生命周期里没有 load、DOMContentLoaded 事件?
页面生命周期状态定义为离散和互斥的。由于页面可以在active、passive 或 hidden 状态下加载,因此单独的加载状态没有意义, 并且由于 load 和 DOMContentLoaded 事件不表示生命周期状态更改,因此它们与生命周期无关。
frozen 或 terminated 状态如何使用异步请求
在这两个状态,任务可能被挂起不执行,例如异步请求、基于回调的API等同样不会被执行。以下是一些建议
sessionStorage,方法是同步的,且在废弃状态仍然能持久化数据。 service worker,在 terminated、discarded 状态时通过监听freeze or pagehide 通过 postMessage() 用来保存数据。(受限与设备资源,可能唤起service worker 会加重设备负担) navigator.sendBeacon 函数运行页面关闭时仍然可以发送异步请求。
如何查看页面 frozen and discarded 状态?
chrome://discards
参考
Don’t lose user and app state, use Page Visibility
Lifecycle events with Page Visibility + Beacon API
Why does visibilitychange fire after pagehide in the unload flow?