页面生命周期 API

浏览器支持

  • Chrome:68.
  • Edge:79。
  • Firefox:不受支持。
  • Safari:不支持。

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

背景

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

以前,网络上没有这种生命周期的概念,应用可以无限期地处于活动状态。当有大量网页运行时,内存、CPU、电池和网络等关键系统资源可能会被过度订阅,从而导致最终用户体验不佳。

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

事实上,当今的浏览器已经采取了积极的措施来为后台标签页中的网页节省资源,并且许多浏览器(尤其是 Chrome)还希望采取更多措施来减少其总体资源占用量。

问题在于,开发者无法为此类系统发起的干预做准备,甚至不知道这些干预正在发生。这意味着浏览器需要采取保守措施,否则可能会破坏网页。

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

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

此解决方案可为 Web 开发者提供可预测性,使其能够构建对系统干预具有弹性的应用,并允许浏览器更积极地优化系统资源,最终让所有 Web 用户受益。

本文的其余部分将介绍新的网页生命周期功能,并探讨这些功能与所有现有网络平台状态和事件之间的关系。它还会就开发者应(不应)在每个状态执行的工作类型提供建议和最佳实践。

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

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

要说明网页生命周期状态以及指示这些状态之间转换的事件,最简单的方法可能是使用图表:

直观呈现本文档中介绍的状态和事件流程。
Page Lifecycle API 状态和事件流程。

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

说明
有效

如果页面可见且具有输入焦点,则处于活动状态。

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

可能的下一步状态
被动 (通过 blur 事件)

被动

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

可能的先前状态
有效 (通过 blur 事件)
已隐藏 (通过 visibilitychange 事件)
已冻结 (通过 resume 事件,然后是 pageshow 事件)

接下来可能会出现的状态
有效 (通过 focus 事件)
已隐藏 (通过 visibilitychange 事件)

已隐藏

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

可能的先前状态:
被动 (通过 visibilitychange 事件)
冻结 (通过 resume 事件,然后是 pageshow 事件)

可能的下一个状态:
passive (通过 visibilitychange 事件)
frozen (通过 freeze 事件)
discarded (未触发任何事件)
terminated (未触发任何事件)

已冻结

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

浏览器会冻结网页以节省 CPU/电池/流量用量;此外,浏览器还会冻结网页以实现更快的返回/前进导航,从而避免需要重新加载整个网页。

可能的先前状态:
已隐藏 (通过 freeze 事件)

可能的下一步状态:
有效 (通过 resume 事件,然后是 pageshow 事件)
被动 (通过 resume 事件,然后是 pageshow 事件)
已隐藏 (通过 resume 事件)
已舍弃 (未触发任何事件)

已终止

当浏览器开始卸载页面并将其从内存中清除后,该页面就会处于终止状态。在此状态下,无法启动任何新任务,并且如果正在进行的任务运行时间过长,系统可能会将其终止。

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

可能的下一步状态

已舍弃

为了节约资源,当页面被浏览器卸载时,页面会处于已舍弃状态。在这种状态下,任何任务、事件回调或任何类型的 JavaScript 都无法运行,因为舍弃通常发生在资源受限的情况下,而在此类情况下,无法启动新进程。

已舍弃状态下,即使网页已消失,标签页本身(包括标签页标题和 Favicon)通常仍会向用户显示。

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

后续可能的状态

事件

浏览器会分派很多事件,但只有一小部分事件会表明页面生命周期状态可能发生变化。下表概述了与生命周期相关的所有事件,并列出了它们可能会转换到哪些状态以及从哪些状态转换。

名称 详细信息
focus

某个 DOM 元素已获得焦点。

注意focus 事件不一定表示状态发生变化。只有在页面之前没有输入焦点时,它才会发出状态更改信号。

可能的前置状态:
passive

可能的当前状态:
有效

blur

某个 DOM 元素失去了焦点。

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

之前可能的状态
有效

当前可能的状态
被动

visibilitychange

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

可能的前状态
passive
hidden

可能的当前状态:
被动
已隐藏

freeze *

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

可能的前置状态:
hidden

可能的当前状态
冻结

resume *

浏览器已恢复冻结的页面。

可能的前状态
冻结

可能的当前状态:
有效 (如果后跟 pageshow 事件)
被动 (如果后跟 pageshow 事件)
隐藏

pageshow

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

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

可能的先前状态:
冻结 (系统还会触发 resume 事件)

可能的当前状态:
有效
无效
隐藏

pagehide

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

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

可能的前置状态:
hidden

可能的当前状态:
已冻结 event.persisted 为 true, followed by freeze event)
已终止 event.persisted 为 false,followed by unload event)

beforeunload

窗口、文档及其资源即将被卸载。 此时,文档仍可见,并且事件仍可取消。

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

可能的前置状态:
hidden

可能的当前状态:
terminated

unload

正在卸载该页面。

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

可能的前置状态:
hidden

可能的当前状态
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 对象现在在桌面版 Chrome 中包含 wasDiscarded 属性(此问题正在跟踪 Android 支持)。如需确定页面在隐藏标签页中是否被舍弃,您可以在页面加载时检查此属性的值(注意:已舍弃的页面必须重新加载才能再次使用)。

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

如需有关在 freezeresume 事件中执行哪些重要操作以及如何处理和准备页面被舍弃的建议,请参阅针对各个状态的开发者建议

接下来的几个部分将简要介绍这些新功能如何融入现有网络平台状态和事件。

如何在代码中观察网页生命周期状态

活动被动隐藏状态下,您可以运行 JavaScript 代码,通过现有的 Web 平台 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}。导致这种情况的原因有以下几种:

  • 并非所有页面生命周期事件都有相同的目标。pagehidepageshowwindow 上触发;visibilitychangefreezeresumedocument 上触发;focusblur 在各自的 DOM 元素上触发。
  • 其中大多数事件都不会冒泡,这意味着无法向共同的祖先元素添加非捕获事件监听器并监听所有事件。
  • 捕获阶段会在目标阶段或冒泡阶段之前执行,因此在该阶段添加监听器有助于确保它们在其他代码取消它们之前运行。

针对每种状态的开发者建议

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

例如,如果网页处于隐藏状态,向用户显示暂时性通知显然是不合理的。虽然这个示例非常明显,但还有一些建议不太明显,值得列举出来。

开发者建议
Active

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

任何可能阻塞主线程的非界面工作都应降优先级到 空闲时段 分流到 Web Worker

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 事件处理脚本会导致无法进行此优化

从所有方面来说,这种冻结在功能上与浏览器为了节省 CPU/电池而执行的冻结相同;因此,它被视为冻结生命周期状态的一部分。

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

在冻结和终止状态下,网页的任务队列中的可冻结任务会被暂停,这意味着无法可靠地使用 IndexedDB 等基于回调的异步 API。

未来,我们将IDBTransaction 对象添加 commit() 方法,以便开发者执行无需回调的有效写入操作。换句话说,如果开发者只是将数据写入 IndexedDB,而不是执行包含读取和写入的复杂事务,则 commit() 方法将能够在任务队列暂停之前完成(假设 IndexedDB 数据库已打开)。

不过,对于需要立即运行的代码,开发者有两种选择:

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

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

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

Chrome 舍弃界面
Chrome 舍弃界面

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

摘要

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

开发者开始实现新的 Page Lifecycle API 的次数越多,浏览器就越能安全地冻结和舍弃未使用的页面。这意味着浏览器消耗的内存、CPU、电池和网络资源更少,这对用户来说是一个不错的选择。