# beforeinstallprompt 事件

> Chromium 页面如何推迟 beforeinstallprompt 并用 prompt() 打开安装对话框，userChoice 报告什么，prompt() 为何拒绝，以及 Safari 的回退。

import Figure from '@components/Figure.astro';
import installFlowDiagram from '@assets/diagrams/install-flow.svg';

`beforeinstallprompt` 是 Chromium 浏览器在页面满足可安装性条件后于 `window` 上触发的事件；处理器调用 `preventDefault()` 压住浏览器自己的横幅，保存这个 `BeforeInstallPromptEvent`，之后在点击中调用 `prompt()`，让安装对话框在页面选定的时机打开。Android 上的 Chrome 68、桌面端 Chrome 73、Edge 79 与 Samsung Internet 9.0 会触发它；Safari（iOS 与 macOS）与 Firefox 从不触发，只能从各自的菜单安装（BCD `api.BeforeInstallPromptEvent`）。

<Figure src={installFlowDiagram} alt="安装提示流程图：站点满足可安装性条件后，Chromium 浏览器触发 beforeinstallprompt；页面调用 preventDefault()、保存事件并显示自己的安装按钮；在用户手势中调用 prompt()，浏览器弹出安装对话框，userChoice 兑现为 accepted（随后触发 appinstalled）或 dismissed；Safari 与 Firefox 不触发该事件，而是通过浏览器菜单安装。" caption="从可安装性条件到 appinstalled：Chromium 浏览器中的 beforeinstallprompt 流程。" />

## 语法

```js
window.addEventListener('beforeinstallprompt', (event) => {
  event.preventDefault();          // 先不让浏览器显示自己的横幅
  deferred = event;                // 一次性：只能用于一次 prompt()
});

const { outcome, platform } = await deferred.prompt();   // 必须在用户手势中
// 或：const { outcome, platform } = await deferred.userChoice;

window.addEventListener('appinstalled', () => { /* 从任何界面完成的安装 */ });
```

`prompt()` 返回的 Promise 与 `userChoice` 兑现为同一个 `{ outcome, platform }` 对象，二者 await 哪个都可以。无论安装来自 `prompt()`、地址栏图标还是浏览器菜单，`appinstalled` 都会在 `window` 上触发。

## 成员

`BeforeInstallPromptEvent` 在 `Event` 之上扩展了两个成员；`appinstalled` 是普通的 `Event`。

| 成员 | 类型 | 说明 |
|---|---|---|
| `platforms` | `readonly string[]` | 浏览器提供的安装目标，例如桌面端为 `["web"]`，Android 上当 manifest 列出 Play 应用且设置 `prefer_related_applications` 时为 `["web", "play"]`。 |
| `userChoice` | `Promise<{ outcome, platform }>` | 对话框关闭后兑现。`outcome` 为 `"accepted"` 或 `"dismissed"`；`platform` 是 `platforms` 中被选中的项，取消时为 `""`。 |
| `prompt()` | `Promise<{ outcome, platform }>` | 显示浏览器的安装对话框。每个事件只允许调用一次，且必须有瞬时用户激活。 |
| `preventDefault()` | 继承 | 阻止浏览器在这次可安装性检查中显示自己的安装横幅（Android 上的 mini-infobar）。 |

## 事件何时触发

页面必须通过 HTTPS（或 `localhost`）提供，并链接一个带有 `name` 或 `short_name`、`start_url`、非 `browser` 的 `display`、以及包含 192 px 与 512 px 尺寸图标的 manifest。Chrome 在 Android 上自 Chrome 108、桌面端自 Chrome 112 起取消了从浏览器菜单安装时的 service worker 要求，并提供默认离线页；Chrome 团队关于这次调整的文章指出，`beforeinstallprompt` 背后的启发式仍然检查 `fetch` 处理器，因此没有 service worker 的页面可能可以从菜单安装，却始终收不到该事件。应用已安装期间，以及参与度启发式尚未满足时，Chrome 也不会触发它。

## 异常

只有 `prompt()` 会拒绝；该 API 中没有别的会抛出。

- `NotAllowedError`：在没有瞬时用户激活的情况下调用了 `prompt()`，例如在页面加载时或定时器中。Chromium 的报错信息为 `The prompt() method must be called with a user gesture`。
- `InvalidStateError`：对同一个事件第二次调用 `prompt()`，或事件底层的横幅请求已失效（页面已导航，或浏览器已经显示过自己的界面）。

Safari 与 Firefox 中事件从不触发，所以不存在可被误用的事件对象；假定提示一定会来的代码只是永远不会显示它的按钮。

:::observed
在 Chrome（英文界面）中，页面对 `beforeinstallprompt` 调用了 `event.preventDefault()` 却从未调用 `prompt()` 时，控制台会输出 `Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner.`，DevTools › Application › Manifest 则在 "Installability" 标题下列出结果。`/demo/#install` 的演示复现了完整流程：按钮只在事件触发后出现，对话框关闭后打印 `userChoice` 的结果。
:::

## 示例

两个示例都运行在页面中；假定 service worker 与 manifest 已经就位。

### 推迟事件并从按钮发起提示

处理器保存事件并显示按钮。点击处理器调用 `prompt()`、读取结果，并清空保存的事件，因为它不能复用。`appinstalled` 监听器负责在用户从浏览器自带界面安装时隐藏按钮。

```js
let deferred = null;
const button = document.querySelector('#install');

window.addEventListener('beforeinstallprompt', (event) => {
  event.preventDefault();
  deferred = event;
  button.hidden = false;
});

button.addEventListener('click', async () => {
  if (!deferred) return;
  const { outcome, platform } = await deferred.prompt();
  deferred = null;                       // 事件只能用一次
  button.hidden = true;
  if (outcome === 'accepted') analytics.track('install_accepted', { platform });
});

window.addEventListener('appinstalled', () => {
  deferred = null;
  button.hidden = true;
});
```

一次取消的代价不止是丢掉一次点击：Chrome 会对同一站点施加自己的冷却期，之后才再次触发 `beforeinstallprompt`，所以把按钮放在用户完成一项任务之后、而不是首屏，能让页面拿到的这唯一一次事件用在更可能被接受的时刻。

### 检测支持，并为 Safari 用户改为给出指引

Safari 从不触发该事件。页面仍可以检测自己是否已以安装态运行（`display-mode: standalone`，或 iOS 上的旧属性 `navigator.standalone`），若没有，就用分享菜单的说明代替一个永远不会出现的按钮。

```js
const installed =
  window.matchMedia('(display-mode: standalone)').matches ||
  navigator.standalone === true;                        // 仅 iOS Safari

const supportsPrompt = 'onbeforeinstallprompt' in window;

if (installed) {
  hideAllInstallUi();
} else if (!supportsPrompt) {
  // Safari 与 Firefox：不会有事件到来。说明手动路径。
  showHint(/iPhone|iPad/.test(navigator.userAgent)
    ? '点按"共享"，然后选择"添加到主屏幕"。'
    : '请使用浏览器菜单安装此应用。');
}
// Chromium：等 beforeinstallprompt 触发后再显示任何东西。
```

在所有实现了该事件的 Chromium 浏览器中 `'onbeforeinstallprompt' in window` 都为 `true`，所以指引分支只在无法等待事件的地方运行；Chromium 分支则把安装界面一直隐藏到事件确认可安装为止。

## 另请参阅

- [Web Manifest Application Information and incubations: BeforeInstallPromptEvent](https://wicg.github.io/manifest-incubations/)（wicg.github.io）
- [Changes to the installability criteria](https://developer.chrome.com/blog/update-install-criteria)（developer.chrome.com）
- [BeforeInstallPromptEvent](https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent)（developer.mozilla.org）
- [安装提示浏览器支持](/zh/compatibility/install-prompt/)
- [可安装性条件](/zh/reference/installation/installability-criteria/)
- [安装提示 UX](/zh/reference/installation/install-prompt-ux/)
- [iOS 添加到主屏幕](/zh/reference/installation/ios-add-to-home-screen/)
- [WebAPK](/zh/reference/installation/webapk/)