> For the complete documentation index, see [llms.txt](https://lizh.gitbook.io/knowledge/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://lizh.gitbook.io/knowledge/frontend/02-jin-jie-08-ye-mian-ke-jian-xing-he-sheng-ming-zhou-qi.md).

# 进阶 08 页面可见性和生命周期

一个 Web 页面的生命周期指的是页面从开始加载到完全卸载的过程中经历的所有状态及状态转化，主要包括了焦点、可见性、页面缓存等内容。

## Page Visibility API

常见的监听用户离开的事件有：beforeunload/unload、pagehide 等。

但是，有些时候，有些事件在手机上可能不会触发，页面就直接关闭了，比如：不关闭当前页面的情况下，直接切换到另一个 App（点击系统通知、任务切换窗口、接电话等）；点击了 Home 按钮，切换回主屏幕。

**Page Visibility API（页面可见性 API）** 正是为解决此类问题提供的 API，它提供监听事件，以了解文档何时变为可见或隐藏，以及查看页面当前可见性状态的功能。

当用户最小化窗口或切换到另一个选项卡时，API 会发送一个 `visibilitychange` 事件让监听器知道页面的状态已更改。在事件可执行某些操作或表现不同，如，Web 应用程序正在播放视频，它可以在用户将选项卡置于后台时暂停视频，并在用户返回选项卡时恢复播放。用户不会失去他们在视频中的位置，视频的配乐不会干扰新前景选项卡中的音频，同时用户不会错过任何视频。

页面可见性 API 向 Document 对象添加了以下属性：

* **document.hidden：** 只读，如果页面处于对用户隐藏的状态，则返回 `true`，否则返回 `false`。
* **document.visibilityState：** 只读，表示文档的当前可见性状态。可能的值为：

  * visible： 页面内容可以至少部分可见。实际上，这意味着页面是非最小化窗口的前景选项卡。
  * hidden： 页面的内容对用户不可见。比如：文档的选项卡切换到后台，浏览器最小化，文档将要关闭，或者设备的屏幕关闭。
  * prerender： 页面的内容正在预呈现，用户看不到。一个文档状态可以从 `prerender` 状态开始，但永远不会从任何其他状态切换到该状态，因为一个文档只能预渲染一次。**注意：** 并非所有浏览器都支持 `prerender` 值。
  * unloaded： 该页面正在从内存中卸载。**注意：** 并非所有浏览器都支持 `unloaded` 值。

  **注意：** 只有在 `document.visibilityState` 属性返回 `visible` 时，`document.hidden` 属性返回 `false`；其他情况下，都返回 `true`。
* **document.onvisibilitychange：** `visibilitychange` 事件，监听文档的状态变化。

  ```javascript
  document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") {
      }
  }, false)
  ```

  **注意：** `visibilitychange` 事件比 `beforeunload/unload`、`pagehide` 事件更可靠，文档状态的所有变化都会触发该事件。

## Page Lifecycle API

上述的 Page Visibility API 着重于监听文档是否可见。

但是，它没有解决一个问题：**监听网页的启动或丢弃**。

随着大量 Web 应用程序（和选项卡）的运行，内存、CPU、电池、网络等关键资源很容易被超额订阅，从而导致糟糕的最终用户体验。在 Android、iOS 以及更新的 Windows 版本上，**应用程序可以由平台随意启动和停止**。这种情况， Page Visibility API 无法监听网页的当前状态。

Page Lifecycle API 定义了网页的生命周期，并添加了扩展以使 Web 应用程序能够响应用户代理通常执行的两个重要生命周期事件：

* **Frozen：** CPU 暂停（用于节省电池、数据、CPU）；
* **Discarded：** 标签丢弃（为了节省内存）。

### 生命周期阶段

网页的生命周期分成六个阶段，每个时刻只可能处于其中一个阶段。

![页面生命周期阶段](https://my-files-1259410276.cos.ap-chengdu.myqcloud.com/md_images/lifecycle.jpg)

* **Active：** 页面处于可见状态，且拥有输入焦点。
* **Passive：** 页面处于可见状态，但没有输入焦点，无法接受输入。UI 更新（比如动画）仍然在执行。该阶段只可能发生在桌面同时有多个窗口的情况。
* **Hidden：** 用户的桌面被其他窗口占据，页面不可见，但尚未冻结。UI 更新不再执行。
* **Terminated：** 由于用户主动关闭窗口，或者在同一个窗口前往其他页面，导致当前页面开始被浏览器卸载并从内存中清除。

  **注意：** 这个阶段总是在 Hidden 阶段之后发生，也就是说，用户主动离开当前页面，总是先进入 Hidden 阶段，再进入 Terminated 阶段。

  这个阶段会导致网页卸载，任何新任务都不会在这个阶段启动，并且如果运行时间太长，正在进行的任务可能会被终止。
* **Frozen：** 如果网页处于 Hidden 阶段的时间过久，用户又不关闭网页，浏览器就有可能冻结网页，使其进入 Frozen 阶段。不过，也有可能，处于可见状态的页面长时间没有操作，也会进入 Frozen 阶段。

  这个阶段的特征是，网页不会再被分配 CPU 计算资源。定时器、回调函数、网络请求、DOM 操作都不会执行，不过正在运行的任务会执行完。浏览器可能会允许 Frozen 阶段的页面，周期性复苏一小段时间，短暂变回 Hidden 状态，允许一小部分任务执行。
* **Discarded：** 如果网页长时间处于 Frozen 阶段，用户又不唤醒页面，那么就会进入 Discarded 阶段，即浏览器自动卸载网页，清除该网页的内存占用。不过，Passive 阶段的网页如果长时间没有互动，也可能直接进入 Discarded 阶段。

  这一般是在用户没有介入的情况下，由系统强制执行。任何类型的新任务或 JavaScript 代码，都不能在此阶段执行，因为这时通常处在资源限制的状况下。

  网页被浏览器自动 Discarded 以后，它的 Tab 窗口还是在的。如果用户重新访问这个 Tab 页，浏览器将会重新向服务器发出请求，再一次重新加载网页，回到 Active 阶段。

### 常见场景

以下是几个常见场景的网页生命周期变化。

* 用户打开网页后，又切换到其他 App，但只过了一会又回到网页： **Active -> Hidden -> Active**。
* 用户打开网页后，又切换到其他 App，并且长时候使用后者，导致系统自动丢弃网页： **Active -> Hidden -> Frozen -> Discarded**。
* 用户打开网页后，又切换到其他 App，然后从任务管理器里面将浏览器进程清除： **Active -> Hidden -> Terminated**。
* 系统丢弃了某个 Tab 里面的页面后，用户重新打开这个 Tab： **Discarded -> Active**。

### 生命周期事件

生命周期的各个阶段都有自己的事件，以供开发者指定监听函数。这些事件里面，只有两个是新定义的（freeze 事件和 resume 事件），其它都是现有的。

**注意：** 网页的生命周期事件是在所有帧（frame）触发，不管是底层的帧，还是内嵌的帧。也就是说，内嵌的 `<iframe>` 网页跟顶层网页一样，都会同时监听到下面的事件。

#### focus

在页面获得输入焦点时触发。网页状态从 Passive 阶段变为 Active 阶段。

#### blur

在页面失去输入焦点时触发。网页状态从 Active 阶段变为 Passive 阶段。

#### visibilitychange

在网页可见状态发生变化时触发，一般发生在以下几种场景：

* 用户隐藏页面（切换 Tab、最小化浏览器），页面由 Active 阶段变成 Hidden 阶段。
* 用户重新访问隐藏的页面，页面由 Hidden 阶段变成 Active 阶段。
* 用户关闭页面，页面会先进入 Hidden 阶段，然后进入 Terminated 阶段。

```javascript
document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
    }
}, false)
```

#### freeze

在网页进入 Frozen 阶段时触发。

```javascript
document.addEventListener('freeze', () => { })
document.onfreeze = () => { }
```

这个事件的监听函数，最长只能运行 500 毫秒，并且只能复用已经打开的网络连接，不能发起新的网络请求。

**注意：** 从 Frozen 阶段进入 Discarded 阶段，不会触发任何事件，无法指定回调函数，只能在进入 Frozen 阶段时指定回调函数。

#### resume

在网页离开 Frozen 阶段，变为 Active / Passive / Hidden 阶段时触发。

```javascript
document.addEventListener('resume', () => { })
document.onresume = () => { }
```

#### pageshow

当浏览器的 History 记录的变化时触发。这包括后退/前进按钮操作，onload 事件触发后初始化页面。有可能是全新的页面加载，也可能是从缓存中获取的页面。如果是从缓存中获取，则该事件对象的event.persisted属性为true，否则为false。

#### pagehide

当浏览器的 History 记录的变化，并且浏览器当前显示与 History 记录不同时，隐藏当前页面时触发。比如：用户离开当前网页、进入另一个网页时触发，或者关闭页面。

#### DOMContentLoaded

当初始的 HTML 文档被完全加载和解析完成之后，DOMContentLoaded 事件被触发，而无需等待样式表、图像和子框架的完全加载。该事件主要用于操作 DOM 节点、初始化接口等。

当浏览器处理一个 HTML 文档，并在文档中遇到 `<script>` 标签时，会先执行其中脚本，再构建后面的 DOM。这是一种防范措施，因为脚本可能想要修改前面 DOM，因此，DOMContentLoaded 事件必须等待脚本执行结束。

一般外部样式表不会影响 DOM，DOMContentLoaded 不会等待它们。但如果样式后面有一个脚本，那么该脚本必须等待样式表加载完成，因为脚本可能想要获取元素的坐标和其他与样式相关的属性。也就是，当 DOMContentLoaded 等待脚本时，脚本也在等待脚本前面的样式，所以，DOMContentLoaded 事件实际要先等待样式加载完成，再等待脚本执行结束。

```javascript
document.addEventListener("DOMContentLoaded", (event) => {
})
```

**注意：** Firefox，Chrome 和 Opera 都会在 DOMContentLoaded 中自动填充表单，如：浏览器记住的账号、密码。

#### load

当整个页面加载完成时触发该事件，包括所有依赖资源，如样式表和图像。与 DOMContentLoaded 对比，DOMContentLoaded 在页面 DOM 加载后立即触发，无需等待资源完成加载。

一般来说，大多数的操作都放在 DOMContentLoaded 事件中执行，很少用到 load 事件。

```javascript
window.onload = function() {}
window.addEventListener('load', (event) => {})
```

#### beforeunload

当窗口、文档及其资源即将被卸载时，触发 beforeunload 事件。该事情主要用于取消默认行为、询问用户是否真的要离开等。

根据规范，要显示确认对话框，事件处理程序需要在事件上调用 `preventDefault()`。

但是请注意，并非所有浏览器都支持此方法，而有些浏览器需要事件处理程序实现两个遗留方法中的一个作为代替：

* 将字符串分配给事件的 returnValue 属性；
* 从事件处理程序返回一个字符串。

```javascript
window.addEventListener('beforeunload', (event) => {
    event.preventDefault()
    event.returnValue = ''
    return ''
})
```

为避免意外弹出窗口，除非页面已与之交互，否则浏览器可能不会显示在 beforeunload 事件中创建的提示，甚至根本不会显示它们。

#### unload

当文档或一个子资源正在被卸载时，触发 unload 事件。该事情主要用于处理一些不涉及延迟的操作，如：关闭相关的弹出窗口。

此时，文档处于以下状态：

* 所有资源仍存在 (图片、iframe 等)；
* 对于终端用户所有资源均不可见；
* 界面交互无效 (window\.open、alert、confirm 等)；
* 错误不会停止卸载文档的过程。

如果需要在 unload 事件中，执行异步操作，如，向服务器发送统计数据，可以使用 `navigator.sendBeacon` 方法。它的特点是：浏览器页面关闭后，仍然可以向服务器发送数据。

```javascript
window.addEventListener("unload", function() {
    navigator.sendBeacon("/api/save", JSON.stringify({}))
})
```

使用需注意：

* 请求以 POST 方式发送；
* 我们不仅能发送字符串，还能发送表单以及其他格式的数据；
* 数据大小限制在 64kb。

**注意：** 避免使用 unload 事件，尽量用 visibilitychange 代替。unload 在一些卸载情况下不会触发：选项卡切换、关闭页面或系统切换器切换、关闭 APP。

#### readystatechange

`document.readyState` 属性描述了 Document 的加载状态。当该属性值发生变化时，会在 Document 对象上触发 `readystatechange` 事件。

一个文档的 `readyState` 可以是以下之一：

* loading： Document 仍在加载；
* interactive：文档已被解析，【正在加载】状态结束，但是如图像、样式表、iframe 之类的子资源仍在加载，即将触发 DOMContentLoaded 事件；
* complete： 文档和所有子资源已完成加载，即将触发 load 事件。

```javascript
switch (document.readyState) {
    case "loading":
        break
    case "interactive":
        break
    case "complete":
        break
}
```

### 获取当前阶段

如果网页处于 Active、Passive 或 Hidden 阶段，可以通过下面的代码，获得网页当前的状态。

```javascript
document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
        return 'hidden'
    }
    if (document.hasFocus()) {
        return 'active'
    }
    return 'passive'
}, false)
```

进入 Frozen 阶段，可以监听 freeze 事件；退出 Frozen 阶段，可以监听 resume事件：

```javascript
document.addEventListener('freeze', (event) => {
})
document.addEventListener('resume', (event) => {
})
```

进入 Terminated 阶段，可以监听 pagehide 事件：

```javascript
document.addEventListener('pagehide ', (event) => {
})
```

要确定一个页面在隐藏标签页中是否被丢弃，可以使用以下代码。

```js
if (document.wasDiscarded) {
    // 页面之前在隐藏标签页中被浏览器丢弃
} 
```

## 浏览器兼容性

* IE 10 以及以下版本不支持 pagehide 事件；
* IE、Firefox 不支持 onfreeze。
* IE、Firefox、Safari 不支持 onresume 。
* IE、safari 不支持 visibilitychange 事件。
* 一些浏览器切换 Tab 时，不会触发 blur 事件，意味着 `active` 状态不经过 `passive` 状态而直接变成了 `hidden`；
* pagehide、visibilitychange 触发顺序已改变：当页面正在被卸载时，如果页面可见，会先触发 pagehide 再触发 visibilitychange，最新版本的 Chrome ，无论页面是否可见都会先触发 visibilitychange 再触发 pagehide。

**注意：** visibilitychange 事件通常都是挂载在 document 对象上，虽然现在最新的浏览器也支持挂载在 window 对象上，不过 Safari 14 之前的版本不支持；而 pageshow 和 pagehide 事件都是通过 window 对象进行注册的。

## 常见问题

### DOMContentLoaded 事件和 Load 事件的区别？

当初始的 HTML 文档被完全加载和解析完成之后，DOMContentLoaded 事件被触发，而无需等待样式表、图像和子框架的加载完成。

Load 事件是当所有资源加载完成后触发的。

### 监听页面关闭的兼容性

监听页面关闭主要用于前端监控在页面关闭的时发送一下日志（结合 sendBeacon）。

页面关闭动作有：页面刷新、页面跳转、关闭 tab、关闭浏览器。

Web 平台提供四个页面关闭事件（按顺序）：beforeunload、unload、pagehide 和 visibilitychange。

移动端的表现：

|               |            | beforeunload | unload | pagehide   | visibilitychange |
| ------------- | ---------- | ------------ | ------ | ---------- | ---------------- |
| **iOS**       | **刷新**     | 不支持          | 支持度低   | ✓          | 不支持              |
|               | **跳转**     | 不支持          | 支持度低   | ✓          | 不支持              |
|               | **关闭 tab** | 不支持          | 支持度低   | 不支持（14 以下） | 不支持              |
|               | **关闭浏览器**  | 不支持          | 支持度低   | 不支持        | 不支持              |
| **Android**   | **刷新**     | ✓            | ✓      | ✓          | ✓                |
|               | **跳转**     | 不支持          | 不支持    | ✓          | ✓                |
|               | **关闭 tab** | 不支持          | ✓      | ✓          | ✓                |
|               | **关闭浏览器**  | 不支持          | 不支持    | 不支持        | ✓                |
| **HarmonyOs** | **刷新**     | ✓            | ✓      | ✓          | ✓                |
|               | **跳转**     | ✓            | 不支持    | 不支持        | ✓                |
|               | **关闭 tab** | 不支持          | 不支持    | 不支持        | ✓                |
|               | **关闭浏览器**  | 不支持          | 不支持    | ✓          | ✓                |

从表格可知，只在 IOS 下有两种情况是无法监听页面关闭：关闭 tab 时，iOS14 以下（iOS13、iOS12、iOS11，其他版本未测）；二是关闭浏览器时，iOS 全不支持。

**注意：** 通过 beforeunload、unload、pagehide、visibilitychange 事件监听页面关闭，安卓设备可正常监听；而 iOS 设备在点左上角关闭或者左滑退出时，无法触发任何关闭事件（刷新、跳转可以触发 beforeunload 之外的事件，但不一定可靠 ），**这是 safari 浏览器的限制**。

**注意：** iOS 在把浏览器切后台的时候，可以触发 visibilitychange。

**注意：** 无法监听页面关闭的一种解决方案是：通过发心跳来确定用户是否关闭页面。

```javascript
let isClosePage = false;
function callback() {
    if (isClosePage) {
        return;
    }
    isClosePage = true;
    // ...
}

window.addEventListener('beforeunload', callback);
window.addEventListener('pagehide', callback);
window.addEventListener('unload', callback);

// IOS14 之前不会冒泡，只能监听 document
document.addEventListener('visibilitychange', () => {
    if (document.visibilityState !== 'visible') {
        callback();
    } else {
        isClosePage = false;
    }
});
```

## 参数资料

[MDN Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)

[W3C Community Group Draft - Page Lifecycle](https://wicg.github.io/page-lifecycle/)

[Lifecycle API for Web Pages](https://github.com/WICG/page-lifecycle)

[阮一峰 Page Lifecycle API 教程](http://www.ruanyifeng.com/blog/2018/11/page_lifecycle_api.html)

[阮一峰 Page Visibility API 教程](https://www.ruanyifeng.com/blog/2018/10/page_visibility_api.html)

[【兼容性】监听页面关闭发送请求](https://cloud.tencent.com/developer/article/1875514)
