浅谈现代浏览器的网页生命周期

前言

在Android、IOS和Windows等的平台上,系统可以随意启动和中止应用程序的运行,这使得这些平台能够重新分配资源,以提供最好的操作体验给用户,当系统做这类干预时应用程序通常是知晓的,这是因为操作系统为应用程序提供了标准化的应用生命周期。

对于网页来说,并没有类似的生命周期机制。随着浏览器中被打开网页数量增加,内存、CPU、等系统关键资源都被过度使用,导致系统性能下降,从而降低了用户的操作体验,因此现如今,各大浏览器也像操作系统一样通过干预页面来节省资源

但是这也引发了一些问题:开发人员可能无法为系统发起的干预做准备,这就可能导致浏览器在优化页面的过程中如果对页面造成了破坏,而开发者对此一无所知。

因此浏览器为了解决上面的问题,提出了页面生命周期的方案(Page Lifecycle API),方案的目标主要有三点:

  1. 在网页中将生命周期状态的概念标准化

  2. 定义新的页面状态,允许浏览器限制网页,防止页面持续占据系统资源

  3. 创建新的API和事件,允许开发人员响应这些页面状态之间的转换

不得不谈的浏览器优化

或许你也和我一样,在使用浏览器的过程中,只要是打开过的tab,不到迫不得己都不舍得关。因此经常在无意间就把浏览器tab区域整的非常拥挤,并且由于内存和CPU不堪重负,电脑往往也会变得非常卡顿。

而我们要知道Chrome每个选项卡的网页通常至少需要 50MB内存,如果打开了 10 个选项卡,那么至少要花费 450MB 的内存来保持后台选项卡状态,随着时间的推移,内存开销会变得非常大。

因此才有了我们在前言中所提到的:浏览器对于这种后台Tab泛滥、大量占用系统资源的情况需要去做相关的优化手段。

页面冻结 - Frozen

当页面长期被放在后台时,为了节省资源的开销,浏览器会将页面冻结(Fozen),页面冻结特征是:浏览器不会再分配 CPU 计算资源给网页任务队列中未执行的任务。也就是说页面要冻结时,待执行的定时器、网络请求、DOM 操作都会被挂起。

直到页面重新被用户打开时,浏览器才将页面解冻,重新分配资源,让页面继续执行冻结前挂起的任务。

不过浏览器可能会在页面冻结期间,周期性复苏页面一小段时间,允许一小部分任务执行。

浏览器为了开发者能够感知到页面被冻结,因此提供了两个事件

  • freeze:页面被冻结前触发,由于被冻结意味着系统资源即将被限制,所以这个事件的回调会被限制:

    • 无法新增网络请求(除了已经建立的网络连接(keep-alive))

    • 事件回调的执行的总时长不能超过 500 毫秒,否则页面不但不冻结,还会被丢弃。

    1
    2
    3
    document.addEventListener("freeze", (e) => {
    // Handle transition to FROZEN
    });
  • resume:页面被解冻时触发

    1
    2
    3
    document.addEventListener("resume", (e) => {
    // handle state transition FROZEN -> ACTIVE
    });

Frozen与bfcache

后向缓存Back-Forward Cache(或bfcachePage Cache)是浏览器实现的导航优化的手段:当用户离开一个页面时,如果当前页面符合bfcache的条件时,浏览器会将整个页面(包括当前JavaScript堆栈)暂存到内存中,将来用户在当前Tab导航变化的过程中如果返回该页面时浏览器可以直接从内存中打开页面,而不是重新加载此页面,如此一来能够极大提高页面之间跳转的速度。

不难看出,bfcache和冻结(Frozen)有异曲同工之处,两者都是停止CPU资源分配,等页面重新被激活时再重新分配资源,在此过程中页面始终被暂存在内存中没有被浏览器丢弃,所以从浏览器^1层面来看,bfcache可以视作页面被冻结

目前在Chrome移动端已经默认开启了bfcache。PC 端的话官方称在96+版本已经默认开启,但是我在101版本实测没开启(可能是安装了某个浏览器扩展导致),如果你也出现这种情况,可以Chrome实验室中主动开启bfcache功能:
chrome://flags/#back-forward-cache

页面丢弃 - Discarded

当前系统如果进入到极端资源限制的情况下,被冻结的页面会进入丢弃阶段(Discarded),此时网页会被浏览器卸载,也就是说页面不再占用任何资源。

对于被冻结的后台Tab来说,页面丢弃后,虽然页面被卸载了,但是它的 Tab标签还是可见的。如果用户重新返回这个 Tab 页,浏览器会发出请求重新加载网页。

由于页面丢弃通常是发生在页面冻结之后的,此时不会允许任何页面任务执行,所以页面被丢弃前不会发出相关事件。但是为了让开发者知道页面被丢弃过,浏览器会在页面重新加载被丢弃的页面时将页面的document.wasDiscarded属性会被设置为true

二者对比

生命周期状态 特征
Frozen 1. 停止分配CPU资源,仍有内存消耗; 2. 用户返回后被冻结页面后,从内存中恢复
Discarded 1. 完全把页面卸载,没有任何CPU和内存的消耗 2. 用户返回需要重新发起网络请求以加载页面

冻结和丢弃的条件

目前,Chrome 在冻结或丢弃页面时会比较保守,只有在确信不会影响用户时才会这样做。下面介绍几种浏览器会放弃冻结或丢弃的特殊场景:

存在未提交的用户输入

浏览器会跟踪用户是否在页面上填写了任何表单数据。如果存在的话,为了避免数据丢失,浏览器会阻止丢弃发生,但不会阻止冻结。

正在播放Audio

在页面上播放音频的方式通常见于音乐网站,用户常见的使用模式是开始播放音频,然后在后台播放或以其他方式最小化选项卡,因为考虑到了这种场景,所以浏览器会阻止正在播放Audio的网页被冻结和丢弃。

使用WebRTC API

使用 WebRTC API代表用户持续在传输音视频流(即使页面被隐藏),因此浏览器会阻止使用了WebRTC API的页面被冻结和丢弃。

使用WebSockets API

使用 WebSockets API 的网站通常会持续发送和/或接收数据。中断此连接可能会导致用户数据丢失、工作丢失、错过通知等。所以浏览器也会阻止使用了 WebSockets 的页面被冻结和丢弃

处于BeforeUnload的待处理状态

除了由于页面tab在后台长期没打开而被冻结之外,还有可能通过我们前面提到的切换导航时的bfcache而冻结。

切换导航会使页面触发beforeunload 事件,我们知道,网页可以通过注册beforeunload事件来触发一个页面离开前的待处理弹窗,这样能够很好的避免用户数据丢失。

对于浏览器来说如果没出现待处理弹窗,则该页面可以被冻结并且能够被正常丢弃。如果有待处理弹窗,这里浏览器为了避免用户数据丢失,页面会被冻结但不会被丢弃。

页面注册unload事件

和beforeunload一样,切换导航时也会触发unload事件。unload事件主要用于当用户退出页面时让网页处理一些收尾工作,而我们之前说的页面冻结是将页面暂存到内存中,解冻时再取出来继续运行。

因此我们可以做以下两个角度的假设:

  • 如果在页面冻结前执行了unload事件,可能会导致解冻后:页面的关键部分被之前触发的unload事件给清理,从而引起页面异常。

  • 如果不触发unload事件而直接冻结,那在冻结的过程中一旦页面被丢弃(Discarded)的话,会导致unload事件会丢失。这对于依赖unload事件的页面会带来很大的副作用。

所以在这种缺陷下,浏览器拒绝把注册了unload事件的页面进行冻结

兼容情况

Frozen和Discarded在19年已经进入到了草案阶段,目前在Chrome上支持较好,Safari和Firefox还没有兼容。

不过随着网页场景越来越复杂,未来浏览器一定会更加积极的进行资源调度优化,以便更有效的使用系统资源。因此页面冻结和丢弃是一个在开发时建议考虑的场景。后文中会详细说明这两个状态在页面生命周期中所占据的重要一环。

另外,为了便于测试,我们可以在 Chrome 中,可以方便的使用 chrome://discards来测试页面在 冻结丢弃 状态下的表现。

页面生命周期

生命周期状态

Active

在 Active 阶段,网页处于可见状态,且拥有输入焦点。

Passive

在 Passive 阶段,网页可见,但没有输入焦点,无法接受输入。UI 更新(比如动画)仍然在执行。该阶段可能发生在桌面同时有多个窗口时,或者存在内嵌的Iframe页面时。

Hidden

在 Hidden 阶段,网页不可见,当前网页可能被被其他窗口占据,由于未被冻结,页面中除了UI 更新不再执行外,其他的任务不会被浏览器限制。

Terminated

在 Terminated 阶段,由于用户主动关闭窗口,或者在同一个窗口前往其他页面,导致当前页面开始被浏览器卸载并从内存中清除。

这个阶段会导致网页卸载,任何新任务都不会在这个阶段启动,并且如果运行时间太长,正在进行的任务可能会被终止。

Frozen

如果网页处于 Hidden 阶段的时间过久,用户又不关闭网页,浏览器就有可能冻结网页,使其进入 Frozen 阶段。不过,处于Passive状态的页面长时间没有操作也有可能会进入 Frozen 阶段。

Discarded

如果网页长时间处于 Frozen 阶段,用户又不唤醒页面,那么就会进入 Discarded 阶段,即浏览器自动卸载网页,清除该网页的内存占用。

生命周期事件

focus & blur

焦点变化会导致页面在 active 和 passive 两个状态间切换,可以使用 document.hasFocus() API 来得到页面当前的状态。在焦点状态发生变化时,还会在 window 上触发 focusblur 事件。

  • $focus$事件在页面获得输入焦点时触发,比如网页从 $Passive$ 阶段变为 $Active$ 阶段。

  • $blur$事件在页面失去输入焦点时触发,比如网页从 $Active$ 阶段变为 $Passive$ 阶段。

visibilitychange

当页面处于可见状态时document.visibilityStatevisible;当页面不可见(例如:浏览器最小化,或用户切换至其他浏览器标签页)时document.visibilityStatehidden 。而当页面可见状态一旦发生变化时,浏览器就会触发visibilitychange 事件。

因此我们可以通过这个套API来监听Passive和Hidden状态之间的转换。

1
2
3
4
5
6
7
8
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') {
// 页面状态为HIDDEN (用户离开了当前页面)
}
if (document.visibilityState === 'visible') {
// 页面状态为PASSIVE (用户打开或回到页面)
}
});

freeze & resume

页面在冻结之前的状态一定是HIDDEN,这也符合我们之前说的:长期处于后台的Tab或者页面跳转导致触发bfcache时有可能会引起页面冻结

  • freeze事件会在页面在进入冻结状态时触发。

  • resume事件在页面从冻结状态被唤醒时触发。

pageshow

pageshow事件在用户加载网页时触发(这个事件的名字有点误导,它跟页面的可见性其实毫无关系,只跟浏览器的 History 记录的变化有关)

此时有可能是全新的页面加载,也可能是bfcache中获取的页面。如果是通过bfcache获取,则该事件对象的event.persisted属性为true,否则为false。

pagehide

pagehide事件和pageshow一样跟网页是否可见也无关,这个事件在用户关闭或切换当前网页时触发。如果浏览器能够将当前页面添加bfcache,则事件对象的event.persisted属性为true并且页面将进入 Frozen 状态,否则进入 Terminated 状态。

beforeunload

beforeunload事件在窗口或文档即将卸载时触发。该事件发生时,卸载仍可取消。

unload

unload事件在页面在卸载时触发。经过这个事件,网页进入 Terminated 状态。

🌰页面生命周期中的事件变化

这里准备了一个日志页面,能够记录页面生命周期变化过程中所触发事件信息:

https://codepen.io/jackym06/full/QWvXoLd

建议与实践

生命周期各状态下的开发建议

作为开发者,了解页面生命周期状态并知道如何在代码中观察它们很重要,因为我们所做的工作类型很大程度上取决于页面处于什么状态。

Active

Active状态是页面响应用户交互最重要的时间,响应速度将直接影响首次输入延迟FID

FID是一个页面性能标准, 用于衡量网站的交互相应速度,测量方式为首次收到用户交互事件的时间点与主线程下一次空闲的时间点之间的差值。

因此开发者需要评估可能阻塞主线程的非 UI 工作, 考虑:

  • 暂缓执行这类任务(可以使用setTimeout 延迟求值、requestIdleCallback空闲求值)

  • 放到Web-Worker里处理

Passive

Passive状态下,页面仍然是可见的,但是用户不会和页面发生交互,所以此时可以考虑:

  • 执行Active状态时暂缓的工作

  • 检查或持久化处理用户在Active时更改的页面内容

Hidden

由于页面接下来的状态可能是终止(TERMINATED),所以Hidden状态通常也是开发者可以可靠观察到的最后一个状态变化。因此当页面变为Hidden时,应当视为当前用户的会话可能已经结束,此时可以考虑:

  • 保存任何未保存的网页状态(更改的表单数据、当前页面滚动位置等)

  • 发送任何未发送的埋点数据。

  • 停止进行 UI 更新、停止用户不希望在后台运行的任务,例如视频播放等。

Frozen

在Frozen状态下,任务队列中未执行的任务会被被挂起,直到页面解冻继续执行。

这也就意味着当页面从Hidden变为Frozen时,需要开发者主动终止一些任务,否则当解冻后执行之前挂起的任务时会发生异常:

例如依赖连接的任务:EventSource、IndexedDB等,在冻结后由于网络连接或数据库连接会断开,因此可以在freeze事件中,将这些连接做好中断处理;等到Frozen转换回Hidden时(resume),再重新进行连接。

WebSocket、WebRTC这两种连接服务由于浏览器会主动保护所在页面不被冻结,所以可以不用考虑他们的中断重连。

Terminated

终止状态下通常网页通常无法执行任何操作,如果业务场景需要判断页面被终止,在生命周期中看起来有三个选择:unloadbeforeunloadpagehide,实际上我们没得选:在典型的一些页面场景上(例如直接从任务管理器中直接将浏览器关闭),这几个事件都无法触发。

如果你可以忍受这种不可靠,也请优先使用pagehide事件来判断,原因在后文中会进行解释

而页面在Terminated前总是会经历Hidden状态,因此一些页面会话结束的逻辑应该在Hidden状态时去做才较为可靠。

Discarded

在丢弃页面时不会触发任何回调,因此开发者无法观察到的,原因是页面被丢弃前通常处于Frozen状态并且处于资源限制状态,因此仅仅为了去响应丢弃事件而去解冻页面是不合理的。

所以我们应该为在状态从Hidden更改为冻结时就做好页面被丢弃的准备,然后通过document.wasDiscarded 属性在页面加载时检查页面是否被丢弃过(如恢复被丢弃之前的页面持久化的状态等)。

拥抱bfcache

之前有聊到,bfcache可以很好的提升用户在页面切换过程中的体验,因此我们需要在页面的开发过程中,尽量规避会让浏览器阻止将页面移入bfcache的情况(或者说尽量让页面达到让浏览器能够冻结的标准):

  • 不要在现代浏览器上使用unload事件,前面有提到一旦注册unload会导致页面无法使用bfcache,并且我们完全可以使用pagehide替换unload

    1
    2
    3
    4
    5
    6
    7
    window.addEventListener('pagehide', event => {
    if(event.persisted) {
    // 下一个状态为 Frozen (bfcache)
    } else {
    // 下一个状态为 Terminated
    }
    })
  • 当页面不需要使用时beforeunload的待处理状态时,请将注册的事件回调移除。

    前文有提及,处于beforeunload不论是否处于待处理状态,都不会影响页面冻结,为什么还要移除注册的beforeunload事件呢

    这是由于对于其他一些还未开放页面冻结状态,但又支持bfcache的浏览器来说,他们对bfcache的处理的是存在差异的:例如fireFox对于激活bfcache的条件是页面既不能注册unload也不能有注册beforeunload事件。

    所以为了提高兼容性,请在没有必要时移除注册的beforeunload事件

总结

标准化页面生命周期的本质是当浏览器或者用户改变页面状态时,开发者能够感知和预测页面的下一步状态。作为开发者在了解整个页面生命周期的走向后,就能够在各种状态进行对应的优化处理,从而提升用户在使用网页时的体验。

参考资料

https://docs.google.com/document/d/1QJpuBTdllLVflMJSov0tlFX3e3yfSfd_-al2IBavbQM/

https://github.com/WICG/page-lifecycle/blob/main/README.md

https://developer.chrome.com/blog/page-lifecycle-api/

http://www.ruanyifeng.com/blog/2018/11/page_lifecycle_api.html

https://wicg.github.io/page-lifecycle/

https://segmentfault.com/a/1190000016573872

https://web.dev/bfcache/

https://developer.mozilla.org/zh-CN/docs/Mozilla/Firefox/Releases/1.5/Using_Firefox_1.5_caching

0%