Page Lifecycle API
今天的现代浏览器有时在系统资源受限的情境下会暂停页面或完全放弃执行它。将来,浏览器会主动执行此操作,因此它们会消耗更少的电量和内存。在Chrome 68中提供的Page Lifecycle API提供了生命周期钩子,因此网页可以安全地处理这些浏览器干预,而不会影响用户体验。具体请查看API了解你的应用程序是否需要实现这些特性。
背景
应用程序的生命周期是现代操作系统管理资源的关键。在Android, iOS, 和最近的Windows版本中,操作系统可以随时开始或结束应用程序。这使得这些平台可以简化和重新分配最有利于用户的资源。
在网络上, 有史以来从来没有过这样的生命周期, 所以应用程序可以一直保持运行态。运行大量网页后,内存,CPU,电池和网络等关键系统资源可能会超负荷运行,从而导致最终用户体验不佳。
虽然web平台早就有与生命周期状态相关的事件 — 如 load, unload, and visibilitychange—这些事件只允许开发者响应用户造成的生命周期状态更改。为了使Web能够在低功耗设备上可靠地工作(并且通常在所有平台上具有更高的资源意识),浏览器需要一种主动回收和重新分配系统资源的方法。
事实上,现在的浏览器已经对在后台标签的页面采取了积极措施来节约资源 , 而且很多的浏览器(特别是Chrome)会做更多这方面的工作 - 以减少它们的整体资源占用。
问题是开发者目前无法为系统执行的这类干预操作做好准备,甚至都不知道系统正在干预。 这意味着浏览器需要保护或冒险破坏网页。
Page Lifecycle API 通过以下措施来解决这个问题:
-
在网上介绍并标准化生命周期状态的概念。
-
定义新的系统启动状态,允许浏览器限制隐藏或非活动状态的标签页可以使用的资源。
-
创建新的API和事件,允许Web开发者响应这些新的系统启动状态的转换。
这种解决方法提供了可预测性,网页开发人员需要创建一个能灵活应对系统干预的应用程序,而且这种解决方法让浏览器可以更加积极地优化系统资源,最终令所有web用户受益。
本篇文章剩余部分会介绍Chrome 68中新的页面生命周期特性,并且会探索这些特性与所有已存在的网页平台的状态和事件的关联。文章也为开发者应该(或不应该)在每个状态进行的工作类型提供建议和最佳实践。
总览页面生命周期状态和事件
所有的页面生命周期状态都是相互独立存在的, 也就是说一个页面在同一个时间点只能存在一个状态。而且通常大多数页面生命周期的状态改变都可通过DOM事件监听 ( 也有例外,查看开发者对每个状态的建议 ).
也许图表是最能直观解释页面生命周期状态的,它同样也能很好的标识事件间的转换:
状态
下表详列了每个状态的细节信息。还列出了可能发生的前后状态,还包括开发者可以用来监听变化的事件。
状态 | 描述 |
---|---|
Active |
当页面可见或有input框聚焦时,该页面处于active状态
可能之前的状态为: passive (通过focus 事件转换而来)
可能下个状态为: passive (通过 blur 事件转换而来)
| | Passive |
当页面可见但没有聚焦的input框时,该页面处于passive状态
之前可能的状态是: active (通过触发blur 事件而来) hidden (通过visibilitychange触发事件而来)
接下来可能变成的状态是: active (通过触发focus 事件而来) hidden (通过触发visibilitychange 事件而来)
| | Hidden |
如果页面不可见且尚未被冻结,则处于hidden状态。
之前可能的状态是: passive (通过事件visibilitychange而来)
接下来可能变成的状态: passive (通过触发visibilitychange事件而来) frozen (通过触发freeze事件而来) terminated (通过触发pagehide事件而来)
| | Frozen |
当处于frozen状态时,浏览器暂停执行页面里任务队列 中 可冻结的任务 直到页面不是冻结状态. 也就是说像JavaScript定时器和fetch回调不会运行。已经运行的任务可以结束(特别是freeze回调), 但它们可能会受限于它们做的是什么或能运行多久。
浏览器冻结页面是为了保护CPU/电池/数据使用,同样它也能使后退/前进导航更快— 因为避免了重新加载整个页。
可能之前的状态为: hidden (通过触发freeze事件而来)
_
之后可能变成的状态: active (通过先触发resume事件, 再触发pageshow事件而来) passive(通过先触发 resume事件,再触发pageshow事件而来) hidden (通过触发resume事件而来)
_ | | Terminated |
一旦浏览器卸载页面或在内存中清除页面时,页面就变为terminated状态. 在这种状态下不会运行新任务, 并且正在进行的任务如果运行了太久也会被杀掉。
之前可能的状态是: hidden (通过触发pagehide事件)
之后可能的状态是: NONE
| | Discarded |
当页面被浏览器卸载为保护资源时,页面处于discarded状态.在这种状态下不会运行任何任务、事件回调甚至是JavaScript。因为在资源受限的情况下通常要放弃某些操作, 所以不可能开启一个新进程。
处在discarded 状态的tab页 (包括tab页窗口的标题和图标 ) 即使是页面消失也总是对用户可见
之前可能的状态是: frozen (不触发事件)
之后可能的状态是: NONE
|
事件
浏览器会发送许多事件,但是只有小部分事件表明页面周期状态可能发生变化。下表概述了与生命周期相关的所有事件,并列出了它们可能转换前后的状态。
名字 | 细节信息 |
---|---|
focus |
有DOM元素已经聚焦。
备注: focus事件并不一定表示状态改变了。如果页面之前没有input聚焦,它仅表示状态更改。
之前可能的状态是: passive
当前可能的状态: active
| | blur |
有DOM元素失去焦点。
备注: blur事件并不一定表示状态改变了。如果页面不再有input框聚焦(例如, 页面没有从一个聚焦的元素切换到另一个元素), 它仅表示状态更改。
之前可能的状态是: active
当前可能的状态: passive
| | visibilitychange |
文档上, visibilityState值已经更新了。文档的visibilityState值已更改。 当用户导航到新页面,切换选项卡,关闭选项卡,最小化或关闭浏览器或在移动端切换应用程序时,可能会使visibilityState的值改变。
| | freeze * |
页面刚被冻结. 页面任务队列不会执行任何冻结的任务。
之前可能的状态: hidden
当前可能的状态: frozen
| | resume * |
The browser has resumed a frozen page. 浏览器已经恢复了冻结的页面
之前可能的状态: frozen
当前可能的状态: active (如果是紧随pageshow 事件发生) passive (如果是紧随pageshow事件发生) hidden
| | pageshow |
会话历史记录新增一条记录。
这可能是个全新的页面,也可能是导航缓存中的页面. 如果页面是页面导航缓存中,事件的持久属性为true,否则为false。
可能之前的状态: frozen (resume 事件也会被触发)
| | pagehide |
会话历史记录新增一条记录。
如果用户在浏览另一个页面, 而且浏览器可能会添加当前的页面到页面导航缓存 ,以便之后调用, 事件属性会持续为true,此时页面将进入到frozen状态, 否则会进入到结束状态。
可能之前的状态是: hidden
当前可能的状态是: frozen (event.persisted为true, freeze事件紧随) terminated(event.persisted为false, unload事件紧随)
| | beforeunload |
window、document以及其资源即将被卸载。但在此时,document仍然是可见的,事件仍可以被取消。
警告: beforeunload事件只能用于警告用户有未保存的改变。一旦保存后,事件应该会被清除。这样做不可能对页面没有影响,因为在某些场景下会牺牲性能。在旧版API看详细内容.
之前可能的状态是: hidden
当前可能的状态是: terminated
| | unload |
此时页面正在卸载
警告: 建议千万不要使用unload事件,因为它不稳定而且在某些场合下可能伤害性能。在旧版API 看详细内容.
之前可能的状态是: hidden
当前可能的状态: terminated
|
* 以下展示页面生命周期API定义的新事件
在Chrome 68添加的新属性
上面的部分展示了两种状态,它是系统初始化状态而非用户初始化状态:frozen 和 discarded. 正如以上提到的,现在的浏览器偶尔会冻结并丢弃隐藏的标签(他们自己决定), 但是开发者无法得知。
在Chrome 68, 开发者现在可以通过监听document的freeze和resume事件来观察一个隐藏的tab标签什么时候冻结和解除冻结的.
document.addEventListener('freeze', (event) => {
// The page is now frozen.
});
document.addEventListener('resume', (event) => {
// The page has been unfrozen.
});
在chrome 68的文档对象中现在也包含了 wasDiscarded 属性. 它用于决定在隐藏的标签中何时被抛弃。你可以在页面加载时检查这个值(备注:被抛弃后的页面要重新用必须重新加载).
if (document.wasDiscarded) {
// Page was previously discarded by the browser while in a hidden tab.
}
若想了解关于在freeze和resume事件发生时该做哪些重要操作的建议,或想知道页面即将被抛弃时如何处理和准备,请查看 对每个状态的开发者建议.
接下来的几个章节概括了这些新特性如何适应已经存在的web平台的状态和事件。
监听页面周期状态
在active, passive, 以及 hidden 这些状态时,可以从现在的web平台API中执行一些JavaScript代码判断当前页面生命周期状态。
const getState = () => {
if (document.visibilityState === 'hidden') {
return 'hidden';
}
if (document.hasFocus()) {
return 'active';
}
return 'passive';
};
但frozen 和 terminated状态,当状态改变时只能在相应的事件(freeze 和pagehide) 中才能监听到。
观察状态改变
基于上面定义的getState()函数,可以作如下修改,这样便可观察所有页面生命周期状态改变。
// 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()函数, 传入需要改变的状态值为参数。
警告! 这段代码在不同的浏览器中会产生不同的结果, 因为事件顺序(以及可靠性)实现还未统一。查看管理跨浏览器差异学习处理这些差异的最佳实践.
对上面的代码要提醒一点, 所有的事件监听器都要被添加到window而且他们都会传入{capture: true}. 这么做有几个原因:
-
不是所有的生命周期事件的target都一致。pagehide和pageshow事件会在window上触发; visibilitychange、freeze以及resume事件在document上触发, 而focus和blur事件会在他们各自的DOM元素上触发。
-
这些事件大部分不会冒泡,这就意味着不可能在公共的祖先上添加非捕获事件监听器监听所有的事件
-
捕获阶段在目标或冒泡阶段之前执行, 所以在此时添加事件监听器能确保它能在其他代码取消它们前执行。
管理跨浏览器差异
本篇文章的开始根据页面生命周期API概述了状态和事件流。但由于这些API刚被引入, 新的事件和DOM API还未在所有的浏览器中实现。
此外, 当下所有浏览器实现的事件也并未一致。例如:
-
当切换标签页时有些浏览器灭有触发blur事件。这就意味着(跟上面在表格和图表中的相反)页面可以直接从active 状态直接进入到hidden状态而不会先转为passive状态。
-
个别浏览器实现了页面导航缓存, 而页面生命周期API定义了缓存的页面应处于冻结状态。由于API完全是新的,所以这些浏览器还未实现freeze和resume事件, 即使这些状态仍然可以通过pagehide和pageshow事件被监听到。
-
IE浏览器老版(10及以下版本)没有实现visibilitychange事件。
-
pagehide和visibilitychange事件的发生顺序有所 改变. 如果在卸载页面时,并且页面处于可见状态,早期浏览器会先触发pagehide事件再触发visibilitychange事件。新的Chrome版本则是先触发visibilitychange事件再触发pagehide事件,无论卸载时文档是否为可见状态。
为了让开发者更容易处理这些跨浏览器的矛盾问题,并能全心关注生命周期状态建议以及最佳实践, 我们发布了PageLifecycle.js, 这是个用来监听页面生命周期API状态改变的JavaScript库。
PageLifecycle.js 规范了跨浏览器在事件触发顺序的差异,这样状态就能准确如本文图表及表格中所述变化(而且在所有的浏览器中都能保持一致).
各种状态时对开发者的建议
作为开发者,了解页面生命周期_以及_知道在代码中如何监听它们都同样重要,因为你接下来应该做的(不应该做的)工作都会极大的依赖于当前页面的状态。
例如,如果页面处于hidden状态,很明显给用户瞬时性通知没有意义。由于这个例子非常明显,但总有不那么明显的场景,以下例举了其中值得关注的场景的建议.
状态 | 开发者建议 |
---|---|
Active |
对用户来说active状态是最关键的,因此这是最好的时间响应用户输入.
任何可能阻止主线程的非UI工作都应该被重新划分为空闲时段 或 卸载到Web worker.
| | Passive |
在passive状态时, 用户不会与页面互动,但仍对用户可见。这也就是说UI更新及动画依然会流畅,但这些更新时间就没那么重要了。
当页面从active更改为passive时,现在是保持未保存的应用程序状态的好时机。
| | Hidden |
当页面从passive状态切换到hidden状态时,可能用户不会再跟它有交互直到页面被重新加载.
开发者能可靠的监听到最后状态变化可能是页面转换到hidden状态。 (特别是在移动设备上, 因为用户可以关掉tab标签或者是浏览器,此时, beforeunload, pagehide, unload事件都不会触发).
这意味着您应该将hidden状态视为用户会话可能结束. 换言之,这时该保存所有没有保存的应用状态、发送还未发送的所有需要分析的数据。
这时候也应该停止更新UI(因为用户都不会看到了),也要关闭所有用户不希望在后台运行的程序。
| | Frozen |
页面处于frozen状态时,在 任务队列 冻结的任务 会被挂起直到页面不再被冻结——这可能永远不会发生(例如: 页面被抛弃).
这意味着页面从hidden转为frozen时,必须停止任何计时器或拆除任何连接,如果冻结,可能会影响同一源的其他open的选项卡标签,或影响浏览器将页面放入页面导航缓存的能力.
特别值得一提的是:
-
关闭所有打开的IndexedDB连接。
-
关闭打开的BroadcastChannel连接。
-
关闭打开的WebRTC连接.
-
停止所有网络轮询以及所有打开的Web Socket连接.
-
释放所有被保持的Web Locks.
应该将任何动态的视图状态(e.g. 无限滚动列表视图的滚动位置) 保存到sessionStorage (或者通过commit()提交到IndexedDB ) 这样的话,如果随后页面被抛弃又重新加载就能恢复原来的状态。
如果页面从frozen转变为hidden,你可以重新打开在任何开始冻结状态时关闭的连接或者重启那时被停止的轮询。
| | Terminated |
通常在页面转为terminated状态时不需要做任何操作。
由于页面即将被卸载,导致用户行为总是在进入terminated状态前进入hidden状态,进入hidden状态时应该执行会话结束逻辑(例如,保存应用程序状态和提交分析数据).
当然 (正如对hidden状态建议中提到的), 对开发者而言了解转为terminated状态在许多场景下(特别是移动设备上)是不可靠的极为重要, 所以依靠termination事件(例如,beforeunload, pagehide, 还有unload事件)都有可能丢失数据。
| | Discarded |
当页面正被抛弃时, discarded状态不会被开发者监听到。这是因为页面通常在资源约束下被丢弃,并且在大多数情况下根本不可能解冻页面以允许脚本运行响应丢弃事件.
所以, 在从hidden转变到frozen时,你应该做好有可能页面会被抛弃的准备。这样的话,在页面加载时,通过检查document.wasDiscarded属性来恢复抛弃的页面状态。
|
再次重申, 由于不同的浏览器对生命周期事件的可靠性及顺序实现不统一。若要根据上表的建议最简单的是使用 PageLifecycle.js.
##要避免的旧版生命周期API
unload事件
关键点: 千万别再现代浏览器中使用unload事件。
许多开发者认为unload事件是能保证会回调,所以使用它作为结束会话的信号来保存状态或者发送分析数据。但这样做极为不靠谱, 特别是在移动设备上! unload事件在一些典型的unload场景中并没有被触发,包括从移动设备上标签切换器上关闭标签还有从app切换器上直接关闭浏览器app.
由于这种原因, 依赖visibilitychange 事件来决定会话是否结束更好,而且可以把hidden状态当做 最后一次最可靠的事件来保存app及用户数据.
另外, 仅仅在已注册的卸载事件处理程序(通过onunload或addEventListener())就可以防止浏览器将页面放入 页面导航缓存 中,以实现更快的后退和前进加载。
现代浏览器(包括IE11), 都建议始终用pagehide 事件来检测页面是否被卸载 (a.k.a terminated状态) 而不是用unload事件. 如果你需要支持Internet Explorer版本10及以下, 您应该检测pagehide事件,如果浏览器不支持pagehide,则只使用unload:
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});
想要了解更多关于页面导航缓存,以及unload事件为何会破坏它们,请看:
beforeunload 事件
关键点: 永远不要无条件地添加beforeunload监听器或将其用作为会话结束信号。 仅在用户未保存的工作时添加,并在保存工作后立即将其删除。
beforeunload和unload事件的问题类似,因为当它发生时会阻止浏览器在其页面导航缓存中缓存页面
beforeunload和unload事件的不同之处是使用beforeunload是被允许的. 例如,当你想要警告用户如果继续卸载页面未保存的变化将会丢失。
由于有正当理由使用beforeunload,但使用它会阻止页面添加到页面导航缓存中。所以建议只在用户有未保存的变化状态时才添加beforeunload监听,在保存好这些改变立即删除。
换句话说,不要这样做(因为它无条件地添加了一个beforeunload监听器):
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});
应该这样处理(只在需要的时候添加beforeunload监听, 在不需要的时候删除监听处理器):
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(), 都是会遵循以上概述的最佳实践. 它们基于一个提案草案,正式用声明性API替换beforeunload事件,该API在移动平台上不易被滥用且更可靠。
若你想正确地使用beforeunload事件并且能跨浏览器工作,建议使用PageLifecycle.js。
FAQs
我的页面在hidden时非常重要,我如何能避免它被冻结或是被抛弃?
在hidden状态下运行时,有很多合理的场景网页都不应被冻结。最常见的例子是当app在播放音乐时。
在某些情况下Chrome丢弃页面会很危险,例如假设页面包含有未提交用户输入的表单,或是有个在页面卸载时发出警告的beforeunload处理程序。
目前, 在丢弃页面时Chrome将更保守,只有在有把握不会影响用户时才会这样做。例如,在hidden状态时,页面会监听是否有如下情况,若有Chrome不会丢弃该页面,除非资源极其受限:
-
播放音频
-
正在使用WebRTC
-
更新表格标题或图标时
-
弹出警告时
-
发送推送通知
备注: 对于更新标题或网站图标以提醒用户未读通知的网页,我们目前 目前有一项提议,从service worker来更新,这允许Chrome冻结或丢弃页面,但仍然会显示更改的tab标题或网站图标。
什么是页面导航缓存?
页面导航缓存是个一般术语,用于描述一些浏览器实现更快前进或后退按钮的导航优化。Webkit把它叫做页面缓存 而Firefox称它为Back-Forwards Cache (或是简写bfcache).
当用户离开页面时,这些浏览器会冻结该页面的版本,以便用户使用前进或后退按钮导航时能快速恢复。请记住,添加beforeunload或unload事件处理器 能阻止优化.
归根结底,这个冻结与浏览器为了保护CPU/电量所做的冻结都有同样的功能; 因此这种情况也被认为是frozen生命周期状态.
###为什么没有提及load或者DOMContentLoaded事件?
页面生命周期API定义的状态是相互独立,没有关联的。由于页面能在active、passive、或者hidden状态时都可以加载,单独的loading状态没有意义存在。而且也因为load和DOMContentLoaded事件并不标识页面生命周期状态变化,所以它们与API没有关系.
如果在frozen或terminated状态不能执行异步API, 如何保存数据到IndexedDB?
在frozen或terminated状态下, 在页面 任务队列里的冻结任务 被挂起,也就是说不能可靠的调用异步操作或基于API的回调(如IndexedDB)。
将来, 我们将为IDBTransaction对象添加commit()方法, 这样为开发者提供了高效执行只写的数据处理方法而无需回调.换言之, 如果开发者仅仅是想将数据写入到IndexedDB而不需执行包含一系列读写数据的复杂数据处理,那么commit()方法能在任务队列被挂起前完成。 (假设IndexedDB数据库已经打开).
但是,对于今天需要工作的代码,开发人员有两种选择:
-
使用Session Storage: Session Storage是同步的而且跨页丢弃持久保存。
-
从service worker中使用IndexedDB: 在页面被终止或丢弃时,service worker可以将数据存在IndexedDb. 在freeze或pagehide事件监听器中可以通过postMessage()发送数据到service worker, service worker可以处理保存数据.
备注: 虽然上面的第二种方法能解决问题,但是在由于内存压力导致设备冻结或丢弃的场景下,这种方案就不是很理想。因为浏览器需要启动service worker进程,这会带来更大的压力。
在frozen或discarded状态下测试app
要在frozen和discarded状态下测试app行为, 可以访问 chrome://discards冻结或丢弃任何你打开的标签页。
这确保你当页面在丢弃后重加载时能正确的处理freeze和resume事件以及document.wasDiscarded 标志
总结
开发者若想要尊重用户设备的系统资源,要时刻牢记在app中使用压面生命周期状态。 在用户不期望的情况下,网页不会消耗过多的系统资源,这一点至关重要。
此外, 越多的开发者开始实现新的生命周期API,浏览器冻结或丢弃不使用的页面就越安全。这就意味着浏览器会消耗更少的内存、CPU、电量, 这对用户也是件好事。
最近, 开发者若想要实现本文提到的 最佳实践,但又不想去记所有可能的状态及事件转换那就使用 PageLifecycle.js吧,这样可以很容易在所有浏览器监听到一致的生命周期状态变化。