使用 Web Locks API 实现多标签页上传的协调处理
通过用 navigator.locks 和 React 上传协调器替代 localStorage 与 BroadcastChannel 的临时方案,杜绝在不同浏览器标签页间重复进行可恢复的上传操作。
并发意味着同时处理多项任务,或至少让系统看起来如此,从而保持软件的响应能力。一名厨师一边切菜,一边用一个锅炖汤,再用另一个锅烤东西,这就是在并行处理多项任务,而非严格按顺序完成每道菜。
过去,前端开发似乎与那些带有锁、信号量及死锁机制的经典多线程系统相去甚远。但现代网络平台改变了这一状况。借助 Worker、WebGL、Canvas 以及 Service Worker,浏览器应用现在面临着类似共享内存竞争的协调问题。
问题所在
一个市场平台工程团队需要一种能够可靠上传超大文件的方法——文件大小在数十吉字节左右,最高可达约20GB。要求用户在整个传输过程中一直保持浏览器标签页打开状态并不稳妥。该产品的目标是实现可恢复上传功能:即使关闭并重新打开标签页,也能无需手动重启即可继续传输。
可恢复上传功能带来了新的故障模式。如果多个标签页试图恢复同一份正在传输的文件,每个标签页都可能独立重新开始传输。这样一来,对象存储系统会收到重复的数据流,导致带宽浪费、处理成本上升,而且由于多个标签页争夺同一项任务,进度显示界面也会出现混乱。
棘手之处在于,每个标签页单独来看都在执行合理操作:会话恢复时会找到不完整的上传记录,客户端会继续进行多部分传输,而用户界面则会显示进度。当同时打开两个标签页时,同样的“合理”流程就会变成两个写入操作针对同一个远程对象键。
如果没有协调机制,两个被恢复的标签页都会认为自己拥有该上传内容的控制权,从而同时发送数据字节。解决此问题需要在各个标签页之间实现互斥机制;浏览器通过 Web Locks 提供了这种基础功能。
在探讨实现细节之前,先回顾一下锁的作用会有所帮助。
首先,什么是锁?
锁用于对共享资源访问进行序列化处理,从而使调用方始终看到一致的状态。经典的银行示例中,有多台自动取款机同时读取和写入同一个账户余额。如果没有同步机制,取款和存款操作就会以不安全的方式交错进行。
通过锁机制,第一个获取锁的调用者可以执行读写操作,其他调用者则需等待锁释放。这一原理同样适用于“仅一个标签页可继续此上传操作”的限制。
跨标签页协调的现状
一些较旧的浏览器实现尝试对标签页进行协调,但在出现竞态条件、崩溃或协议错误时便不再有效。
方案1:localStorage + 轮询
一种常见的自制互斥锁会在localStorage中存储一个标志位:
// Simplified example - DON'T use this
if (!localStorage.getItem('upload-lock')) {
localStorage.setItem('upload-lock', Date.now());
// Start upload
}
但很快就会出现问题:
- 竞态条件:读写操作并非原子性操作
- 失效锁:崩溃的标签页会使标志位永久处于已锁定状态
- 轮询开销:其他标签页必须持续检查状态
- 事件限制:存储事件会在其他标签页触发,而非在执行写操作的标签页
方案2:Broadcast Channel API
标签页可以通过 BroadcastChannel 进行跨文档消息通信:
const channel = new BroadcastChannel('upload-coordination');
channel.postMessage({ type: 'REQUEST_UPLOAD_PERMISSION' });
但仍存在一些挑战:
- 没有互斥原语:必须自行设计状态机
- 竞态条件:同时发送的消息仍可能产生冲突
- 协议复杂:心跳检测、崩溃识别及冲突处理规则都需要手动实现
- 易出错:顺序和时序问题十分常见
Web Locks API 的出现:浏览器原生的互斥机制
原生 Web Locks 提供了原子级的浏览器互斥锁。其实用特性包括:
- 原子级锁获取——可避免 localStorage 的竞态问题
- 自动清理——关闭标签页或发生崩溃时会自动释放锁
- 非阻塞选项——保持用户界面的响应能力
我们的实现方式:useUploadCoordinator
一个自定义的 React 钩子用于封装上传所有权的处理策略。
核心策略
需将交互式启动与静默恢复区分对待。当用户明确选择文件时,应优先采用非阻塞获取方式,以避免界面在其他标签页中等待。如果锁处于空闲状态,则立即获取并开始上传;若已被占用,仍需根据产品规则做出决策,而非冻结页面。
对于恢复的上传内容,则需更严格地处理:只有第一个重新获取所有权的标签页才能继续传输。后续看到锁已被占用的标签页应让当前所有者继续操作,而非开启第二个数据流。
// 1. Fresh uploads (user-initiated): Non-blocking
// Try to get the lock, but proceed regardless
navigator.locks.request(LOCK_NAME,
{ ifAvailable: true },
async (lock) => {
if (lock) {
// Hold the lock until upload completes
await holdLockUntilComplete(abortController);
}
// Upload proceeds either way - never block the user
}
);
// 2. Restored uploads: Conditional
// Only proceed if we get the lock
navigator.locks.request(LOCK_NAME,
{ ifAvailable: true },
async (lock) => {
if (lock) {
uploader.emit("restore-confirmed"); // Proceed with restore
await holdLockUntilComplete(abortController);
}
// If no lock, skip silently - another tab is handling it
}
);
关键实现细节
在传输完成或中止之前一直保持锁状态:
const holdLockUntilComplete = (abortController: AbortController) => {
return new Promise<void>((resolve) => {
const cleanup = () => {
// Remove all listeners and resolve (releasing the lock)
uploader.off("complete", cleanup);
uploader.off("error", cleanup);
uploader.off("cancel-all", cleanup);
resolve();
};
// Release lock on any terminal event
uploader.on("complete", cleanup);
uploader.on("error", cleanup);
uploader.on("cancel-all", cleanup);
// Also release if component unmounts
abortController.signal.addEventListener("abort", cleanup);
});
};
检测支持情况,当缺少相关 API 时安全降级:
const checkWebLocksAvailable = (): boolean => {
return typeof navigator !== 'undefined' && 'locks' in navigator;
};
// Fallback behavior
if (!checkWebLocksAvailable()) {
// Allow restores to proceed (accept risk of duplicates)
uploader.emit("restore-confirmed");
return;
}
实际应用中的优势
在推出该协调器后,团队观察到:
- 由于多标签页同时恢复导致的零重复上传
- 更好的用户体验——协调机制不会使用户界面卡住
- 降低基础设施成本——重复传输和存储的对象更少
- 代码结构更简洁——无需轮询循环或手动构建的状态机
只有获得锁状态的标签页会继续上传;未获锁的标签页会检测到锁已被占用并停止操作。
实际应用中的考量因素
何时使用此模式:
- 需要重新加载后恢复的文件上传
性能影响:
- 影响极小——无需轮询循环
- 自动清理机制可限制泄漏的等待线程
- 非阻塞请求能保持交互的响应性
测试策略:
- 测试所有已发布的支持浏览器
- 模拟在持有锁时标签页突然崩溃的情况
- 确认在 Web Locks 不可用时的备用行为
结论
跨标签页协调无需复杂的自定义协议。只需利用平台的 Web Locks 互斥锁:它在标签页关闭时会自动清理,并支持非阻塞获取方式。具体 API 详情可参阅 Web Locks 文档。
在实际生产环境中经验证有效的规则:
- 切勿阻塞用户操作——对于用户发起的操作,应优先使用非阻塞锁机制
- 确保安全失败——出现故障时应降低功能表现,而非完全中断上传过程
- 保持简洁——使用平台提供的原生功能,而非自行重新实现
这三条规则比任何特定的辅助函数名称都更为重要。那种会冻结点击流程的复杂协调机制,其体验远不如那种偶尔要求用户重试的简单备用方案。同样,一个忽略了崩溃后清理工作的巧妙消息传递协议,也会重新引发曾让localStorage使用极为不便的死锁问题。
每当需要为共享任务选定唯一负责人时,无论是否涉及大文件上传,都适用同样的模式。后台同步队列、离线数据刷新工具以及一次性迁移脚本都能从这种“一个胜出者,多个谦逊的失败者”的架构中受益。