页面生命周期 API

浏览器支持

  • 68
  • 79
  • x
  • x

当今的现代浏览器有时会在系统资源受限时暂停或完全舍弃页面。将来,浏览器希望主动执行此操作,以降低功耗和内存。Page Lifecycle API 提供生命周期钩子,确保您的网页可以安全地处理这些浏览器干预,而不会影响用户体验。请查看该 API,以了解是否应在应用中实现这些功能。

背景

应用生命周期是现代操作系统管理资源的关键方式。在 Android、iOS 和最新的 Windows 版本中,操作系统可以随时启动和停止应用。这样一来,这些平台便可以在对用户最有利的位置简化和重新分配资源。

在 Web 上,历史上没有这样的生命周期,应用可以无限期地保持活跃状态。随着大量网页在运行,内存、CPU、电池和网络等关键系统资源可能会超额订阅,从而导致糟糕的最终用户体验。

虽然 Web 平台长期以来存在与生命周期状态相关的事件(例如 loadunloadvisibilitychange),但这些事件只允许开发者响应用户发起的生命周期状态变化。为了使 Web 在低功耗设备上可靠运行(并且在所有平台上一般更注重资源),浏览器需要一种主动回收和重新分配系统资源的方法。

事实上,如今的浏览器已采取积极措施来节省后台标签页中的网页的资源,并且许多浏览器(尤其是 Chrome)想要在这方面执行更多工作,以减少总体资源占用空间。

问题在于,开发者目前没有办法为此类系统发起的干预措施做好准备,甚至不知道这些干预措施正在发生。这意味着浏览器需要保守,否则存在破坏网页的风险。

Page Lifecycle API 尝试通过以下方式解决此问题:

  • 引入并标准化 Web 上的生命周期状态的概念。
  • 定义新的系统启动状态,以允许浏览器限制隐藏标签页或非活跃标签页可以使用的资源。
  • 创建新的 API 和事件,以允许 Web 开发者响应这些新的系统启动状态的转换。

该解决方案提供了 Web 开发者构建抵御系统干预所需的应用所需的可预测性,同时允许浏览器更积极地优化系统资源,最终使所有 Web 用户受益。

这篇博文的其余部分将介绍新的页面生命周期功能,并探索这些功能与所有现有 Web 平台状态和事件之间的关系。此外,本文还会针对开发者应该(以及不应该)在每个状态下应做的工作类型提供建议和最佳做法。

页面生命周期状态和事件概览

所有页面生命周期状态都是离散且互斥的,这意味着一个页面一次只能处于一种状态。通常,网页生命周期状态的大多数更改都可以通过 DOM 事件进行观察(如需了解例外情况,请参阅针对每种状态的开发者建议)。

要解释页面生命周期状态(以及指示页面生命周期状态之间的转换事件),最简单的方式也许就是用下图:

页面生命周期 API 状态和事件流。本文档通篇所描述的状态和事件流的可视化表示。

下表详细介绍了每种状态。还列出了在之前和之后可能出现的状态,以及开发者可用于观察变化的事件。

状态 说明
有效

如果某个页面可见且具有输入焦点,则该页面将处于“活跃”状态。

之前可能出现的状态
被动 (通过 focus 事件)
已冻结 (通过 resume 事件,然后是 pageshow 事件)

可能的后续状态
被动 (通过 blur 事件)

被动式

如果页面可见且没有输入焦点,则会处于“被动”状态。

之前可能出现的状态
活跃 (通过 blur 事件)
hidden (通过 visibilitychange 事件)
已冻结 (通过 resume 事件,然后是 事件){2pageshow

后续可能的状态
有效 (通过 focus 事件)
hidden (通过 visibilitychange 事件)

隐藏

如果网页不可见(并且未冻结、舍弃或终止),则处于隐藏状态。

之前可能出现的状态
被动 (通过 visibilitychange 事件)
已冻结 (通过 resume 事件,然后是 pageshow 事件)

后续可能出现的状态
被动 (通过 visibilitychange 事件)
已冻结 (通过 freeze 事件)
已舍弃 (没有触发任何事件)
已终止 (没有触发事件)

冻结

处于冻结状态时,浏览器会暂停执行页面的 任务队列 可冻结的 任务,直到页面解除冻结为止。这意味着 JavaScript 计时器和提取回调等内容不会运行。已在运行的任务可以完成(最重要的是 freeze 回调),但它们可以执行的操作和可以运行的时长可能会受到限制。

浏览器冻结页面是为了保留 CPU/电池/数据流量使用情况;同时也是为了实现更快的 往返导航速度,而无需重新加载整个网页。

之前可能出现的状态
隐藏 (通过 freeze 事件)

可能的后续状态
活跃 (通过 resume 事件,然后是 pageshow 事件)
被动 pageshowpageshow事件
(通过隐藏事件) 20}


resume

已终止

一旦网页开始被浏览器卸载并从内存中清除,它就会处于“终止”状态。在此状态下,无法启动任何 新任务,如果进行中的任务运行时间过长,可能会被终止。

之前可能出现的状态
隐藏 (通过 pagehide 事件)

可能的后续状态

已舍弃

在浏览器取消加载网页时,该网页会处于“已舍弃”状态,以节省资源。任何任务、事件回调或任何类型的 JavaScript 都无法在这种状态下运行,因为舍弃通常发生在资源限制下,在资源限制下,无法启动新进程。

处于“已舍弃”状态的标签页本身(包括标签页标题和网站图标)通常对用户可见,即使相应页面已消失也是如此。

之前可能出现的状态
隐藏 (未触发任何事件)
已冻结 (未触发任何事件)

可能的后续状态

活动

浏览器会分派大量事件,但只有一小部分表示页面生命周期状态可能发生变化。下表列出了与生命周期相关的所有事件,并列出了它们可能会转换进出的状态。

名称 具体说明
focus

某个 DOM 元素已获得焦点。

注意focus 事件不一定会预示状态发生变化,只有当网页之前没有输入焦点时,它才会指示状态发生变化。

之前可能出现的状态
被动

可能的当前状态
活跃

blur

某个 DOM 元素失去了焦点。

注意blur 事件不一定会预示状态发生变化,只有当网页不再具有输入焦点(即网页不仅仅将焦点从一个元素切换到另一个元素)时,它才会指示状态发生变化。

之前可能的状态
活跃

可能的当前状态
被动

visibilitychange

文档的 visibilityState 值已更改。当用户导航到新页面、切换标签页、关闭标签页、最小化或关闭浏览器,或者在移动操作系统上切换应用时,就可能会发生这种情况。

之前可能出现的状态
被动
隐藏

可能的当前状态
被动
隐藏

freeze *

该网页刚刚被冻结了。页面任务队列中的任何 可冻结任务都不会启动。

之前的可能状态
隐藏

可能的当前状态
“已冻结”

resume *

浏览器恢复了冻结的网页。

之前可能出现的状态
已冻结

可能的当前状态
活跃 (如果后面是 pageshow 事件)
被动 (如果后面是 pageshow 事件)
hidden

pageshow

正在遍历会话历史记录条目。

这可能是全新的网页加载,也可能是从往返缓存中获取的网页。如果网页是从往返缓存中获取的,则事件的 persisted 属性为 true,否则为 false

之前可能出现的状态
已冻结 (也会触发 resume 事件)

可能的当前状态
活跃
被动
隐藏

pagehide

正在遍历会话历史记录条目。

如果用户正在导航到另一个网页,并且浏览器能够将当前网页添加到往返缓存以供日后重复使用,则该事件的 persisted 属性为 true。如果值为 true,则网页将进入冻结状态,否则将进入“终止”状态。

之前的可能状态
隐藏

可能的当前状态
已冻结 event.persisted 为 true,后跟 freeze 事件)
terminated event.persisted 为 false, unload 事件紧随其后)

beforeunload

即将取消加载该窗口、该文档及其资源。 此时,文档仍然可见,并且活动仍可取消。

重要提示beforeunload 事件只能用于提醒用户有未保存的更改。保存这些更改后,应移除该事件。绝不能无条件地将其添加到页面中,因为在某些情况下,这样做会降低性能。如需了解详情,请参阅旧版 API 部分

之前的可能状态
隐藏

可能的当前状态
terminated

unload

正在卸载该网页。

警告:绝不建议使用 unload 事件,因为它不可靠,并且在某些情况下可能会影响性能。如需了解详情,请参阅旧版 API 部分

之前的可能状态
隐藏

可能的当前状态
terminated

* 表示由 Page Lifecycle API 定义的新事件

Chrome 68 中添加的新功能

上图显示了两种由系统启动(而非用户启动)的状态:已冻结和已舍弃。 如上所述,如今的浏览器已偶尔(可自行决定)冻结和舍弃隐藏的标签页,但开发者无法得知发生这种情况的时间。

在 Chrome 68 中,开发者现在可以通过监听 document 上的 freezeresume 事件来观察隐藏的标签页何时冻结和取消冻结。

document.addEventListener('freeze', (event) => {
  // The page is now frozen.
});

document.addEventListener('resume', (event) => {
  // The page has been unfrozen.
});

在 Chrome 68 中,document 对象现在也包含 wasDiscarded 属性。如需确定某个网页是否在隐藏标签页中被舍弃,您可以在网页加载时检查此属性的值(注意:必须重新加载已舍弃的网页才能再次使用)。

if (document.wasDiscarded) {
  // Page was previously discarded by the browser while in a hidden tab.
}

如需获得建议,了解应在 freezeresume 事件中完成哪些重要事项,以及如何处理要舍弃的网页并做好准备,请参阅针对每种状态的开发者建议

下面几个部分将简要介绍这些新功能如何适应现有 Web 平台状态和事件。

在代码中观察页面生命周期状态

主动被动隐藏状态下,可以运行 JavaScript 代码,根据现有的网络平台 API 确定当前的页面生命周期状态。

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

另一方面,冻结状态和已终止状态只能在其各自的事件监听器(freezepagehide)中检测到,因为状态发生变化时。

观察状态变化

基于上面定义的 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;
  }
};

// Options used for all event listeners.
const opts = {capture: true};

// 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(), opts));
});

// 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');
}, opts);

window.addEventListener('pagehide', (event) => {
  // If the event's persisted property is `true` the page is about
  // to enter the back/forward cache, which is also in the frozen state.
  // If the event's persisted property is not `true` the page is
  // about to be unloaded.
  logStateChange(event.persisted ? 'frozen' : 'terminated');
}, opts);

上述代码会执行三项操作:

  • 使用 getState() 函数设置初始状态。
  • 定义接受下一个状态的函数,并在发生更改时将状态更改记录到控制台。
  • 为所有必要的生命周期事件添加捕获事件监听器,而该事件监听器会依次调用 logStateChange() 并传入下一个状态。

关于上述代码,有一点需要注意,所有事件监听器都会添加到 window,并且它们都会传递 {capture: true}。导致这种情况的原因有以下几种:

  • 并非所有页面生命周期事件都具有相同的目标。系统会对 window 触发 pagehidepageshow;对 document 触发 visibilitychangefreezeresume,并针对它们各自的 DOM 元素触发 focusblur
  • 其中大多数事件都不会冒泡,这意味着,如果将非捕获事件监听器添加到共同祖先元素中并观察所有事件,则不可能做到。
  • 捕获阶段在目标阶段或气泡阶段之前执行,因此在此处添加监听器有助于确保它们在其他代码可以取消它们之前运行。

开发者针对每个州提供的建议

作为开发者,您有必要了解页面生命周期状态了解如何在代码中观察这些状态,因为您应该(且不应该)执行的工作类型在很大程度上取决于页面所处的状态。

例如,如果网页处于隐藏状态,向用户显示瞬时通知显然没有意义。虽然此示例非常显而易见,但还有其他建议并不那么明显,值得列举。

状态 开发者建议
Active

活跃状态是用户最关键的时刻,因此也是网页 响应用户输入的最重要时间。

任何可能阻塞主线程的非界面工作都应该降低为 空闲期,或者 分流到 Web 工作器

Passive

处于被动状态时,用户没有与网页互动,但仍可以查看该网页。这意味着界面更新和动画应该仍然很流畅,但发生这些更新的时间不太重要。

当页面从活跃更改为被动时,最好保留未保存的应用状态。

Hidden

当页面从“被动”变为“隐藏”时,在页面重新加载之前,用户有可能不再与页面互动。

向隐藏状态的过渡通常也是开发者能够可靠观察到的最后一个状态变化(在移动设备上尤其如此,因为用户可以关闭标签页或浏览器应用本身,并且在这些情况下不会触发 beforeunloadpagehideunload 事件)。

这意味着您应该将隐藏状态视为用户会话可能结束。换言之,应保留任何未保存的应用状态并发送所有未发送的分析数据。

您还应停止进行界面更新(因为用户不会看到更新),并且应停止用户不希望在后台运行的任何任务。

Frozen

冻结状态下, 任务队列中的 可冻结任务会挂起,直到页面解除冻结为止,但这种情况可能永远不会发生(例如,如果页面被舍弃)。

也就是说,当网页从“隐藏”更改为“已冻结”时,您必须停止所有计时器或断开所有连接,因为这些连接如果冻结,可能会影响同一源中其他打开的标签页,或影响浏览器能否将该网页放入 往返缓存

尤其要注意的是:

此外,您还应将任何动态视图状态(例如,无限列表视图中的滚动位置)保留到 sessionStorage(或通过 commit() 恢复 IndexedDB),以便在网页被舍弃并稍后重新加载时恢复该状态。

如果页面从“冻结”转换回“隐藏”,您可以重新打开所有已关闭的连接,或重新开始在该页面最初冻结时停止的任何轮询。

Terminated

通常,当网页转换为“已终止”状态时,您无需执行任何操作。

由于因用户操作而取消加载的网页总是在进入“已终止”状态之前进入“隐藏”状态,因此应在进入“隐藏”状态时执行会话结束逻辑(例如,保持应用状态并向分析报告)。

此外(正如针对隐藏状态的建议中所述),开发者必须认识到,在许多情况下(尤其是在移动设备上)无法可靠地检测到向“已终止”状态的过渡,因此依赖于终止事件(例如 beforeunloadpagehideunload)的开发者可能会丢失数据。

Discarded

在网页被舍弃时,开发者无法观察到“已舍弃”状态。这是因为网页通常会因资源限制而被舍弃,在大多数情况下,仅仅为了允许脚本运行来响应舍弃事件而取消冻结网页是不可能的。

因此,您应做好准备,以便在从“隐藏”更改为“冻结”时可能会被舍弃,然后可通过检查 document.wasDiscarded 在页面加载时对被舍弃页面的恢复做出响应。

同样,由于并非所有浏览器都能够一致地实现生命周期事件的可靠性和排序,因此,遵循上表中的建议最简单的方法是使用 PageLifecycle.js

应避免使用的旧版生命周期 API

unload 事件

许多开发者将 unload 事件视为有保证的回调,并将其用作会话结束信号以保存状态和发送分析数据,但这样做非常不可靠,尤其是在移动设备上!unload 事件在许多典型的卸载情况下不会触发,包括从移动设备上的标签页切换器关闭标签页,或从应用切换器关闭浏览器应用。

因此,最好依赖于 visibilitychange 事件来确定会话何时结束,并将隐藏状态视为保存应用和用户数据的最后可靠时间

此外,仅仅存在注册的 unload 事件处理脚本(通过 onunloadaddEventListener())可能会导致浏览器无法将网页放入往返缓存中以实现更快的往返缓存。

在所有新型浏览器中,建议始终使用 pagehide 事件(而不是 unload 事件)来检测可能的网页卸载情况(也称为“终止”状态)。如果您需要支持 Internet Explorer 10 及更低版本,则应检测 pagehide 事件,并且仅在浏览器不支持 pagehide 时使用 unload

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

window.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.
});

beforeunload 事件

beforeunload 事件也有与 unload 事件类似的问题,因为从历史上看,beforeunload 事件的存在可能会导致网页无法储存至往返缓存。现代浏览器没有此限制。不过,作为预防措施,某些浏览器在尝试将网页放入往返缓存时不会触发 beforeunload 事件,这意味着事件不能作为会话结束信号的可靠事件。此外,某些浏览器(包括 Chrome)需要先在网页上进行用户互动,然后才能触发 beforeunload 事件,从而进一步影响其可靠性。

beforeunloadunload 之间的一个区别在于,beforeunload 的合法用法。例如,如果您想警告用户,他们有未保存的更改,如果他们继续卸载页面,这些更改将会丢失。

使用 beforeunload 是有正当理由的,因此建议您在用户有未保存的更改时添加 beforeunload 监听器,然后在保存后立即将其移除。

换句话说,不要这样做(因为它会无条件地添加 beforeunload 监听器):

addEventListener('beforeunload', (event) => {
  // A function that returns `true` if the page has unsaved changes.
  if (pageHasUnsavedChanges()) {
    event.preventDefault();

    // Legacy support for older browsers.
    return (event.returnValue = true);
  }
});

请改为执行以下操作(因为它仅在需要时添加 beforeunload 监听器,而在不需要时将其移除):

const beforeUnloadListener = (event) => {
  event.preventDefault();
  
  // Legacy support for older browsers.
  return (event.returnValue = true);
};

// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
  addEventListener('beforeunload', beforeUnloadListener);
});

// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
  removeEventListener('beforeunload', beforeUnloadListener);
});

常见问题解答

为什么没有“正在加载”状态?

Page Lifecycle API 将状态定义为离散和互斥状态。由于网页能够以主动、被动或隐藏状态加载,并且可能会在加载完成之前更改状态,甚至被终止,因此在此范例下,单独的加载状态没有意义。

我的网页在隐藏状态下发挥重要作用,如何防止它被冻结或舍弃?

在隐藏状态下运行时,网页不应冻结,有很多合理原因。最明显的例子就是播放音乐的应用。

此外,Chrome 舍弃某个网页也存在风险,例如,其中包含的表单包含未提交的用户输入,或者包含会在网页卸载时发出警告的 beforeunload 处理程序。

目前,Chrome 在舍弃页面时仍会较为保守,并且仅在确信此类操作不会影响用户时才这样做。例如,在隐藏状态下被观察到执行了以下任何操作的网页不会被舍弃,除非在极端资源限制下:

  • 正在播放音频
  • 使用 WebRTC
  • 更新表格标题或网站图标
  • 显示提醒
  • 发送推送通知

如需了解用于确定标签页是否可以安全冻结或舍弃的当前列表功能,请参阅 Chrome 中的冻结和舍弃启发式方法

什么是往返缓存?

“往返缓存”是一个术语,用于描述某些浏览器实现的、能够更快地使用后退和前进按钮的导航优化。

当用户离开某个页面时,这些浏览器会冻结该页面的一个版本,以便在用户使用后退或前进按钮返回时可以快速恢复该页面。请注意,添加 unload 事件处理脚本将无法进行此优化

对于所有 intent 和目的而言,这种冻结在功能上与冻结浏览器为节省 CPU/电池电量而执行的操作相同;因此,它被视为生命周期状态的一部分。

如果我无法在冻结或终止状态下运行异步 API,如何将数据保存到 IndexedDB?

在冻结和终止状态下,页面的任务队列中的可冻结任务会挂起,这意味着异步和基于回调的 API(如 IndexedDB)将无法可靠地使用。

将来,我们将IDBTransaction 对象添加 commit() 方法,让开发者能够执行实际上不需要回调的只写事务。换句话说,如果开发者只是将数据写入 IndexedDB,而不执行包含读写的复杂事务,则 commit() 方法能够在任务队列挂起之前完成(假设 IndexedDB 数据库已经打开)。

不过,对于目前需要正常运行的代码,开发者有两种选择:

  • 使用会话存储会话存储是同步的,在页面舍弃后保持不变。
  • 通过 Service Worker 使用 IndexedDB:Service Worker 可以在页面被终止或舍弃后,将数据存储在 IndexedDB 中。在 freezepagehide 事件监听器中,您可以通过 postMessage() 向 Service Worker 发送数据,Service Worker 可以负责保存数据。

在冻结和舍弃状态下测试应用

如需测试应用在冻结和舍弃状态下的行为,您可以访问 chrome://discards 以实际冻结或舍弃打开的任何标签页。

Chrome 舍弃界面

这样,当页面在舍弃后重新加载时,您可以确保页面能够正确处理 freezeresume 事件以及 document.wasDiscarded 标志。

摘要

如果开发者希望尊重用户设备的系统资源,在构建应用时应考虑页面生命周期状态。请务必确保网页不会在用户没有预料到的情况下消耗过多的系统资源

开发者开始实现新的 Page Lifecycle API 越多,浏览器就越安全,冻结并舍弃未使用的页面。这意味着浏览器将消耗更少的内存、CPU、电池和网络资源,对用户来说是一大胜利。