登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

Web Locks API 怎么避免多个标签页重复执行任务

来源:17golang原创

时间:2026-10-05 03:25:12 348浏览 收藏

多个标签页同时启动缓存刷新、数据同步或后台轮询时,可以让它们请求同一个 Web Lock。只要使用默认的 exclusive 模式,同一时刻就只有一个同源浏览上下文能持有这把锁。

但要注意一个容易踩坑的区别:默认排队只能避免同时执行,不能自动保证任务永远只执行一次。等待中的标签页会在前一个回调结束后依次拿到锁。真正避免重复,要根据任务类型选择 ifAvailable 直接跳过,或在锁内重新检查完成标记;涉及服务端副作用时,还要加幂等键。

官方参考:MDN Web Locks API、LockManager.request()、Web Locks 规范。

先按任务目标选方案
任务目标建议方式还要补什么
忙时直接跳过,不排队{ ifAvailable: true }之后要有新的触发机会
任务最终必须完成一次默认排队 + 锁内检查完成标记持久化状态与失败重试
多个标签页只保留一个轮询者长期持有 exclusive 锁退出信号与接管逻辑
会修改服务端数据Web Lock + 幂等键服务端唯一约束或去重记录

先分清:互斥不等于只执行一次

navigator.locks.request(name, callback) 会请求一把按名称区分的锁。默认模式是 exclusive:同名锁被占用时,后续请求进入队列;获得锁后执行回调,回调返回的 Promise 完成或失败时,锁自动释放。

因此下面这段代码能保证三个标签页不会同时执行 refreshCache(),却不能保证它只执行一次。A 标签页完成后,B、C 仍可能依次进入回调:

await navigator.locks.request("cache-refresh:v1", async () => {
  await refreshCache();
});

如果业务要求“今天的报表只生成一次”,锁只是把并发请求排成一列;你还需要在拿到锁之后检查“今天是否已经完成”。如果任务会向服务端扣款、创建订单或提交消息,浏览器锁之外还要让服务端识别同一个幂等键。

多标签页 Web Locks 互斥、完成标记与服务端幂等的静态关系图
图1:exclusive 锁负责浏览器内互斥,完成标记负责挡住排队后的重复,幂等键负责覆盖浏览器边界之外的重复提交。这是静态说明图,不是运行截图。

Web Locks 能覆盖哪些边界

Web Locks 适合协调共享同一存储桶的页面和 Worker。日常开发可以把它理解成“同源浏览上下文之间的命名锁”,但它不是跨设备、跨浏览器配置或跨用户的分布式锁。

  • API 需要安全上下文,正式环境应运行在 HTTPS 下。
  • 锁名由应用自定义,同名任务必须使用完全一致的名称。
  • 默认 exclusive 适合写入、同步和单领导者任务;shared 允许多个持有者同时进入,不适合防止重复执行。
  • 页面与 Worker 都可以使用,但不同设备、独立浏览器配置和隔离的隐私会话不会共享同一把锁。
  • 锁名只表达浏览器内的抽象资源,不会自动锁住数据库记录或服务端接口。

上线前先做能力检测:

function supportsWebLocks() {
  return "locks" in navigator;
}

如果不支持,不要用一个 localStorage 布尔值假装成原子锁,因为“读取未占用”和“写入占用”之间仍有竞争窗口。可靠的降级通常放到服务端幂等、唯一约束或任务队列中。

三种抢锁方式怎么选

选择方案时,先问两个问题:其他标签页要不要等待?当前持有者失败后,任务是否必须被接管?答案不同,锁的生命周期也不同。

默认排队:适合必须进入临界区再判断的任务

不传选项时,请求会等待。它适合“所有触发都要看到最新状态,但最终只允许一份结果”的任务。正确做法是在获得锁后重新读取持久化状态,而不是在请求锁之前判断。

ifAvailable:适合忙时直接放弃的任务

设置 ifAvailable: true 后,如果锁不能立即授予,回调仍会被调用,但参数是 null。这不会抛出“锁忙”错误,也不会进入等待队列。

长期持锁:适合轮询、WebSocket 或领导者任务

让回调在整个后台循环期间保持未完成,锁就会一直被持有。其他标签页等待;当前标签页关闭或回调结束后,下一个请求者可以接管。重点是让停止信号既能取消尚未获批的请求,也能让已经获批的循环主动返回。

Web Locks 默认排队、ifAvailable 与长期持锁策略静态对照图
图2:默认排队适合锁内检查,ifAvailable 适合忙时跳过,长期持锁适合只保留一个活动领导者。这是静态结构图,不是执行流程图。

方案一:任务忙时让其他标签页立即跳过

缓存预热、非关键刷新、可由下一次定时器再次触发的任务,通常不需要排队。下面的函数用返回值告诉调用方本次是否真正执行:

async function refreshIfFree() {
  if (!("locks" in navigator)) {
    return { ran: false, reason: "unsupported" };
  }

  let ran = false;

  await navigator.locks.request(
    "cache-refresh:v1",
    { ifAvailable: true },
    async (lock) => {
      if (!lock) return;

      ran = true;
      await refreshCache();
    },
  );

  return { ran, reason: ran ? "completed" : "busy" };
}

核对点有两个:一是一定要判断 lock 是否为 null;二是必须 await refreshCache()。如果回调里只启动一个未等待的 Promise,回调会提前完成,锁也会提前释放。

这种方案的代价是:赢家执行失败时,已经跳过的标签页不会自动回来补做。因此它适合“稍后再触发也没关系”的工作,不适合必须交付一次结果的结算、迁移或提交任务。

方案二:锁内检查完成标记,挡住顺序重复

对于“最终必须完成一次”的任务,可以允许请求排队,但每个标签页拿到锁后都重新检查完成标记。完成状态建议放在 IndexedDB 等同源持久化存储中。下面的 jobState 表示项目自己的 IndexedDB 封装:

async function runDailyReport(dateKey) {
  const jobKey = `daily-report:${dateKey}`;

  await navigator.locks.request(jobKey, async () => {
    const state = await jobState.get(jobKey);
    if (state?.status === "done") {
      return;
    }

    await createReport({
      dateKey,
      idempotencyKey: jobKey,
    });

    await jobState.put({
      key: jobKey,
      status: "done",
      finishedAt: Date.now(),
    });
  });
}

为什么完成标记必须在锁内检查?因为两个标签页若先在锁外同时看到“未完成”,再依次拿锁,它们仍会各执行一次。把读取、任务和写入都放入同一个回调,后来的标签页才能看到前一个留下的最新状态。

仍有一个崩溃窗口:服务端已经创建报表,但页面在写入本地 done 前崩溃。所以上例把稳定的 jobKey 同时作为服务端幂等键;服务端应对相同键返回同一结果,而不是再次创建。

方案三:只让一个标签页承担持续轮询

实时通知、后台同步和定时拉取常常只需要一个活动标签页。可以让每个标签页都请求同一把长期锁,获批者运行循环,其余标签页排队等待:

async function startLeaderPoller(controller) {
  try {
    await navigator.locks.request(
      "orders-poller:v1",
      { signal: controller.signal },
      async () => {
        while (!controller.signal.aborted) {
          await pollOrdersOnce();
          await delay(15_000, controller.signal);
        }
      },
    );
  } catch (error) {
    if (error.name !== "AbortError") throw error;
  }
}

const controller = new AbortController();
startLeaderPoller(controller);

// 应用卸载或用户退出时调用:
// controller.abort();

signal 能取消尚未获得锁的请求;请求已经获批后,锁管理器不会替你中止回调,因此循环本身也必须观察同一个信号并返回。delay 也应支持 AbortSignal,避免停止时还要白等一个完整周期。

容易导致重复或卡住的风险点

锁名没有按业务资源统一

"sync"、"data-sync" 和 "sync:v1" 是三把不同的锁。把锁名集中在一个模块定义,并在需要时包含租户、用户或资源标识;不要把密钥、Token 等敏感信息放进锁名。

在回调里启动任务后立即返回

锁的持有时间由回调返回值决定。所有受保护的异步工作都必须被 await,否则任务还在运行,锁已经释放。

把 query() 当成加锁前判断

navigator.locks.query() 适合诊断当前持有和等待状态,但查询结果只是一个快照。查询完成到真正请求锁之间,状态可能已经改变,不能用它替代 request() 的原子协调。

用 shared 模式保护写任务

shared 会允许多个同名共享锁同时获批,只适合多个读取者并行、写入者使用 exclusive 的读写锁模型。避免重复执行的任务通常应保持默认 exclusive。

随意使用 steal

steal 会抢占当前持有者,但原持有者的 JavaScript 代码可能仍继续运行,只是失去了独占保证。它是异常恢复出口,不是普通选主或超时重试方案。

落地检查清单

  1. 明确任务需要“互不并发”“忙时跳过”“最终一次”还是“单领导者”。
  2. 为同一业务资源统一锁名,并保持默认 exclusive。
  3. 回调里等待完整异步任务,不让锁提前释放。
  4. 默认排队方案必须在锁内重新读取完成标记。
  5. 服务端写操作使用稳定幂等键,并建立唯一约束或去重记录。
  6. 长期任务同时处理等待阶段的 AbortSignal 和获批后的主动退出。
  7. 对不支持 Web Locks 的环境准备服务端降级,不用脆弱的 localStorage 标记冒充锁。
  8. 记录任务开始、跳过、完成、失败和接管事件,但不要用 query() 参与正确性判断。

最实用的记法是:Web Lock 管同源浏览器上下文之间的互斥,完成标记管排队后的业务去重,幂等键管浏览器边界之外的重复副作用。把这三层按任务风险组合起来,多个标签页才能既不同时抢活,也不会稍后又把同一件事做一遍。

相关问题

Web Locks 能跨浏览器或跨设备吗?

不能把它当成跨设备分布式锁。不同设备、浏览器配置或隔离会话应依赖服务端幂等、数据库唯一约束或任务队列协调。

ifAvailable 获取失败会抛异常吗?

不会因为“锁正忙”而抛异常。回调会收到 null,应用应据此跳过并记录本次未执行。

标签页关闭后锁会永远占着吗?

锁与持有它的执行上下文生命周期相关;正常设计仍应让回调可结束,并让长期任务响应应用卸载或退出信号,不要依赖页面强制关闭作为唯一释放方式。

BroadcastChannel 能替代 Web Locks 吗?

BroadcastChannel 适合广播“谁在工作”或同步状态,但消息本身不提供原子互斥。它可以配合 Web Locks 做通知,不能单独保证只有一个标签页进入临界区。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>