页面生命周期 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 事件不一定表示状态发生变化。只有当页面不再具有输入焦点(即页面并未仅从一个元素切换到另一个元素的焦点)时,才会发出状态更改信号。

之前可能的状态
有效

可能的当前状态:
passive

visibilitychange

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

之前可能的状态
被动
隐藏

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

freeze *

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

可能的前置状态:
hidden

当前可能的状态
冻结

resume *

浏览器恢复了冻结网页。

之前可能的状态
冻结

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

pageshow

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

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

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

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

pagehide

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

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

之前可能显示的状态
已隐藏

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

beforeunload

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

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

可能的前置状态:
hidden

可能的当前状态:
terminated

unload

正在卸载该页面。

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

可能的前置状态:
hidden

当前可能的状态
已终止

* 表示由 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 事件中执行哪些重要操作以及如何处理和准备页面被舍弃的建议,请参阅针对各个状态的开发者建议

接下来的几个部分概述了这些新功能如何融入现有的 Web 平台状态和事件。

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

活动被动隐藏状态下,您可以运行 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

活动状态对用户来说是最关键的时刻,因此网页能够 响应用户输入的最重要时间。

任何可能阻塞主线程的非界面工作都应降低优先级,使之进入 空闲时段 分流到 Web 工作器

Passive

被动状态下,用户不会与页面互动,但仍能看到该页面。这意味着界面更新和动画应该仍然很流畅,但这些更新的发生时间并不那么重要。

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

Hidden

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

转换为隐藏状态通常也是开发者可靠观察到的最后一次状态更改(在移动设备上尤其如此,因为用户可以关闭标签页或浏览器应用本身,在这些情况下,系统不会触发 beforeunloadpagehideunload 事件)。

这意味着,您应将隐藏状态视为用户会话可能结束的状态。也就是说,保留所有未保存的应用状态并发送所有未发送的分析数据。

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

Frozen

冻结状态下,任务队列中的可冻结任务会被暂停,直到页面解冻为止,而这可能永远不会发生(例如,如果页面被舍弃)。

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

具体而言,请务必:

如果页面被舍弃并稍后重新加载,您还应将所有动态视图状态(例如无限列表视图中的滚动位置)保留到您希望恢复的 sessionStorage(或通过 commit() 的 IndexedDB)。

如果网页从冻结状态转换回隐藏状态,您可以重新打开所有已关闭的连接,或重新启动您在网页最初冻结时停止的所有轮询。

Terminated

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

由于用户操作而被卸载的网页总是先经过隐藏状态,再进入“已终止”状态,因此应执行会话结束逻辑(例如保留应用状态并向 Google Analytics 报告)

此外(如隐藏状态建议中所述),开发者必须认识到,在许多情况下(尤其是在移动设备上),无法可靠地检测到转换为终止状态,因此依赖于终止事件(例如 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 标志。

摘要

如果开发者希望尊重用户设备的系统资源,则应在构建应用时考虑页面生命周期状态。在用户意想不到的情况下,网页不会消耗过多的系统资源,这一点至关重要

开发者开始实现新的网页生命周期 API 的数量越多,浏览器冻结和舍弃未使用的网页就越安全。这意味着浏览器将消耗更少的内存、CPU、电池和网络资源,这对用户来说是件好事。