# Web Locks API

> navigator.locks.request() 持有命名独占锁或共享锁期间运行回调，跨标签页协调。LockOptions 成员、每种拒绝原因与带回退的示例。

`navigator.locks.request()` 向浏览器的锁管理器申请一把命名锁，在持锁期间运行回调，回调的 Promise 完结后释放。同一源下共享同一个存储桶的标签页、iframe 和 worker 在相同名字上排队，因此 PWA 可以把对 IndexedDB 的写入串行化、选出一个标签页独占 WebSocket，或防止两个 Service Worker 客户端同时跑同一份迁移。

支持面很宽：Chrome 69、Edge 79、Firefox 96、macOS 与 iOS 上的 Safari 15.4、Samsung Internet 10.0 以及 Android WebView 69 都实现了带 `request()` 与 `query()` 的 `LockManager`（BCD `api.LockManager`）。接口标注为 `[SecureContext, Exposed=(Window,Worker)]`，所以在专用、共享与 Service Worker 中都可用，但在 `localhost` 以外的 `http://` 源上不存在。

## 语法

```js
navigator.locks.request(name, callback)
navigator.locks.request(name, options, callback)

navigator.locks.query()
```

`request()` 返回 `Promise<any>`，在锁释放之后以 `callback` 的返回值兑现或以其抛出的值拒绝。同步回调会被包成一个立即兑现的 Promise，所以锁只在同步执行期间被持有。`query()` 返回 `Promise<LockManagerSnapshot>`，描述快照那一刻该源锁管理器里每一把已持有和排队中的锁。

## 参数

`request()` 接受资源名、可选的 `LockOptions` 字典和一个回调；回调始终是最后一个参数。

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `name` | `DOMString` | 是 | 由应用自行命名的资源名。以 `-` 开头的名字保留给浏览器，会被拒绝。 |
| `options` | `LockOptions` | 否 | 模式与等待行为，见下表。 |
| `callback` | `LockGrantedCallback` | 是 | `(lock) => Promise<any> \| any`，授予时以 `Lock`（`name`、`mode`）调用；`ifAvailable` 为 `true` 且无法立即授予时以 `null` 调用。 |

`LockOptions` 有四个成员，全部可选。

| 成员 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `mode` | `LockMode`（`"exclusive"` 或 `"shared"`） | `"exclusive"` | 多个上下文可同时持有同名的 `"shared"` 锁；`"exclusive"` 持有者会阻塞该名字上的所有其他请求直到释放。 |
| `ifAvailable` | `boolean` | `false` | 只在无需等待时授予，否则以 `null` 调用 `callback` 而不排队。仍是异步的，因为检查通常要跨进程。 |
| `steal` | `boolean` | `false` | 释放该名字的当前持有者（它们的 `request()` Promise 以 `AbortError` 拒绝），跳过队列并授予本次请求。只能与 `"exclusive"` 模式搭配。 |
| `signal` | `AbortSignal` | 无 | 在请求仍在排队时中止它；锁一旦授予，信号即被忽略。 |

`query()` 不接受参数。其快照包含 `held` 与 `pending` 两个 `LockInfo` 数组，每项带 `name`、`mode` 以及所在 frame 或 worker 的 `clientId`（与 Service Worker 里 `Client.id` 的值相同）。

## 异常

出现下列情况之一时，`request()` 在任何锁入队之前即拒绝，顺序与规范的方法步骤一致。

| 异常 | 条件 |
|---|---|
| `InvalidStateError` | 调用方文档不是 fully active（例如已脱离文档树的 iframe）。 |
| `SecurityError` | 无法为该环境获取锁管理器：不透明源（如没有 `allow-same-origin` 的沙箱 iframe），或浏览器排除在 Locks API 之外的上下文。 |
| `NotSupportedError` | `name` 以 U+002D HYPHEN-MINUS（`-`）开头；`steal` 与 `ifAvailable` 同为 `true`；`steal` 为 `true` 但 `mode` 不是 `"exclusive"`；或同时传了 `signal` 与 `steal` 或 `ifAvailable`。 |
| `AbortError`（或信号的 abort reason） | 调用 `request()` 时 `signal` 已经中止，或请求仍在排队时被中止。 |
| `AbortError` | 另一个带 `steal: true` 的请求夺走了本持有者的锁。 |

`query()` 只会以同样的 `InvalidStateError` 与 `SecurityError` 条件拒绝。两个方法都不同步抛出；回调抛出的任何值都会在锁释放后成为 `request()` Promise 的拒绝原因。

:::observed
Chrome 对 `navigator.locks.request('-x', () => {})` 以 `NotSupportedError: Names cannot start with '-'.` 拒绝，对 `request('x', { steal: true, ifAvailable: true }, cb)` 以 `NotSupportedError: The 'steal' and 'ifAvailable' options cannot be used together.` 拒绝。在没有 `allow-same-origin` 的 `<iframe sandbox>` 中调用，拒绝原因为 `SecurityError: Access to the Locks API is denied in this context.`；被 `steal: true` 挤掉的持有者会看到自己的 `request()` Promise 以 `AbortError: Lock broken by another request with the 'steal' option.` 拒绝。这些字符串来自 Chromium 的 [`lock_manager.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/locks/lock_manager.cc) 与 [`lock.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/locks/lock.cc)（chromium.googlesource.com）。
:::

## 示例

每个示例都先检测 `navigator.locks`，并给出缺失时执行的分支；在上文列出的浏览器里，回退分支只会在非安全源上运行。

### 跨标签页串行化 IndexedDB 迁移

打开同一个数据库的两个标签页可能同时判断需要升级 schema。把升级包进一把独占锁，第二个标签页就会先等待，然后发现活已经干完。没有锁支持时函数直接执行升级，这也正是引入锁之前代码的行为。

```js
async function withLock(name, work) {
  if (!('locks' in navigator)) return work();
  return navigator.locks.request(name, work);
}

await withLock('db-migration', async () => {
  const version = await readSchemaVersion();
  if (version < 3) await migrateTo3();
});
```

回调的返回值会成为外层 Promise 的值，所以 `withLock()` 可以把持锁期间读到的数据直接返回，不必再跑一个来回。

### 用 AbortSignal 在超时后放弃

排在一个卡在长回调里的标签页后面的请求，否则会一直等下去。传入 `AbortSignal` 并由定时器中止它；拒绝原因就是传给 `abort()` 的值，没传时为 `AbortError`。

```js
async function requestWithTimeout(name, ms, work) {
  if (!('locks' in navigator)) return work();
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new DOMException('Lock wait exceeded', 'TimeoutError')), ms);
  try {
    return await navigator.locks.request(name, { signal: controller.signal }, work);
  } catch (err) {
    if (err.name === 'TimeoutError') return null;
    throw err;
  } finally {
    clearTimeout(timer);
  }
}
```

锁一旦授予，信号就被忽略，之后再中止也不会打断 `work()`。

### 让读者共享缓存、由一个写者刷新

共享锁允许任意数量的读者同时进行，而写者要等它们全部结束，然后阻塞新的读者。`ifAvailable: true` 让刷新步骤在已有写者持锁时直接跳过，而不是排队再做一次重复刷新。

```js
async function readCatalog() {
  if (!('locks' in navigator)) return loadCatalogFromCache();
  return navigator.locks.request('catalog', { mode: 'shared' }, () => loadCatalogFromCache());
}

async function refreshCatalog() {
  if (!('locks' in navigator)) return fetchAndStoreCatalog();
  return navigator.locks.request('catalog', { ifAvailable: true }, async (lock) => {
    if (!lock) return 'skipped';
    await fetchAndStoreCatalog();
    return 'refreshed';
  });
}
```

刷新总是返回 `'skipped'` 时，`navigator.locks.query()` 能显示是哪个 `clientId` 以什么模式持有 `catalog`。

## 另请参阅

- [Storage Buckets API](/zh/reference/capabilities/storage-buckets/)，决定哪些上下文共享同一个锁管理器的边界
- [IndexedDB](/zh/reference/storage/indexeddb/)，大多数锁名最终保护的存储
- [Clients API](/zh/reference/service-worker/clients-api/)，`query()` 快照中 `clientId` 的来源
- [Web Locks API: request() method](https://w3c.github.io/web-locks/#api-lock-manager-request)（w3.org）
- [Web Locks API: LockOptions](https://w3c.github.io/web-locks/#dictdef-lockoptions)（w3.org）
- [Web Locks API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API)（developer.mozilla.org）