用户打开你的仪表盘,又在第二个标签页里打开一次,昨天还在另一台显示器上留了第三个标签页。访问令牌过期了。三个标签页几乎在同一瞬间发现这件事,然后同时向认证端点发起刷新请求。其中两个拿回了新的刷新令牌,而第三个已经把令牌作废,用户什么都没做错,却被迫面对登录界面。
常见的补救办法是一堆带时间戳的本地存储标记、一条广播频道消息,外加一行写着“这里存在竞态”的注释。它确实存在竞态。本地存储没有原子的比较并交换操作,两个标签页可能在同一个时间片里都读到“没有刷新正在进行”,然后同时写入“刷新正在进行”。你真正需要的是一把互斥锁,而浏览器从2022年3月起就内置了一把。它叫Web Locks API,属于基线广泛可用,却几乎没人用它。
一个方法解决核心问题
真正重要的方法只有一个。你给它一个名字、一个回调函数,浏览器保证同一源上的其他代码——任何标签页、任何内嵌框架、任何工作线程——都不会在同一时间运行同名锁内的代码。
await navigator.locks.request("token-refresh", async () => {const stored = readToken();if (!isExpired(stored)) return stored; // 别人已经处理过了const fresh = await fetch("/auth/refresh", { method: "POST" }).then((r) =>r.json(),writeToken(fresh);return fresh;});
锁的持有时间恰好等于回调函数返回的Promise处于待定状态的时间,回调返回或抛出异常时锁就被释放。没有需要手动调用的解锁方法,请求被拒绝时也不会留下泄漏的锁。仅凭这一点,它就比任何手写的存储标记方案更安全。
回调内部的二次检查是很多人会跳过的部分。三个标签页在“token-refresh”上排队。第一个完成网络往返。第二个和第三个随后拿到锁,发现令牌已经不再过期,于是立即返回。一次请求,三个标签页都满意。
两个选项承载主要价值
request()接收一个选项对象,其中两个选项承载了大部分价值。
mode: 'shared'提供读者-写者模式。任意数量的共享持有者可以同时持有同一个名字,但一个独占持有者会阻塞所有共享持有者。这与IndexedDB用于只读事务和读写事务的语义相同,适合多个标签页读取缓存数据集、偶尔由一个标签页重写的场景。
// 多个这样的调用可以并发运行。navigator.locks.request("catalog", { mode: "shared" }, async () => {return readCatalogFromIDB();// 这个调用会等待所有读者完成,然后阻塞新的读者。navigator.locks.request("catalog", { mode: "exclusive" }, async () => {await rewriteCatalogFromIDB(await fetchCatalog());});
ifAvailable: true把行为从“排队等待轮到你”翻转为另一种模式。当锁已经被他人持有时,请求不会等待,而是立即返回null。这个选项适合那些“能拿到锁就做,拿不到就跳过”的任务,比如后台同步或非关键的数据预取。它让调用方可以自行决定在锁不可用时的降级策略,而不是无条件阻塞。
为什么值得现在就用
Web Locks API的浏览器支持已经覆盖主流环境,不需要引入任何第三方库。它解决的是多标签页并发场景下的真实竞态问题,而这类问题在令牌刷新、缓存重建、离线数据同步等场景中反复出现。与其继续维护那些带有竞态隐患的本地存储标记,不如直接使用浏览器原生提供的互斥机制。
锁的作用域是源级别,这意味着同一站点下的不同标签页、内嵌框架和工作线程都受到同一套锁规则约束。回调内的二次检查是保证“只发一次请求”的关键步骤,省略它会让后续拿到锁的标签页重复执行刷新逻辑。把这两点结合起来,就能用很少的代码消除一类难以排查的偶发登录问题。
热门跟贴