react hooks 实现一个简单的倒计时

5,671 阅读3分钟

UseCountdown

功能

hooks 的功能为:传入字符串形式的终止日期 deadline 和 日期的 moment format 字符串,返回由剩余天数、小时、分钟和秒组成的对象

效果

屏幕录制2021-03-2220.gif

思考过程

先写好入参和返回值的 TS 表示:

// 入参
export interface ICountdown {
  deadline: string; // 终止日期
  format?: 'YYYY-MM-DD HH:mm:ss' | string;
}
// 返回值
export type Remains = Record<'day' | 'hour' | 'minute' | 'second', number>;

开始写方法体之前,先思考一下需要哪些状态:

首先,需要一个不断更新的当前时间(初始化可以用 moment 获取当前时间),时间获取后,设置一个每秒更新一次的定时器。

其次,还要通过比较每秒更新后的时间和用户传入的 deadline,计算剩余时间。

所以我们需要两个状态:currentremains

current 保存当前时间;remains 保存计算后的时间,并返回给使用者。

const [current, setCurrent] = useState(moment());
const [remains, setRemains] = useState<Remains>({
  day: 0,
  hour: 0,
  minute: 0,
  second: 0,
});

deadline 发生变化后,创建每秒执行一次的定时器,如果当前时间大于或等于 deadline,则清除定时器,否则时间增加一秒;

useEffect(() => {
  const timer = window.setInterval(() => {
    current.isSameOrAfter(moment(deadline, format))
      ? clearInterval(timer)
      : setCurrent(prev => prev.current.add(1, 's'));
  }, 1000);
  return () => clearInterval(timer);
}, [deadline]);

当当前时间 current 变化时,比较 currentdeadline,计算剩余时间:

注:我一开始计算 remains 的方法特别蠢,用 deadline.valueOf() - current.valueOf() 得到一个毫秒数,然后回传给 moment 后 format。实际上结果是以差值为毫秒数,得到了相对于 Unix 纪元以来的某个时间,而不是真实的时间差值。最后手动根据毫秒数做除法求秒、分、时和天的值

useEffect(() => {
  let millisec = moment(deadline, format).valueOf() - current.valueOf();
  setRemains({
    day: Math.floor(millisec / (1000 * 60 * 60 * 24)),
    hour: Math.floor((millisec / (1000 * 60 * 60)) % 24),
    minute: Math.floor((millisec / (1000 * 60)) % 60),
    second: Math.round((millisec / 1000) % 60),
  });
}, [current]);

但由于 currentmoment 创建的对象,属于引用类型,每次使用 prev.current.add(1, 's') 更新 current 后,在原始对象基础上做更新,引用关系不变,useEffect 感知不到 current 的变化,无法触发 hooks。

所以,我这里将 current 改为 current + updater 的形式,updater 每次更新后 +1,useEffect 感知 updater 的变化。

const [{ current, updater }, setCurrent] = useState({
  current: moment(),
  updater: 0,
});

最终代码如下:

import { useEffect, useState } from 'react';
import moment from 'moment';

// 入参
export interface ICountdown {
  deadline: string;
  format?: 'YYYY-MM-DD HH:mm:ss' | string;
}
// 返回值
export type Remains = Record<'day' | 'hour' | 'minute' | 'second', number>;

const useCountdown = ({
  deadline,
  format = 'YYYY-MM-DD HH:mm:ss',
}: ICountdown): Remains => {
  // 由于 moment() 返回对象,setCurrent 修改值后指针不变,无法在 useEffect 中捕获变化,所以这里定义了一个 updater 用于 useEffect 捕获时间更新
  const [{ current, updater }, setCurrent] = useState({
    current: moment(),
    updater: 0,
  });
  const [remains, setRemains] = useState<Remains>({
    day: 0,
    hour: 0,
    minute: 0,
    second: 0,
  });
  useEffect(() => {
    const timer = window.setInterval(() => {
      current.isSameOrAfter(moment(deadline, format))
        ? clearInterval(timer)
        : setCurrent(prev => ({
            current: prev.current.add(1, 's'),
            updater: prev.updater + 1,
          }));
    }, 1000);
    return () => clearInterval(timer);
  }, [deadline]);

  // current 变化,计算相差多长时间
  useEffect(() => {
    let millisec = moment(deadline, format).valueOf() - current.valueOf();
    // 处理 millisec 可能为负数的情况
    millisec = millisec >= 0 ? millisec : 0;
    // 用毫秒数得到秒、分、小时和天
    setRemains({
      day: Math.floor(millisec / (1000 * 60 * 60 * 24)),
      hour: Math.floor((millisec / (1000 * 60 * 60)) % 24),
      minute: Math.floor((millisec / (1000 * 60)) % 60),
      second: Math.round((millisec / 1000) % 60),
    });
  }, [updater]);

  return remains;
};

export default useCountdown;

使用方式如下,可以直接传入一个字符串和 format,或者用 useMemo() 缓存 moment format 缓存后的结果。(如果不缓存的话,每次 useCountdown 返回结果更新,触发 Index 函数执行,每次函数执行都会产生一个新的 deadline 值)

// countdown
import React, { useMemo } from 'react';
import useCountdown from '@/components/Countdown';
import moment from 'moment';

const Index = () => {
  const deadline = useMemo(
    () =>
      moment()
        .add(4, 's')
        .add(1, 'm')
        .add(1, 'h')
        .add(1, 'd')
        .format('YYYY-MM-DD HH:mm:ss'),
    [],
  );
  const { day, hour, minute, second } = useCountdown({
    // deadline: '2021-04-22 17:49:00',
    deadline,
  });

  return <h1>{`${day}天${hour}时${minute}分${second}秒`}</h1>;
};

export default Index;