用于管理倒计时的hooks
参数
我们先来看Hooks的入参类型和返回值类型
Params
可以接收四个参数:
- 如果同时传入
leftTime
和targetDate
,则leftTime
优先级更高 targetDate
的类型是dayjs.ConfigType
:string | number | Date | Dayjs | null | undefined
interval
的默认值是1000ms
tsx
// Params
export interface Options {
// 剩余时间(毫秒)
leftTime?: number;
// 目标时间
targetDate?: TDate;
// 变化时间间隔(毫秒)
interval?: number;
// 倒计时结束触发的函数
onEnd?: () => void;
}
Result
返回两个参数:
- countdown: 倒计时毫秒时间戳, 类型为
number
- formattedRes: 格式化后的倒计时,类型如下:
tsx
export interface FormattedRes {
// 天数
days: number;
// 小时
hours: number;
// 分钟
minutes: number;
// 秒
seconds: number;
// 毫秒
milliseconds: number;
}
源码
整体源码在最后, 我们一步步拆解
入参处理
入参处理的思路便是参数归一化:剩余时间 -> 目标时间 -> 剩余时间:
-
memoLeftTime
: 判断传入的剩余时间是否是有效的,如果是,则将传入的剩余时间转换成备用的目标时间 -
target
: 最终的目标时间,这里先判断leftTime
和targetDate
的优先级,如果传入了leftTime
,则取memoLeftTime
作为目标时间,否则取targetDate
-
[timeLeft, setTimeLeft]
: 初始化的剩余时间的毫秒时间戳state -
onEndRef
: 倒计时结束时触发的函数,使用useLatest
包一层是为了避免闭包问题
tsx
const useCountDown = (options: Options = {}) => {
const { leftTime, targetDate, interval = 1000, onEnd } = options || {};
const memoLeftTime = useMemo<TDate>(() => { return isNumber(leftTime) &&
leftTime > 0 ? Date.now() + leftTime : undefined; }, [leftTime]);
const target = 'leftTime' in options ? memoLeftTime : targetDate;
const [timeLeft, setTimeLeft] = useState(() => calcLeft(target));
const onEndRef = useLatest(onEnd);
...
}
calcLeft
上面初始化timeLeft
state时,使用到了calcLeft
函数,主要功能是计算出目标时间和当前时间的差值,如果小于等于0,则倒计时结束:
tsx
const calcLeft = (target?: TDate) => {
if (!target) {
return 0;
}
// 计算差值
const left = dayjs(target).valueOf() - Date.now();
// 如果小于0,则返回0,否则返回差值本身
return left < 0 ? 0 : left;
};
倒计时源码
先判断目标时间是否有效,若无效,则直接结束倒计时
接下来是立即执行一次差值计算,目的是为了消除首次渲染的延迟显示以及确保依赖变化时状态同步更新
然后开启一个定时器,依赖传入的interval
参数,每隔一段时间执行一次差值计算。如果最后返回的差值为0,则清除定时器,同时触发传入的onEnd
函数
当依赖变化和组件卸载时,清除定时器
tsx
useEffect(() => {
if (!target) {
// for stop
setTimeLeft(0);
return;
}
// 立即执行一次
setTimeLeft(calcLeft(target));
const timer = setInterval(() => {
const targetLeft = calcLeft(target);
setTimeLeft(targetLeft);
if (targetLeft === 0) {
clearInterval(timer);
onEndRef.current?.();
}
}, interval);
return () => clearInterval(timer);
}, [target, interval]);
格式化返回值
最后是对返回值进行格式化,parseMs
函数是将当前timeLeft
毫秒数结构化为时间单位(天,时, 分, 秒, 毫秒),其中使用%
取模是为了避免计算结果超出规定的自然的时间导致显示出错:
tsx
const formattedRes = useMemo(() => parseMs(timeLeft), [timeLeft]);
return [timeLeft, formattedRes] as const;
- parseMs:
tsx
const parseMs = (milliseconds: number): FormattedRes => {
return {
days: Math.floor(milliseconds / 86400000),
hours: Math.floor(milliseconds / 3600000) % 24,
minutes: Math.floor(milliseconds / 60000) % 60,
seconds: Math.floor(milliseconds / 1000) % 60,
milliseconds: Math.floor(milliseconds) % 1000,
};
};
整体源码
tsx
import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import useLatest from '../useLatest';
import { isNumber } from '../utils/index';
export type TDate = dayjs.ConfigType;
// Params
export interface Options {
leftTime?: number;
targetDate?: TDate;
interval?: number;
onEnd?: () => void;
}
export interface FormattedRes {
days: number;
hours: number;
minutes: number;
seconds: number;
milliseconds: number;
}
const calcLeft = (target?: TDate) => {
if (!target) {
return 0;
}
// https://stackoverflow.com/questions/4310953/invalid-date-in-safari
const left = dayjs(target).valueOf() - Date.now();
return left < 0 ? 0 : left;
};
const parseMs = (milliseconds: number): FormattedRes => {
return {
days: Math.floor(milliseconds / 86400000),
hours: Math.floor(milliseconds / 3600000) % 24,
minutes: Math.floor(milliseconds / 60000) % 60,
seconds: Math.floor(milliseconds / 1000) % 60,
milliseconds: Math.floor(milliseconds) % 1000,
};
};
const useCountdown = (options: Options = {}) => {
const { leftTime, targetDate, interval = 1000, onEnd } = options || {};
const memoLeftTime = useMemo<TDate>(() => {
return isNumber(leftTime) && leftTime > 0 ? Date.now() + leftTime : undefined;
}, [leftTime]);
const target = 'leftTime' in options ? memoLeftTime : targetDate;
const [timeLeft, setTimeLeft] = useState(() => calcLeft(target));
const onEndRef = useLatest(onEnd);
useEffect(() => {
if (!target) {
// for stop
setTimeLeft(0);
return;
}
// 立即执行一次
setTimeLeft(calcLeft(target));
const timer = setInterval(() => {
const targetLeft = calcLeft(target);
setTimeLeft(targetLeft);
if (targetLeft === 0) {
clearInterval(timer);
onEndRef.current?.();
}
}, interval);
return () => clearInterval(timer);
}, [target, interval]);
const formattedRes = useMemo(() => parseMs(timeLeft), [timeLeft]);
return [timeLeft, formattedRes] as const;
};
export default useCountdown;