页面生命周期 API

Browser Support

  • Chrome: 68.
  • Edge: 79.
  • Firefox: not supported.
  • Safari: not supported.

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

背景

应用生命周期是现代操作系统管理资源的一种重要方式。在 Android、iOS 和新近的 Windows 版本中,应用可以随时由操作系统启动和停止。这样一来,这些平台就能优化资源并将其重新分配到最能让用户受益的地方。

在 Web 上,历史上一直没有此类生命周期,应用可以无限期保持活跃状态。在大量网页运行的情况下,内存、CPU、电池和网络等关键系统资源可能会过度订阅,从而导致最终用户体验不佳。

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

事实上,现在的浏览器已经采取积极措施来节省后台标签页的资源,许多浏览器(尤其是 Chrome)希望在这方面做得更好,以减少其总体资源占用量。

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

页面生命周期 API 尝试通过以下方式解决此问题:

  • 在 Web 上引入并标准化生命周期状态的概念。
  • 定义新的系统启动状态,使浏览器能够限制隐藏或非活动标签页可消耗的资源。
  • 创建新的 API 和事件,使 Web 开发者能够响应向这些新的系统启动状态的过渡以及从这些状态的过渡。

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

本文的其余部分将介绍新的页面生命周期功能,并探讨它们与所有现有的 Web 平台状态和事件有何关系。此外,它还会针对开发者在每种状态下应该(和不应该)执行的工作类型提供建议和最佳实践。

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

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

或许最简单的方法就是通过图表来解释网页生命周期状态以及指示状态之间转换的事件:

直观地展示了本文档中描述的状态和事件流。
页面生命周期 API 状态和事件流。

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

说明
有效

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

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

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

被动

如果某个页面可见但没有输入焦点,则该页面处于被动状态。

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

可能的下一个状态
有效 (通过 focus 事件)
隐藏 (通过 visibilitychange 事件)

隐藏

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

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

可能的下一个状态
被动 (通过 visibilitychange 事件)
冻结 (通过 freeze 事件)
舍弃 (未触发任何事件)
终止 (未触发任何事件)

冻结

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

浏览器会冻结页面,以节省 CPU/电池/数据使用量;这样做也是为了实现更快的 后退/前进导航,避免需要重新加载整个页面。

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

可能的下一个状态
active (通过 resume 事件,然后通过 pageshow 事件)
passive (通过 resume 事件,然后通过 pageshow 事件)
hidden (通过 resume 事件)
discarded (未触发任何事件)

已终止

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

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

可能的后续状态

已舍弃

当网页被浏览器卸载以节省资源时,该网页处于舍弃状态。在此状态下,无法运行任何任务、事件回调或 JavaScript,因为舍弃通常发生在资源受限的情况下,此时无法启动新进程。

舍弃状态下,即使网页已消失,标签页本身(包括标签页标题和 favicon)通常对用户可见。

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

可能的后续状态

事件

浏览器会调度许多事件,但只有一小部分事件表示页面生命周期状态可能会发生变化。下表列出了与生命周期相关的所有事件,以及这些事件可能转换到的状态和转换自的状态。

名称 详细信息
focus

DOM 元素已获得焦点。

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

可能的先前状态
被动

可能的当前状态
有效

blur

某个 DOM 元素已失去焦点。

注意blur 事件不一定表示状态发生了变化。只有当网页不再具有输入焦点时(即网页并非刚刚将焦点从一个元素切换到另一个元素),才会发出状态更改信号。

可能的先前状态
有效

可能的当前状态
被动

visibilitychange

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

可能的先前状态
被动
隐藏

可能的当前状态
被动
隐藏

freeze *

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

可能的先前状态
hidden

可能的当前状态
冻结

resume *

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

可能的先前状态
冻结

可能的当前状态
active (如果后面跟有 pageshow 事件)
passive (如果后面跟有 pageshow 事件)
hidden

pageshow

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

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

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

可能的当前状态
有效
被动
隐藏

pagehide

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

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

可能的先前状态
hidden

可能的当前状态
冻结 event.persisted 为 true,随后是 freeze 事件)
终止 event.persisted 为 false,随后是 unload 事件)

beforeunload

窗口、文档及其资源即将卸载。 此时,文档仍处于可见状态,并且活动仍可取消。

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

可能的先前状态
hidden

可能的当前状态
已终止

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

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

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

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

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

Frozen

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

这意味着,当网页从隐藏状态变为冻结状态时,您必须停止所有计时器或关闭所有连接,因为如果网页处于冻结状态,这些计时器或连接可能会影响同一来源中的其他打开的标签页,或者影响浏览器将网页放入 后退/前进缓存的能力。

具体而言,您务必要:

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

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

Terminated

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

由于因用户操作而卸载的网页在进入 terminated 状态之前始终会先进入 hidden 状态,因此会话结束逻辑(例如,持久保存应用状态并向分析服务报告)应在 hidden 状态下执行。

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

Discarded

在网页被舍弃时,开发者无法观察到舍弃状态。这是因为在资源受限的情况下,页面通常会被舍弃,而解冻页面以允许脚本响应舍弃事件运行在大多数情况下是不可能的。

因此,您应为从 hiddenfrozen 的更改可能被舍弃做好准备,然后您可以通过检查 document.wasDiscarded 在页面加载时对舍弃页面的恢复做出反应。

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

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

应尽可能避免发生以下事件。

unload 事件

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

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

此外,仅仅是存在已注册的 unload 事件处理程序(通过 onunloadaddEventListener()),就可能会导致浏览器无法将网页放入往返缓存中,从而无法更快地加载返回和前进页面。

在所有现代浏览器中,建议始终使用 pagehide 事件来检测可能的网页卸载(即终止状态),而不是 unload 事件。如果您需要支持 Internet Explorer 10 及更低版本,则应进行功能检测,仅在浏览器不支持 pagehide 时使用 unloadpagehide

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.
    event.returnValue = true;
  }
});

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

const beforeUnloadListener = (event) => {
  event.preventDefault();

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

// A function that adds a `beforeunload` listener if there are unsaved changes.
onPageHasUnsavedChanges(() => {
  addEventListener('beforeunload', beforeUnloadListener);
});

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

常见问题解答

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

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

我的网页在隐藏时会执行重要工作,如何防止它被冻结或舍弃?

有很多正当理由说明网页在以隐藏状态运行时不应冻结。最明显的例子是播放音乐的应用。

在某些情况下,Chrome 丢弃网页会带来风险,例如网页包含未提交的用户输入内容的表单,或者具有在网页卸载时发出警告的 beforeunload 处理程序。

目前,Chrome 在舍弃网页时会比较保守,只有在确信不会影响用户的情况下才会这样做。例如,如果网页在处于隐藏状态时被发现执行了以下任一操作,则不会被舍弃,除非资源严重受限:

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

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

什么是往返缓存?

后退/前进缓存是指某些浏览器实现的一种导航优化,可加快后退和前进按钮的使用速度。

当用户离开某个网页时,这些浏览器会冻结该网页的一个版本,以便用户在通过返回或前进按钮返回时能够快速恢复该网页。请注意,添加 unload 事件处理程序会阻止此优化

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

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

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

虽然大多数 IndexedDB API 都是基于回调的,但 IDBTransaction 接口上的 commit() 方法提供了一种在活跃事务中启动提交过程的方式,而无需等待未完成请求的事件被分派。这提供了一种可靠的方法,可在 freezevisibilitychange 事件监听器中将数据保存到 IndexedDB 数据库,因为提交会立即运行,而不是在单独的任务中排队。

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

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

Chrome 舍弃界面
Chrome 舍弃界面

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

摘要

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

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