页面生命周期API及H5唤起App踩坑心得

2,681 阅读21分钟

一直以来,向App导流的Web页面面临着无法确定是否成功唤起App的问题。

客户会疑惑明明我已经呼起App了,结果回退到原Web页面却仍旧弹出了诸如“请您前往商城下载”或浏览器弹出“默认下载询问框”等问题。只要能够确定用户确实呼起了,那么只需要清除弹出下载框的定时器即可,即便是已经弹出的下载框,也可以在用户切回页面时关闭/隐藏即可

那么是否有办法判断用户确实已经呼起了App呢?当然有,不过有一些坑。

这里将谷歌开发者关于页面生命周期的文章进行了全文翻译,以飨读者。

页面生命周期和事件总览

所有页面的生命周期状态都是各自独立甚至互斥的,这意味着一个页面一次只能处于一种状态。而且,大多数页面生命周期状态的更改都可以通过 DOM 事件观察到。

也许解释页面生命周期状态之间转换最简单的方法是画张图:

PageLifecycle

状态

下表详细地解释了每种状态和可能的前后状态,以及开发人员可以用来观察更改的事件。

状态 描述
Active 若页面可见且具有输入焦点,则页面处于active状态。

可能的前一个状态
passive(通过focus事件判断)

可能的后一个状态
passive(通过blue事件判断)
Passive 若页面可见但并没有输入焦点,则页面处于passive状态。

可能的前一个状态
active(通过blur事件判断)
hidden(通过visibilitychange事件判断)

可能的后一个状态
active(通过blur事件判断)
hidden(通过visibilitychange事件判断)
Hidden 若页面不可见且并未处于frozen状态,则页面处于hidden状态

可能的前一个状态
passive(通过 visibilitychange事件判断)

可能的后一个状态
passive(通过 visibilitychange事件判断)
frozen(通过freeze事件判断)
terminated(通过pagehide事件判断)
Frozen frozen状态下,浏览器将暂停执行页面任务队列中的可冻结任务,直到页面被解除冻结。这意味着像 JavaScript 定时器和 fetch 回调不会执行。已经运行的任务还是可以完成的(最重要的是冻结回调),但是它们的功能和运行时间可能受到限制。

浏览器冻结页面是为了保护CPU/电池/数据的使用;他们这样做也是为了更快的进行后退/前进导航——避免了重新加载整个页面。

可能的前一个状态
hidden(通过freeze事件判断)

可能的后一个状态
active(先通过resume事件,之后是pageshow事件)
passive(先通过resume事件,之后是pageshow事件)
hidden(通过resume事件)
Terminated 一旦页面开始被浏览器卸载并从内存中清除,它就处于terminated状态。在此状态下不能启动任何新任务,如果运行时间太长,即便是正在运行的任务也可能被终止。

可能的前一个状态
hidden(通过pagehide事件)

可能的后一个状态
Discarded 当浏览器为了节省资源而卸载页面时,它就处于discarded状态。任何类型的任务、事件回调或 JavaScript 都无法在这种状态下运行,因为它通常发生在资源受限的情况下,在这种情况下启动新进程是不可能的。

discarded状态下,页面标签(包括标签标题和favicon)对用户是可见的,即便页面已经消失掉了。

可能的前一个状态
frozen(无事件触发)

可能的后一个状态

事件

浏览器派发的事件众多,但其中只有一小部分表示了页面生命周期状态可能的变化。下表中列出了所有与生命周期相关的事件,并且列出了它们可能是从哪些状态转换而来或转换到哪些状态。

事件名 描述
focus DOM元素获得焦点。

注意:focus事件不一定表示状态改变。它仅仅是在页面从未获得焦点变为获得焦点时发出更改的信号。

可能的前一个状态
passive

可能的当前状态
active
blur DOM元素失去焦点
注意:blur事件不一定表示状态改变。它仅仅是在页面失去输入焦点时发出更改的信号。(即页面不只是将焦点从一个元素切换到另一个元素)

可能的前一个状态
active

可能的当前状态
passive
visibilitychange 文档的visibilityState值发生变化。当用户导航到新页面、切换选项卡、最小化或关闭浏览器、或在移动端切换app时,可能会发生这种情况。

可能的前一个状态
passive
hidden

可能的当前状态
passive
hidden
freeze 页面被冻结了。页面任务队列中的任何可冻结的任务都不会启动。

可能的前一个状态
hidden

可能的当前状态
frozen
resume 浏览器恢复了被冻结的页面。

可能的前一个状态
frozen

可能的当前状态
active(如果紧跟pageshow事件)
passive(如果紧跟pageshow事件)
frozen
pageshow 这个事件的名字有点误导,它跟页面的可见性其实毫无关系,只跟浏览器的 History 记录的变化有关。

pageshow事件在用户加载网页时触发。这时,有可能是全新的页面加载,也可能是从缓存中获取的页面。如果是从缓存中获取,则该事件对象的event.persisted属性为true,否则为false
pagehide pagehide事件在用户离开当前网页、进入另一个网页时触发。它的前提是浏览器的 History 记录必须发生变化,跟网页是否可见无关。

如果浏览器能够将当前页面添加到缓存以供稍后重用,则事件对象的event.persisted属性为true。 如果为true。如果页面添加到了缓存,则页面进入 Frozen 状态,否则进入 terminatied 状态。

可能的前一个状态
hidden

可能的当前状态
frozen(event.persisted值为true, 紧接着是freeze事件)
terminated(event.persisted值为false,紧接着是unload事件)
beforeunload beforeunload事件在窗口或文档即将卸载时触发。该事件发生时,文档仍然可见,此时卸载仍可取消。经过这个事件,网页进入 terminated 状态。

⚠️警告: beforeunload事件应该仅用于警告用户未保存的更改。一旦保存了这些更改,就应该删除事件。不应该无条件地将它添加到页面中,因为这样做在某些情况下会影响性能。

可能的前一个状态
hidden

可能的当前状态
terminated
unload unload事件在页面正在卸载时触发。

⚠️警告:不建议使用unload事件,因为它不可靠,而且在某些情况下会影响性能。

可能的前一个状态
hidden

可能的当前状态
terminated

Chrome 68 版本新增

上面的图表展示了由系统而不是用户发起的两种状态:冻结(frozen)和丢弃(discarded)。正如上文所述,现在的浏览器已经偶尔会冻结和丢弃隐藏的标签(由它们自己决定),但是开发人员无法知道它的触发时机。

在Chrome 68中,开发人员可以通过监听document中的frozenresume事件来观察隐藏的页面标签在什么时间被冻结和解除冻结。

document.addEventListener('freeze', (event) => {
  // 页面被冻结了
});

document.addEventListener('resume', (event) => {
  // 页面已经解除冻结
});

实际上,我们在应用过程中确实看到了生命周期在不同浏览器上的不一致性:

不同浏览器的生命周期

在Chrome 68中,document对象还新增了一个wasDiscarded属性。我们可以在页面加载时检查此属性的值(注意:必须重新加载被丢弃的页面才能再次使用)来确定在隐藏选项卡中某个页面是否被丢弃。

if (document.wasDiscarded) {
  // 在隐藏的选项卡中,页面之前处于被丢弃状态。
}

用来监听页面生命周期状态的代码

activepassivehidden状态下,可以运行JavaScript代码来确定现有Web平台的当前页面生命周期状态。

const getState = () => {
  if (document.visibilityState === 'hidden') {
    return 'hidden';
  }
  if (document.hasFocus()) {
    return 'active';
  }
  return 'passive';
};

另一方面,frozenterminated则只能在其各自的事件侦听器(freezepagehide)中检测到了,因为处于改动状态中。

观察状态更改

基于前文的getState()函数,我们可以按照下面的方法观测页面生命周期的更改。

// 使用 `getState()` 方法存储一下初始状态。
let state = getState();

// 接收下一个状态,如果状态发生了改变,则打印到控制台,同时更新状态。
const logStateChange = (nextState) => {
  const prevState = state;
  if (nextState !== prevState) {
    console.log(`State change: ${prevState} >>> ${nextState}`);
    state = nextState;
  }
};

// 这些生命周期可以使用同一个监听器来观察状态的更改
// 通过调用`getState()`来决定下一个状态,注意都要使用捕获模式
['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach((type) => {
  window.addEventListener(type, () => logStateChange(getState()), {capture: true});
});

// 另一方面,接下来的两个监听器可以从事件本身就确定下一个状态了。
window.addEventListener('freeze', () => {
  // 在 `freeze` 事件中,下一个状态永远是`frozen`
  logStateChange('frozen');
}, {capture: true});

window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    // 如果`event.persisted`值为`true`, 那么页面将进入页面导航缓存,该缓存也处于冻结状态。
    logStateChange('frozen');
  } else {
    // 反之,页面将被卸载
    logStateChange('terminated');
  }
}, {capture: true});

上面的代码,做了三件微小的事。

  • 通过getState()设置初始状态。
  • 定义接受下一个状态的函数,如果有更改,则将状态更改记录到控制台。
  • 为所有必要的生命周期事件添加捕获的事件监听器,它们又反过来调用logStateChange(),并传递下一个状态。

需要注意的一点是,所有的事件监听器都被添加到window上,并且均配置了{capture: true}。原因如下:

  • 并非所有页面生命周期事件都拥有一致的target:
    • pagehidepageshowwindow上触发;
    • visibilitychangefreezeresume则是document
    • focusblur依据的则是其各自的DOM元素。
  • 大多数事件不会冒泡,这意味着不可能将未捕获的事件监听器添加到公共的祖先元素从而进行观察。
  • 捕获阶段在target或冒泡阶段之前执行,因此在那时添加监听器可在其他代码取消它们之前进行。

管理跨浏览器差异

本文开头的图表根据页面生命周期 API 概述了状态流和事件流。但是由于这个 API 刚刚引入,所以新的事件和DOM API并没有在所有浏览器中实现。

此外,目前在所有浏览器中事件的实现的并不一。例如:

  • 有些浏览器在切换标签时并不会触发blur事件。这意味着(与上面的关系图表相反)页面可以从active状态切换至hidden状态且无需经过passive状态。
  • 有一些浏览器实现了页面导航缓存,而页面生命周期 API 将缓存的页面列为冻结状态。由于这个 API 是全新的,所以这些浏览器还没有实现frozenresume事件,但仍然可以通过pagehidepageshow事件。
  • 旧版本的 IE 浏览器(10及以下)并未实现 visibilitychange 事件。
  • pagehidevisibilitychage事件的调度顺序已经更改。在之前的浏览器中,如果页面的可见状态在页面卸载时是可见的,那么浏览器会在pagehide事件后派发visibilitychange。而新的Chrome将在pagehide前派发visibilitychange,不再考虑文档在卸载时的可见性状态了。
  • Safari 浏览器在关闭选项卡时并不能可靠地触发 pagehidevisibilitychange 事件,因此在 Safari 中,您可能还要监听beforeunload事件,以检测隐藏状态的更改。但是因为beforeunload事件可以被取消,所以您需要等到事件完成传播之后才能知道状态是否已经更改为隐藏。重要提示:以这种方式使用beforeunload事件应该只在 Safari 中执行,因为在其他浏览器中使用该事件会影响性能

为了让开发人员更容易地处理这些跨浏览器的不一致问题,且遵循生命周期状态建议和最佳实践,我们发布了 PageLifecycle.js,一个方便你观察页面生命周期API状态变化的 JavaScript 库。

PageLifecycle.js 规范了跨浏览器事件触发顺序的差异,因此状态的变化总是完全按照文中图表和表格中所描述的那样发生(并且在所有浏览器中都是如此,奥利给)。

对每个状态的开发者建议

作为开发人员,理解声明周期状态并了解如何在代码中观察它们很是重要,因为您应该(不应该)做的工作类型很大程度上取决于您的页面处于什么状态。

例如,如果页面处于隐藏状态,向用户显示临时通知显然是没有意义的。虽然这个例子很明显,但是还有一些不太明显的建议需要告诉您。

状态 开发者建议
active active 状态对用户来说至关重要,因此也是页面响应用户输入最重要的时间。

任何可能阻塞主线程的非UI工作都应该优先考虑空闲期,或是将其转给Web Wroker
passive passive 状态下,用户没有与页面进行交互,但仍然可以看到它。这意味着UI更新和动画应该是平滑的,但是更新发生的时间并不重要。

当页面从 active 切换到 passive 状态时,是保存未保存应用状态的好时机。
hidden 当页面从passive切换为hidden状态时,用户可能不会再和它进行交互了,直到它被重新加载。

hidden状态转换通常也是开发人员能够稳妥地观察到的最后一个状态更改 (在移动设备上尤其如此,因为用户可以关闭选项卡或浏览器本身,而在这些情况下,beforeunloadpagehideunload事件不会触发)

这意味着您应该将hidden状态视为可能的用户会话的可能结束。换句话说,这时候应该保存任何未保存的应用状态并发送任何未发送的统计分析数据。

您还应该停止进行UI更新(因为用户根本看不到),并且应该停止用户不希望在后台运行的任务。
frozen frozen状态下,任务队列中的可冻结任务将被挂起,直到该页面被解除冻结——这是不可能的(比方说页面被丢弃)。

这意味着,当页面从hidden状态变为frozen状态时,您必须停止任何计时器或断开任何连接,如果页面被冻结了,这些连接可能会影响相同源中其他打开的选项卡,或是影响浏览器的页面导航缓存能力。

尤其要注意下面这些:

- 关闭所有打开的IndexedDB连接。
- 关闭所有打开的BroadcastChannel连接。
- 关闭激活的WebRTC连接。
- 停止任何网络轮询或关闭任何打开的WebSocket连接。
- 解开所有的Web Locks

您还应该将任何的动态视图装的(例如无限列表中的滚动位置)保存到sessionStorage(或通过commit()IndexedDB恢复),如果页面被丢弃并在稍后重新加载,则需要恢复这些状态。

如果页面从冻结状态转换为隐藏状态,则可以重新打开关闭的连接或重新启动在页面冻结时停止的轮询。
terminated 当页面转换到terminated状态,通常不需要采取任何操作。

由于用户在操作页面进入terminated状态之前肯定经过hidden状态,所以我们应当在hidden状态执行结束会话的逻辑(比如数据上报或保存应用状态)。

同时,这让开发人员意识到在许多情况下(特别是移动端)对转换到terminated状态的检测并不可靠,所以如果开发人员依靠终止类事件(比如,beforeunloadpagehideunload)可能会丢失数据。
discarded 当一个页面被丢弃时,开发人员是无法看到discarded状态的。这是因为页面通常是在资源被约束时被丢弃的。在大多数情况下,仅仅因为仅仅为让脚本响应丢弃事件而解冻页面是不可能的。

因此,您应该为状态从hiddenfrozen更改时出现discard的可能性做准备,然后您可以通过检查document.wasDiscarded属性来对页面加载时恢复被丢弃的页面做出反应。

同样的,由于可靠性和生命周期事件并非在所有浏览器中的表现都一致,因此我们仍旧建议您使用PageLifecycle.js

应该避免使用的传统生命周期API

unload事件

⚠️ 重要:不要在现代浏览器中使用unload事件。

unload事件极为不可靠,特别是在移动设备上!unload事件在许多典型的卸载情况下根本不会触发,比如从移动设备上的选项卡切换器关闭一个选项卡,或是切换应用时关闭浏览器的时候,这些都不会触发。

因此,最好是依靠visibility事件来确定会话何时结束,并将hidden状态是保存应用和用户数据的最后的可靠时间。

此外,只需要一个已经注册的unload事件处理程序(通过onunloadaddEventListener('unload')) 就能够阻止浏览器将页面放入导航缓存中,从而得到更快地向前或向后加载速度。

在所有的现代浏览器(包括IE11)中,建议您总是使用pagehide事件而非unload事件来检测可能的页面卸载(也就是terminated状态)。如果你需要支持IE 10或更低版本,那么就应该特别检测pagehide事件,只有当浏览器不支持pagehide时才使用unload事件:

const terminationEvent = 'onpagehide' in self ? 'pagehide' : 'unload';

addEventListener(terminationEvent, (event) => {
  // 注意:如果浏览器能够缓存页面,那么`event.persisted`的值为`true`,状态则是`frozen`而非`terminated`
}, {capture: true});

beforeunload事件

⚠️重点:永远不要无条件地监听beforeunload事件或将其用作会话结束的信号。只有当用户有未保存的工作时才添加它,并在保存工作后立即删除。

beforeunload事件与unload事件有相似的问题,在出现该事件时,它会阻止浏览器在页面导航缓存中缓存页面。

然而,beforeunloadunload之间的区别在于,beforeunload有合法的用法。举例来说,当您想要警告用户他们有未保存的更改时,如果仍旧继续卸载页面,则会丢失这些更改。

换句话说,不要像下面这样做(因为它无条件地添加了一个beforeunload监听器):

addEventListener('beforeunload', (event) => {
  // 如果页面有未保存的更改,则返回' true '的函数
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = '您确定想要退出么?';
  }
}, {capture: true});

而应该像下面这样(只在需要的时候添加beforeunload监听器,在不需要的时候删除它):

const beforeUnloadListener = (event) => {
  event.preventDefault();
  return event.returnValue = '您确定想要退出么?';
};

// 当页面有未保存的更改时调用回调的函数。
onPageHasUnsavedChanges(() => {
  addEventListener('beforeunload', beforeUnloadListener, {capture: true});
});

// 当保存页面所有更改时调用回调的函数。
onAllChangesSaved(() => {
  removeEventListener('beforeunload', beforeUnloadListener, {capture: true});
});

注意:PageLifecycle.js 提供了很方便的addUnsavedChanges()removeUnsavedChanges()方法,它们遵循了上面列出的所有最佳实践。这些都基于一个草案提案,该提案正式使用声明性的API替换beforeunload事件,声明性API更不容易被滥用,而且在移动平台上更可靠。

如果您希望以跨浏览器的方式正确地使用beforeunload事件,那么建议使用PageLifecycle.js库。

问答

1. 当我的页面被隐藏时,我改如何阻止它被冻结或丢弃?

有很多方法可以让隐藏状态下运行的页面不被冻结。最明显的例子是播放音乐。

在一些情况下,Chrome 放弃一个页面是有风险的,如果它包含了一个未提交的表单,或是拥有一个beforeunload监听器用以在页面卸载时发出警告。

目前,Chrome在丢器页面方便比较保守,只有在确信不会影响用户的情况下才会这么做。例如,除非在极端的资源约束下,否则在隐藏状态下有下列行为的页面将不会被丢弃:

  • 播放音频
  • 使用WebRTC
  • 更新页面titlefavicon
  • 弹出alert
  • 推送notification

注意:在页面更新title或者favicon提醒用户有未读通知时,我们有一个提议,即让这些更新从service worker进行,从而允许Chrome冻结或丢弃页面,但仍显示更改的选项卡titlefavicon

2. 什么是页面导航缓存?

页面导航缓存是一个通用术语,用于描述一些浏览器实现的、使后退和前进按钮使用更快的导航优化。Webkit将其称为页面缓存,Firefox将其称为向后-向前缓存(或简称为bfcache)。

当用户从一个页面导航到另一个页面时,这些浏览器会冻结该页面的一个版本,以便在用户使用后退或前进按钮导航回本页面时可以迅速恢复。请注意,添加beforeunloadunload事件会阻止这种优化。

不管出于怎样的目的,这种冻结在功能上都和冻结浏览器一样,为了节省CPU/电池;因此,它被认为是frozen生命周期状态的一部分。

3. 为什么没有提到loadDOMContentLoaded事件?

页面生命周期API认为状态是离散且相互排斥的。由于页面可以以activepassivehidden状态加载,因此单独的加载状态毫无意义,而且由于loadDOMContentLoaded事件并不表示生命周期状态的更改,所以它们就和该API无缘了。

4. 如果我不能在frozenterminated状态下运行异步API,那我如何将数据保存到 IndexedDB 呢?

frozenterminated状态下,页面任务队列中的可冻结任务会被挂起,这意味着 IndexedDB 等异步和回调类API无法放心使用。

在不久的将来,我们将向IDBTransaction对象添加commit()方法,让开发人员拥有无需回调的写数据方法。换句话说,如果开发人员只是将数据写入IndexedDB而没有执行读写等复杂任务,那么commit()方法将能够在任务队列挂起之前完成(假设IndexedDB数据库已经打开)。

那么对于现在这个时段,开发人员有两个选择:

  • 使用Session StorageSession Storage是同步的,在页面被丢弃时可以跨页持久保存。
  • Service Worker中使用IndexedDB:在页面处于terminateddiscarded状态时,Service Worker可以将数据存在IndexedDB中。 在freezepagehide事件监听器中可以通过postMessage()发送数据到Service Worker, 它可以处理保存数据。

⚠️注意:虽然上面的Service Worker方法可以使用,但在设备内由于内存压力而被迫冻结或丢弃页面的情况下并不理想,因为浏览器可能不得不唤醒Service Worker进程,这将给系统带来更大的压力。

frozendiscarded状态测试您的应用

若您想要测试应用在frozendiscarded状态下的表现,您可以访问chrome://discards来冻结或丢弃任何打开的标签页。

image

这将允许您正确处理页面的freezeresume事件以及页面在丢弃后重新加载的document.wasDiscarded属性。

一言以蔽之

若开发人员想考虑用户设备的系统资源,那么就应当在构建应用时就考虑到页面的生命周期状态。更重要的是,页面不会在用户无意识的情况下消耗过多的系统资源。

此外,开发人员实现的新页面生命周期API越多,则被浏览器冻结和丢弃的页面就越安全。这意味着浏览器将消耗更少的内存、CPU、电池和网络资源,这对用户来说也是一个胜利。

最后,想要实现本文中所描述的最佳实践但又不想记住所有可能的状态和事件转换的开发人员可以使用PageLifecycle.js来轻松地观察符合所有浏览器的一致的生命周期状态变化。