背景
在 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发送数据,让后者来完成。但当内存压力较大的时候,不建议使用后者。