产品发布页、秒杀活动页、课程开课页,这些场景几乎都离不开倒计时。而倒计时结束后紧接着的往往不是空白,而是一个引导用户留下邮箱的订阅入口,方便后续通知。这两个功能单独实现都不难,但要在React里把它们联动起来,还要保证状态管理干净、定时器不泄漏,就需要一点设计了。本文带你从零实现一套倒计时加邮件订阅通知的完整方案。

一、用useState和useEffect实现基础倒计时
最直接的思路是在useEffect里用setInterval每秒减一,状态用useState维护。这个写法人人都会,但细节上很容易踩坑。首先是清理函数:如果不返回清理函数,组件卸载后定时器还在跑,接着调用setCount就会触发内存泄漏警告。其次是闭包问题:在setInterval的回调里直接读取state,拿到的是创建定时器那一刻的快照,不会随状态更新。
解决闭包问题的标准做法是使用函数式更新,也就是setCount(prev => prev - 1),让React基于上一次的状态计算新值,完全绕开闭包捕获的旧值。下面是一个最简版本:
import { useState, useEffect } from 'react';
function Countdown({ seconds }) {
const [count, setCount] = useState(seconds);
useEffect(() => {
if (count <= 0) return;
const timer = setInterval(() => {
setCount(prev => (prev >= 1 ? prev - 1 : 0));
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>剩余 {count} 秒</div>;
}注意这里useEffect的依赖数组故意留空,因为定时器只需要创建一次,靠函数式更新来推进数值。如果写成依赖count,每秒都会销毁并重建定时器,虽然功能上也能跑,但多了一次不必要的清理开销,而且如果间隔很短会明显抖动。
二、封装可复用的useCountdown Hook并处理结束态
真实项目里倒计时往往需要天数、时、分、秒的拆分显示,还要在结束时触发回调联动其他逻辑。这时候建议把它封装成自定义Hook,把目标时间戳作为入参,内部统一计算剩余时间。相比每秒减一的方案,基于目标时间戳计算的好处是:即使用户切换标签页导致定时器被浏览器降频,回到页面时也能立刻显示正确值,不会越走越偏。
另一个值得对比的点是setInterval与setTimeout递归。递归写法每次执行完才安排下一次,不会出现任务堆积;而setInterval在回调执行时间超过间隔时会排队连发。对每秒一次的倒计时来说差异不大,但养成递归的习惯在处理高频任务时更安全。
import { useState, useEffect, useCallback } from 'react';
function useCountdown(targetTime, onEnd) {
const calc = () => {
const diff = Math.max(0, targetTime - Date.now());
return {
days: Math.floor(diff / 86400000),
hours: Math.floor(diff / 3600000) % 24,
minutes: Math.floor(diff / 60000) % 60,
seconds: Math.floor(diff / 1000) % 60,
done: diff <= 0
};
};
const [remain, setRemain] = useState(calc);
const endRef = useRef(onEnd);
endRef.current = onEnd;
useEffect(() => {
if (remain.done) return;
const timer = setTimeout(() => {
const next = calc();
setRemain(next);
if (next.done && endRef.current) endRef.current();
}, 1000);
return () => clearTimeout(timer);
}, [remain]);
return remain;
}这里把onEnd存进useRef,是为了避免回调函数作为依赖导致定时器频繁重建,同时保证回调里拿到的永远是最新的闭包。倒计时结束时通过onEnd通知外层组件,正好可以作为订阅通知的联动信号。
三、倒计时结束后联动邮件订阅通知
有了结束回调,联动就简单了。设计上有两种常见策略:一是倒计时归零后自动展示订阅表单,二是活动开始前就允许订阅,结束后给已订阅用户发送通知邮件。无论哪种,前端都只需要维护一个subscribed状态,提交邮箱后调用后端接口落库。
提交邮箱时要做好三件事:正则校验格式、防重复提交、失败提示。校验通过后调用接口,成功则切换到感谢页面状态,失败则回退表单并显示错误信息。示例代码如下:
function LaunchPage() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState('idle'); // idle | loading | success | error
const target = Date.now() + 3600 * 1000; // 一小时后,实际应来自后端
const handleEnd = useCallback(() => {
// 倒计时结束,可以弹出订阅提示或触发其他运营动作
console.log('倒计时结束');
}, []);
const remain = useCountdown(target, handleEnd);
const submit = async (e) => {
e.preventDefault();
if (!/^[\w.-]+@[\w-]+\.[\w.]+$/.test(email)) {
setStatus('error');
return;
}
setStatus('loading');
try {
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
setStatus(res.ok ? 'success' : 'error');
} catch {
setStatus('error');
}
};
if (status === 'success') {
return <p>订阅成功,活动开始前会收到邮件提醒!</p>;
}
return (
<div>
<h3>{remain.done ? '活动已开始' : `${remain.days}天 ${remain.hours}:${remain.minutes}:${remain.seconds}`}</h3>
{remain.done && (
<form onSubmit={submit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="输入邮箱获取通知"
/>
<button disabled={status === 'loading'}>订阅</button>
</form>
)}
{status === 'error' && <p style={{ color: 'red' }}>邮箱格式有误或提交失败,请重试</p>}
</div>
);
}四、性能与服务端时间的几个注意点
还有一个容易被忽略的问题:目标时间从哪来。如果直接在客户端用Date.now() + N生成,用户改一下本机时间倒计时就完全失真。正确做法是由服务端下发活动的截止时间戳,客户端渲染前先算一次本地与服务端的时间偏移量,之后的倒计时都基于修正后的时间计算,这样无论用户时钟是否准确,结束时刻都是一致的。
性能方面,每秒一次的setState对现代React来说压力很小,不需要刻意优化。但如果你在倒计时组件里渲染了大量子节点,可以考虑把数值部分拆成独立小组件,或者用React.memo包住与时间无关的兄弟组件,避免每秒整棵树重渲染。另外,组件卸载时的清理一定要做,上一篇的Hook里return () => clearTimeout(timer)这行就是保命的,删掉它在切换路由后会看到控制台报错。
整套方案的核心可以总结为三点:基于目标时间戳而不是逐秒递减来保证准确性,通过自定义Hook封装并暴露结束回调,用useRef稳定回调引用避免定时器重建。把这三点理解透,倒计时加订阅通知这类运营页需求基本可以模板化地快速交付了。