Appearance
背景与问题
- 传统做法:把“剩余秒数”存本地并每秒自减。缺点:刷新/切 Tab 误差、时间漂移、多端不一致。
- 我们的目标:即便刷新页面或多标签页,倒计时仍准确且可恢复,点击后立即进入冷却,并且实现简单、易维护。
核心思路(TL;DR)
- 只存“下一次可发送时间点”
nextAllowedAt(毫秒时间戳),不存剩余秒数。 - 用一个响应式时钟
nowTick驱动remainingTime的实时计算:remainingTime = ceil((nextAllowedAt - nowTick) / 1000) - 点击成功后:立即计算
nextAllowedAt = Date.now() + resendMs,立刻启动计时。 - 多标签页/刷新:把
nextAllowedAt放进localStorage,并监听storage事件同步。 - 稳定的实例键:
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();
};易错点与排查
remainingTime 一直不变
- 忘记加
nowTick,或startTimer里没有每秒更新nowTick。
- 忘记加
刷新后倒计时归零
instanceId每次随机,导致storageKey变化。使用持久化 uuid或业务自定义instanceId。
传入的
resendTime是字符串- 强制数值化:
resendMs = Number(props.resendTime) * 1000。
- 强制数值化:
多按钮冲突
- 对不同业务传不同的
instanceId(如email、sms);对同一业务共享同一个。
- 对不同业务传不同的
可选增强
- 服务端对时:如果后端能返回
serverTime,可做serverOffset = serverTime - Date.now(),再用nowTick + serverOffset计算,更抗本地快/慢钟。 - 后端返回冷却信息:后续若后端返回
nextAllowedAt或cooldownSeconds,把成功逻辑改为使用后端值即可,整体架构无需变。 - 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。 - [ ] 刷新页面,倒计时正确续接。
- [ ] 多标签页打开同一页面,任一处点击,另一处也同步进入冷却。
- [ ] 冷却结束自动恢复可点击,本地存储清理。
- [ ] 不依赖后端返回“剩余秒数”,后续后端支持也能平滑接入。
结语
用时间点 + 响应式时钟替换“存剩余秒数并自减”,能在简单的实现下获得更稳的行为与更好的用户体验。后续若要上服务端校准与跨端一致性,也可以无缝演进。