多标签页的协调困境
现代 Web 应用越来越复杂,用户经常在多个标签页中打开同一个应用。一个在线文档编辑器可能被同时打开在三个标签页里;一个股票交易网站可能同时运行在多个窗口中。这些场景都面临同一个问题:如何让多个独立的 JavaScript 执行上下文协调工作?
2018 年之前,开发者没有优雅的解决方案。最常用的做法是通过 localStorage 配合时间戳模拟锁机制:写入一个带有时间戳的标记,其他标签页检查这个标记是否存在且未过期。这种方案存在致命缺陷——它完全依赖"先检查后操作"的假设,但检查和写入之间没有任何原子性保证。两个标签页可能同时检查到"锁空闲",然后同时写入,最终造成数据竞争。
// 传统 localStorage 锁的典型实现(有缺陷)
function acquireLock(lockName) {
const lockData = localStorage.getItem(lockName);
if (!lockData || Date.now() - JSON.parse(lockData).timestamp > 5000) {
localStorage.setItem(lockName, JSON.stringify({
timestamp: Date.now(),
tabId: Math.random().toString(36)
}));
return true;
}
return false;
}
这段代码的问题在于:从 getItem 读取到 setItem 写入之间,没有任何机制阻止其他标签页执行相同的操作。这只是最显而易见的问题。即使加上轮询检测、随机退避、心跳续约等机制,也无法从根本上解决崩溃后的锁释放问题——如果一个标签页在持有锁时崩溃,其他标签页只能等待超时。
更复杂的方案涉及 SharedWorker 或 BroadcastChannel,但它们各有局限。SharedWorker 需要单独维护一个持久化的 Worker 实例,增加了架构复杂度;BroadcastChannel 只能广播消息,无法保证消息处理的顺序性和互斥性。
这就是 Web Locks API 诞生的背景。它不是一个简单的 API 封装,而是浏览器提供的一项基础设施级能力——将锁管理下沉到浏览器内核层面,由浏览器统一协调同一源下的所有锁请求。
锁的本质:从请求到释放
理解 Web Locks API 的关键在于理解三个核心概念:锁请求(Lock Request)、锁(Lock)、锁管理器(Lock Manager)。
锁请求是开发者发起的意图声明。当你调用 navigator.locks.request('my-resource', callback) 时,你只是在说:“我想要获取名为 my-resource 的锁”。这个请求会被放入一个队列中等待处理。
锁是被授予后的状态。当浏览器决定你的请求可以被满足时,它会创建一个锁对象,并调用你提供的回调函数。这个锁对象包含两个属性:name(资源名称)和 mode(模式)。
锁管理器是浏览器的内部组件,它维护着两个关键数据结构:已持有锁集合(held lock set)和锁请求队列映射表(lock request queue map)。每个资源名称对应一个独立的请求队列,锁管理器根据当前持有的锁状态和队列中的请求顺序,决定哪个请求可以被授予。
sequenceDiagram
participant Tab1
participant Tab2
participant LockManager
Tab1->>LockManager: request('resource')
LockManager->>LockManager: 检查 held set: 空
LockManager->>LockManager: 检查 queue: 空
LockManager->>Tab1: 授予锁,执行回调
Note over Tab1: 持有锁中...
Tab2->>LockManager: request('resource')
LockManager->>LockManager: 检查 held set: Tab1 持有
LockManager->>LockManager: 将请求加入 queue
Note over Tab2: 等待中...
Tab1->>LockManager: 回调结束,释放锁
LockManager->>LockManager: 从 held set 移除
LockManager->>LockManager: 处理 queue
LockManager->>Tab2: 授予锁,执行回调
锁的释放机制是 Web Locks API 设计中最精妙的部分。锁不是通过显式调用来释放的,而是与回调函数的生命周期绑定。当回调函数返回的 Promise settle 时,锁自动释放。这种 RAII(Resource Acquisition Is Initialization)风格的设计有两个重要优势:
第一,避免了忘记释放锁的问题。无论回调函数是正常返回还是抛出异常,锁都会被释放。
第二,自动处理了标签页崩溃的情况。如果持有锁的标签页崩溃或被强制关闭,浏览器会检测到这个上下文的终止,并自动释放该标签页持有的所有锁。
// 锁的自动释放机制
await navigator.locks.request('my-resource', async (lock) => {
// 锁已被授予,开始执行
await doSomeWork();
await doMoreWork();
// 函数返回,Promise settle,锁自动释放
});
// 即使抛出异常,锁也会被释放
try {
await navigator.locks.request('my-resource', async (lock) => {
throw new Error('Something went wrong');
});
} catch (e) {
// 异常被捕获,锁已被释放
}
排他锁与共享锁:读者写者问题的浏览器解法
Web Locks API 支持两种锁模式:排他锁(exclusive)和共享锁(shared)。默认模式是排他锁,同一时刻只有一个上下文可以持有排他锁。共享锁允许多个上下文同时持有同一资源的锁,但排他锁和共享锁不能同时存在。
这种设计直接对应经典的读者写者问题。在数据库访问场景中,多个标签页可以同时读取数据(共享锁),但写入时需要独占访问(排他锁)。
// 多个标签页可以同时持有共享锁
await navigator.locks.request('database', { mode: 'shared' }, async (lock) => {
const data = await readFromDatabase();
// 其他标签页也可以同时读取
});
// 但排他锁会等待所有共享锁释放
await navigator.locks.request('database', { mode: 'exclusive' }, async (lock) => {
await writeToDatabase();
// 此时没有其他标签页可以读取或写入
});
锁管理器的调度算法遵循严格的规则。一个锁请求被认为是"可授予的"(grantable),需要满足两个条件:
- 该请求是其资源队列中的第一个请求
- 对于排他锁请求:当前没有同名的锁被持有;对于共享锁请求:当前没有同名的排他锁被持有
这种 FIFO 的公平性保证了请求不会无限期等待(没有饥饿问题),但也意味着如果一个长时间运行的共享锁持有者不断有新的共享锁请求加入,排他锁请求可能会被推迟。这是读者写者问题中读者优先策略的体现。
图片来源: W3C Web Locks API 规范
四个高级选项的权衡
除了 mode,request() 方法还接受三个选项:ifAvailable、steal 和 signal。它们分别解决不同的问题场景。
ifAvailable 选项用于"尝试获取但不等待"的场景。如果锁可以立即授予,就执行回调;否则回调参数为 null,表示未获取到锁。这适合实现"单例标签页"模式——只有一个标签页执行后台同步任务。
// 单例后台同步
const lock = await navigator.locks.request('bg-sync', { ifAvailable: true }, async (lock) => {
if (!lock) {
console.log('另一个标签页正在同步,跳过');
return;
}
await performBackgroundSync();
});
steal 选项是一种"紧急接管"机制。它会强制释放当前持有的同名锁,并将锁授予当前请求。这看似危险,但在某些恢复场景中很有用:如果检测到持有锁的标签页已经无响应(通过其他通信机制判断),可以强制接管。
// 紧急接管(谨慎使用)
await navigator.locks.request('critical-resource', { steal: true }, async (lock) => {
await performRecovery();
});
需要注意的是,steal 只对排他锁有效,且不能与 ifAvailable 同时使用。
signal 选项接受一个 AbortSignal,允许取消锁请求。最常见的用法是实现超时:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
await navigator.locks.request('resource', { signal: controller.signal }, async (lock) => {
clearTimeout(timeoutId);
await doWork();
});
} catch (e) {
if (e.name === 'AbortError') {
console.log('等待锁超时,已取消');
}
}
诊断能力:query() 方法的调试价值
Web Locks API 提供了一个强大的诊断方法 navigator.locks.query(),它返回当前锁管理器的状态快照,包括所有持有的锁和等待中的请求。
const state = await navigator.locks.query();
console.log(state);
// 输出示例:
// {
// held: [
// { name: "resource1", mode: "exclusive", clientId: "abc123" },
// { name: "resource2", mode: "shared", clientId: "def456" }
// ],
// pending: [
// { name: "resource1", mode: "exclusive", clientId: "ghi789" }
// ]
// }
clientId 是浏览器分配的唯一标识符,对应一个特定的浏览上下文(标签页或 Worker)。在 Service Worker 中,这个 ID 与 Client.id 相同,可以用来识别持有锁的客户端。
这个方法在调试死锁问题时特别有用。如果一个请求迟迟得不到响应,query() 可以告诉你是哪个客户端持有锁,以及有多少请求在排队。
与传统方案的深度对比
为了理解 Web Locks API 的价值,有必要将其与之前的解决方案进行全面对比。
localStorage 方案的根本缺陷
localStorage 方案的核心问题在于它本质上是一个存储 API,而不是同步原语。它有以下固有局限:
缺乏原子性:检查锁状态和设置锁标记是两个独立操作,中间存在竞态窗口。即使使用 localStorage 的同步 API 特性(它确实是同步的),也无法解决这个问题,因为同步性只保证了操作本身的原子性,不保证多步骤操作的整体原子性。
崩溃恢复不可靠:典型的解决方案是在锁数据中写入时间戳,其他标签页通过检查时间戳判断锁是否过期。但这引入了一个两难选择:超时时间设置太短,可能误杀正常运行的标签页;设置太长,崩溃后需要等待很久才能恢复。
轮询效率低:为了检测锁状态变化,需要定时轮询 localStorage,这既浪费 CPU 资源,又增加了代码复杂度。
BroadcastChannel 的局限
BroadcastChannel 是一个真正的通信 API,但它只能广播消息,无法实现互斥。虽然可以通过消息传递协议模拟锁(例如,一个"协调者"标签页管理所有锁请求),但这需要选举协调者,而协调者选举本身又需要锁——这是一个循环依赖。
SharedWorker 的复杂度
SharedWorker 确实可以充当锁协调者的角色,因为它在所有连接的标签页之间共享状态。但代价是:
- 需要维护一个独立的 Worker 脚本
- Worker 的生命周期管理复杂(何时启动,何时关闭)
- 所有操作都需要通过
postMessage异步通信,增加了延迟
Web Locks API 的优势
相比之下,Web Locks API 由浏览器原生实现,有以下核心优势:
真正的原子性:锁的授予和释放由浏览器的锁管理器原子化处理,不存在竞态窗口。
自动崩溃恢复:浏览器监控所有浏览上下文的生命周期,上下文终止时自动释放其持有的锁。这比任何应用层方案都更可靠。
公平调度:FIFO 队列保证请求按顺序处理,避免饥饿。
零依赖:不需要额外的 Worker 脚本或轮询机制。
典型应用场景与实现模式
领导者选举(Leader Election)
在需要"主标签页"执行后台任务的场景中,领导者选举是一个经典模式。Web Locks API 使得实现变得极其简单:
async function runAsLeader() {
// 持有锁不释放,成为领导者
return new Promise((resolve) => {
navigator.locks.request('leader-election', async (lock) => {
if (!lock) return;
// 成为领导者,开始执行后台任务
await startBackgroundTasks();
// 这个 Promise 永不 resolve,锁永不释放
// 除非标签页关闭或崩溃
return new Promise(() => {});
});
});
}
其他标签页的锁请求会被排队。当领导者标签页关闭或崩溃时,浏览器自动释放锁,队列中的下一个请求被授予,新的领导者诞生。
IndexedDB 多标签页同步
IndexedDB 本身支持事务,但事务只在单个连接内有效。当多个标签页同时操作同一个数据库时,需要在应用层协调。Web Locks API 可以与 IndexedDB 事务配合使用:
async function safeWrite(data) {
await navigator.locks.request('idb-write', async (lock) => {
const db = await openDatabase();
const tx = db.transaction('store', 'readwrite');
const store = tx.objectStore('store');
await store.put(data);
await tx.complete;
// 锁在这里自动释放
});
}
资源预加载协调
如果一个应用需要在多个标签页中预加载相同的资源,可以使用共享锁让一个标签页负责加载:
async function preloadResource(url) {
const cache = await caches.open('resources');
// 先检查是否已缓存
const cached = await cache.match(url);
if (cached) return cached;
// 尝试获取加载锁
await navigator.locks.request(`preload:${url}`, { ifAvailable: true }, async (lock) => {
if (!lock) {
// 其他标签页正在加载,等待完成
return waitForCache(url);
}
// 当前标签页负责加载
const response = await fetch(url);
await cache.put(url, response.clone());
return response;
});
}
死锁风险与预防
像任何锁机制一样,Web Locks API 也存在死锁风险。死锁发生在两个或多个上下文相互等待对方持有的锁时。
// 标签页 1
await navigator.locks.request('lock-a', async () => {
await navigator.locks.request('lock-b', async () => {
// 嵌套持有两个锁
});
});
// 标签页 2
await navigator.locks.request('lock-b', async () => {
await navigator.locks.request('lock-a', async () => {
// 顺序相反,可能死锁
});
});
预防死锁的策略包括:
全局锁顺序约定:为所有锁名称定义一个全局顺序,所有嵌套锁请求必须按此顺序进行。例如,按字母顺序:总是先请求 lock-a,再请求 lock-b。
避免嵌套锁请求:如果一个操作需要多个资源,将它们合并成一个锁,或者重新设计资源分配逻辑。
使用超时:通过 signal 选项为锁请求设置超时,避免无限等待。
// 使用超时避免无限死锁
const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);
try {
await navigator.locks.request('lock-a', { signal: controller.signal }, async () => {
// ...
});
} catch (e) {
if (e.name === 'AbortError') {
console.log('可能发生死锁,已超时取消');
}
}
浏览器兼容性与 Polyfill 策略
Web Locks API 的浏览器支持情况:
| 浏览器 | 支持版本 |
|---|---|
| Chrome | 69+ |
| Firefox | 96+ |
| Safari | 15.4+ |
| Edge | 79+ |
对于需要支持旧浏览器的场景,可以使用 Polyfill。需要注意的是,Polyfill 只能基于 localStorage 实现,因此无法提供与原生 API 相同的可靠性保证。
// 检测支持并提供降级方案
if ('locks' in navigator) {
await navigator.locks.request('resource', callback);
} else {
// 降级到 localStorage 方案
await acquireLocalStorageLock('resource', callback);
}
Bitovi 提供的 web-locks-polyfill 包含一个基于 localStorage 的部分实现,但不支持 query() 方法,且选项参数有限。更重要的是,它的默认锁超时时间为 2 秒,如果回调函数执行时间超过这个值,可能会出现重复执行的问题。
与分布式锁的本质差异
Web Locks API 与分布式锁(如 Redis 锁、ZooKeeper 锁)在概念上相似,但有根本差异:
作用域:Web Locks API 只在同一浏览器实例内的同一源下有效。不同的浏览器、不同的设备、甚至同一设备的不同用户配置文件之间,锁是独立的。它不是跨设备的分布式协调机制。
持久性:Web Locks API 的锁是易失的,浏览器关闭后所有锁都会消失。这与分布式锁的持久化特性形成对比。
一致性保证:Web Locks API 由单一浏览器进程管理,不存在网络分区问题。分布式锁需要考虑 CAP 权衡。
用途定位:Web Locks API 解决的是客户端协调问题,分布式锁解决的是服务端协调问题。它们是互补而非替代关系。
安全边界与隐私考量
Web Locks API 的安全设计遵循以下原则:
同源隔离:锁严格按照源隔离。https://example.com 的锁与 https://example.org 的锁完全独立,即使它们运行在同一个浏览器进程中。
隐私模式隔离:每个隐私浏览会话被视为独立的用户代理,其锁与正常会话完全隔离。这防止了通过锁机制检测用户是否处于隐私模式。
存储分区一致性:如果浏览器对存储进行了分区(例如,将第三方 iframe 的存储与顶级页面隔离),锁也会相应分区。这确保锁的特权级别与存储 API 保持一致。
无持久化:锁不会写入持久存储,不会留下任何痕迹。这保护了用户隐私。
API 设计的哲学思考
Web Locks API 的设计体现了几个重要的 API 设计原则:
基于回调而非返回值:锁通过回调函数持有,而不是返回一个需要手动释放的对象。这种设计避免了忘记释放锁的问题,也使得锁的生命周期与代码块作用域自然绑定。
Promise 集成:回调函数可以是 async 函数,锁会等待 Promise settle 后再释放。这使异步代码的锁管理变得直观。
渐进式能力:基础用法极其简单(一行代码),高级功能(共享锁、偷锁、超时)通过选项逐步引入。这降低了学习曲线。
诊断优先:query() 方法提供了完整的可观测性,这在调试并发问题时至关重要。
总结
Web Locks API 代表了 Web 平台能力的一次重要演进。它填补了客户端协调机制的空白,将锁管理从应用层下沉到浏览器内核层。
对于开发者而言,这意味着以前需要复杂实现(且容易出错)的跨标签页协调,现在可以用几行代码优雅解决。领导者选举、数据库同步、资源预加载协调等场景,都有了标准化的解决方案。
但它不是万能的。Web Locks API 解决的是同一浏览器内的协调问题,无法替代服务端的分布式协调机制。它也不是消息传递的替代品——如果需要在标签页之间传输数据,仍然需要 BroadcastChannel 或 postMessage。
在选择使用 Web Locks API 时,需要评估应用场景是否真正需要跨标签页协调。简单的 Web 应用可能完全不需要它;但对于复杂的富应用——在线文档编辑器、协作工具、交易系统——它是一个值得掌握的利器。
参考资料
- Web Locks API - W3C Specification
- Web Locks API - MDN Web Docs
- Cross-tab Synchronization with the Web Locks API - SitePen
- Web Locks API Explained - Bits and Pieces
- web-locks-polyfill - GitHub
- Can I use Web Locks API
- Mastering Cross-Window Communication - Medium
- Preventing race conditions with LockManager - Volodymyr Potiichuk