Skip to content

背景与问题

  • 传统做法:把“剩余秒数”存本地并每秒自减。缺点:刷新/切 Tab 误差、时间漂移、多端不一致。
  • 我们的目标:即便刷新页面多标签页,倒计时仍准确可恢复,点击后立即进入冷却,并且实现简单、易维护

核心思路(TL;DR)

  1. 只存“下一次可发送时间点” nextAllowedAt(毫秒时间戳),不存剩余秒数。
  2. 用一个响应式时钟 nowTick 驱动 remainingTime 的实时计算: remainingTime = ceil((nextAllowedAt - nowTick) / 1000)
  3. 点击成功后:立即计算 nextAllowedAt = Date.now() + resendMs立刻启动计时
  4. 多标签页/刷新:把 nextAllowedAt 放进 localStorage,并监听 storage 事件同步。
  5. 稳定的实例键instanceId 使用持久化 uuid 兜底(或业务自定义),刷新不变。

实现步骤

步骤 1:稳定的实例 ID(避免刷新后 key 改变)

ts
// 复杂逻辑:生成并持久化一个 uuid(仅首次生成);父组件也可显式传入 instanceId 以区分业务场景
const PERSIST_ID_KEY = "email_verify_persist_id";
let persistedId = "";
try {
  persistedId = localStorage.getItem(PERSIST_ID_KEY) || "";
  if (!persistedId) {
    persistedId = uuidv4();
    localStorage.setItem(PERSIST_ID_KEY, persistedId);
  }
} catch {
  /* ignore */
}

const instanceId = props.instanceId || persistedId || uuidv4();
const storageKey = `email_verify_nextAllowedAt_${instanceId}`;

步骤 2:只存时间点,不存剩余秒数

ts
// 下一次可发送的时间戳(毫秒)
const nextAllowedAt = ref<number | null>(null);

步骤 3:响应式“时钟”驱动剩余时间计算

ts
// 复杂逻辑:增加一个响应式“时钟”,每秒刷新以触发重算
const nowTick = ref(Date.now());

// 复杂逻辑:由时间点实时计算剩余秒数,而非自减,避免漂移与刷新误差
const remainingTime = computed(() => {
  if (!nextAllowedAt.value) return 0;
  const ms = nextAllowedAt.value - nowTick.value;
  return ms > 0 ? Math.ceil(ms / 1000) : 0;
});

步骤 4:开始计时(刷新时钟 + 心跳 + 到期清理)

ts
const timer = ref<ReturnType<typeof setInterval> | null>(null);

const startTimer = () => {
  if (timer.value) clearInterval(timer.value);
  // 复杂逻辑:先立即打点一次,UI 立刻更新
  nowTick.value = Date.now();
  timer.value = setInterval(() => {
    // 复杂逻辑:刷新“时钟”,触发 remainingTime 重算
    nowTick.value = Date.now();
    if (remainingTime.value <= 0) {
      if (timer.value) {
        clearInterval(timer.value);
        timer.value = null;
      }
      nextAllowedAt.value = null;
      try {
        localStorage.removeItem(storageKey);
      } catch {}
    }
  }, 1000);
};

步骤 5:点击成功后立刻进入冷却

ts
// 复杂逻辑:保证 props.resendTime 可以是数字或字符串
const resendMs = computed(
  () => Math.max(0, Number(props.resendTime ?? 60)) * 1000
);

// 成功后:
nextAllowedAt.value = Date.now() + resendMs.value;
nowTick.value = Date.now(); // 立即刷新一次,UI 不延迟
try {
  localStorage.setItem(storageKey, String(nextAllowedAt.value));
} catch {}
startTimer();

步骤 6:挂载/卸载与多标签页同步

ts
// 恢复与清理
onMounted(() => {
  try {
    const raw = localStorage.getItem(storageKey);
    const ts = raw ? Number(raw) : NaN;
    nextAllowedAt.value = Number.isFinite(ts) ? ts : null;
  } catch {
    nextAllowedAt.value = null;
  }

  if (remainingTime.value > 0) startTimer();
  else {
    try {
      localStorage.removeItem(storageKey);
    } catch {}
  }

  // 跨标签页同步
  window.addEventListener("storage", onStorage);
});

onUnmounted(() => {
  if (timer.value) {
    clearInterval(timer.value);
    timer.value = null;
  }
  window.removeEventListener("storage", onStorage);
  if (remainingTime.value <= 0) {
    try {
      localStorage.removeItem(storageKey);
    } catch {}
  }
});

const onStorage = (e: StorageEvent) => {
  if (e.key !== storageKey) return;
  const ts = e.newValue ? Number(e.newValue) : NaN;
  nextAllowedAt.value = Number.isFinite(ts) ? ts : null;
  if (remainingTime.value > 0) startTimer();
};

易错点与排查

  1. remainingTime 一直不变

    • 忘记加 nowTick,或 startTimer 里没有每秒更新 nowTick
  2. 刷新后倒计时归零

    • instanceId 每次随机,导致 storageKey 变化。使用持久化 uuid或业务自定义 instanceId
  3. 传入的 resendTime 是字符串

    • 强制数值化:resendMs = Number(props.resendTime) * 1000
  4. 多按钮冲突

    • 不同业务不同的 instanceId(如 emailsms);对同一业务共享同一个。

可选增强

  • 服务端对时:如果后端能返回 serverTime,可做 serverOffset = serverTime - Date.now(),再用 nowTick + serverOffset 计算,更抗本地快/慢钟。
  • 后端返回冷却信息:后续若后端返回 nextAllowedAtcooldownSeconds,把成功逻辑改为使用后端值即可,整体架构无需变。
  • BroadcastChannel:多标签页更实时的同步可用 BroadcastChannel('send-code') 广播 nextAllowedAt

最简可复用片段(粘贴即用)

ts
// state
const nextAllowedAt = ref<number | null>(null);
const nowTick = ref(Date.now());
const timer = ref<ReturnType<typeof setInterval> | null>(null);

// computed
const resendMs = computed(
  () => Math.max(0, Number(props.resendTime ?? 60)) * 1000
);
const remainingTime = computed(() => {
  if (!nextAllowedAt.value) return 0;
  const ms = nextAllowedAt.value - nowTick.value;
  return ms > 0 ? Math.ceil(ms / 1000) : 0;
});

// startTimer
const startTimer = () => {
  if (timer.value) clearInterval(timer.value);
  nowTick.value = Date.now();
  timer.value = setInterval(() => {
    nowTick.value = Date.now();
    if (remainingTime.value <= 0) {
      clearInterval(timer.value!);
      timer.value = null;
      nextAllowedAt.value = null;
      try {
        localStorage.removeItem(storageKey);
      } catch {}
    }
  }, 1000);
};

// success
nextAllowedAt.value = Date.now() + resendMs.value;
nowTick.value = Date.now();
localStorage.setItem(storageKey, String(nextAllowedAt.value));
startTimer();

验收清单

  • [ ] 点击发送后,按钮立即进入冷却,文本显示 xxs
  • [ ] 刷新页面,倒计时正确续接
  • [ ] 多标签页打开同一页面,任一处点击,另一处也同步进入冷却
  • [ ] 冷却结束自动恢复可点击,本地存储清理。
  • [ ] 不依赖后端返回“剩余秒数”,后续后端支持也能平滑接入

结语

时间点 + 响应式时钟替换“存剩余秒数并自减”,能在简单的实现下获得更稳的行为与更好的用户体验。后续若要上服务端校准跨端一致性,也可以无缝演进。

本站总访问