考试倒计时、计时器

页面刷新导致倒计时/计时器不准确(requestAnimationFrame


本文章参考其他大神的计时器做了优化:页面退出监听状态更新计时器

考试倒计时场景

  • 无考试分钟数、无考试结束时间(展示计时器)
  • 无考试分钟数、有考试结束时间 (展示到结束时间的倒计时)
  • 有考试分钟数、无考试结束时间 (展示分钟数倒计时)
  • 有考试分钟数、有考试结束时间
    a. 考试分钟数 > 考试结束时间(展示到考试结束时间的倒计时)
    b. 考试分钟数 < 考试结束时间 (展示分钟数倒计时)

计时器

import React, { useEffect, useState } from 'react';
// 辅助函数,用于在数字前面添加前导零
export const pad = (num: number, size = 2) => String(num).padStart(size, '0');

// 格式化时间
const formatTime = (totalSeconds: number) => {
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;
  return {
    hours: pad(hours),
    minutes: pad(minutes),
    seconds: pad(seconds),
  };
};
const Timepiece = () => {
  const initSeconds = formatTime(parseInt(localStorage.getItem('totalSeconds') || '', 10) || 0); // 先取本地缓存秒数
  const [time, setTime] = useState(initSeconds); // 页面展示时分秒

  // 更新时间的定时器
  useEffect(() => {
    let intervalId;
    // 更新时间的函数
    const updateTimer = () => {
      // 获取已经过去的秒数(假设我们从localStorage加载了一个初始秒数)
      let totalSeconds = parseInt(localStorage.getItem('totalSeconds') || '', 10) || 0 as number;
      totalSeconds++; // 增加一秒

      // 格式化时间并更新state
      setTime(formatTime(totalSeconds));

      // 将总秒数保存到localStorage
      localStorage.setItem('totalSeconds', totalSeconds.toString());
    };

    // 使用setInterval来定期更新时间
    intervalId = setInterval(updateTimer, 1000);

    // 清除定时器的函数,组件卸载时执行
    return () => {
      clearInterval(intervalId);
    };
  }, []); // 由于我们不依赖任何state或props的变化,所以这里也是空依赖数组

  console.log('======time', time);
  return (
    <div>
     	{time.hours}:{time.minutes}:{time.seconds}
    </div>
  );
};

export default Timepiece;

倒计时

做了两个优化
1.页面刷新不会影响倒计时展示
2. 监听页面tab,更新倒计时

/* eslint-disable react-hooks/exhaustive-deps */
import React, {
  RefObject,
  useCallback, useEffect, useRef, useState,
} from 'react';
import { Dialog, Toast } from 'antd-mobile';
import { HrCandidateExaminationSubmitVO, candidateExaminationSubmit } from '@/api';
import { BaseApiResponse } from '@/utils/fetchExt';
// 辅助函数,用于在数字前面添加前导零
const pad = (num: string | number, size = 2) => String(num).padStart(size, '0');

interface ExaminationObj {
  duration: number; // 考试分钟数
  examinationId: number; // 考试id
  name: string;
  endDate: string; // 考试结束时间
}
interface PropsDTO {
  clearCountDown: () => void;
  setsubmitVisible: (val: any) => void;
  examinationObj: ExaminationObj;
  requestRef: RefObject<number>;
}
const Countdown = (props: PropsDTO) => {
  const {
    examinationObj, requestRef, setsubmitVisible, clearCountDown,
  } = props;
  const [count, setCount] = useState<number>(0);
  const [duration, setTotalDuration] = useState<number>(0);
  const previousTimeRef = useRef(null);
  const currentCountRef = useRef<number>(0);

  // 提交答案
  const onSubmit = () => {
    const { examinationId, name } = examinationObj;
    const answerList = JSON.parse(localStorage.getItem('answerList') || '[]') as HrCandidateExaminationSubmitVO[];
    	//XXXXXX调用提交接口
    }).catch((err) => {
      Toast.show({
        content: (err as BaseApiResponse).message,
        afterClose: () => {
          clearCountDown();
          window.location.href = '/ExaminationList';
        },
      });
    });
  };
  // 格式化时间
  function formatSeconds(value: number) {
    let second = parseInt((value / 1000) as unknown as string, 10); //  秒
    let minute = 0; //  分
    let hour = 0 as number | string; //  时
    if (second > 59) {
      minute = parseInt((second / 60) as unknown as string, 10);
      second = parseInt((second % 60) as unknown as string, 10);
      if (minute > 59) {
        hour = parseInt((minute / 60) as unknown as string, 10);
        minute = parseInt((minute % 60) as unknown as string, 10);
        if (hour < 10) hour = `0${hour}`; // 小时补零
      }
    }
    const result = `${pad(hour)}:${pad(minute)}:${pad(second)}`;
    console.log(pad(hour), pad(minute), pad(second));
    return result;
  }

  // 倒计时
  const animate = useCallback((time: number | null) => {
    if (previousTimeRef.current !== undefined) {
      const deltaTime = (time || 0) - (previousTimeRef.current || 0);
      if (deltaTime > 1000) {
        if (currentCountRef.current > 2000) {
          previousTimeRef.current = time;
          setCount((prevCount) => {
            currentCountRef.current = prevCount - 1000;
            localStorage.setItem('totalDuration', JSON.stringify(prevCount - 1000));
            return (prevCount - 1000);
          });
        } else {
          onSubmit();
          return;
        }
      }
    } else {
      previousTimeRef.current = time;
    }

    // console.log('=====previousTimeRef.current', previousTimeRef.current);
    requestRef.current = requestAnimationFrame(animate);
  }, []);

  // 计算倒计时
  const calculatedCountdownFn = () => {
    const timer = JSON.parse(localStorage.getItem('totalDuration') || 'null') as string; // 剩余倒计时
    const startTime = JSON.parse(localStorage.getItem('startTime') || 'null') as string || new Date().getTime(); // 首次考试开始时间
    const nowTime = new Date().getTime(); // 当前时间
    const Countdduration = examinationObj?.duration * 60 * 1000; // 倒计时毫秒
    const CountdownEndTime = (Number(startTime)) + Countdduration; // 倒计时结束时间 = 首次考试时间 + 倒计时分钟
    const fixedEndTime = new Date(examinationObj?.endDate).getTime(); // 固定结束时间
    localStorage.setItem('startTime', JSON.stringify(startTime)); // 保存首次考试时间
    console.log('=======时间差', CountdownEndTime - nowTime);

    // 没有设置倒计时-设置了考试结束时间
    if (!examinationObj.duration && examinationObj.endDate) {
      localStorage.setItem('totalDuration', JSON.stringify(fixedEndTime - nowTime));
      setCount(fixedEndTime - nowTime);
      setTotalDuration(fixedEndTime - nowTime);
    } else if ((fixedEndTime > CountdownEndTime) || (!examinationObj.endDate)) { // 倒计时时间比固定结束时间结束快时
      localStorage.setItem('totalDuration', JSON.stringify(CountdownEndTime - nowTime));
      setCount(CountdownEndTime - nowTime);
      setTotalDuration(CountdownEndTime - nowTime);
    } else { // 固定时间比倒计时时间结束快时
      localStorage.setItem('totalDuration', JSON.stringify(fixedEndTime - nowTime));
      setCount(fixedEndTime - nowTime);
      setTotalDuration(fixedEndTime - nowTime);
    }
    console.log('====fixedEndTime', fixedEndTime > CountdownEndTime);
    console.log('====CountdownEndTime', CountdownEndTime > fixedEndTime);
  };

  useEffect(() => {
    calculatedCountdownFn();
  }, []);

  // 监听切换tab
  function visibilitychangeEvent() {
    console.log('document.visibilityState', document.visibilityState, duration);
    // 用户离开了当前页面
    if (document.visibilityState === 'hidden') {
      console.log('hidden');
    }

    // 用户打开或回到页面
    if (document.visibilityState === 'visible') {
      console.log('visible');
      calculatedCountdownFn();
    }
  }

  useEffect(() => {
    if (duration <= 1000) {
      return;
    }
    currentCountRef.current = duration;
    previousTimeRef.current = null;
    if (requestRef.current) {
      cancelAnimationFrame(requestRef.current);
    }
    requestRef.current = requestAnimationFrame(animate);
    return () => {
      cancelAnimationFrame(requestRef.current);
    };
  }, [animate, duration]);

  useEffect(() => {
    // 用于监听浏览器切换Tab页
    document.addEventListener('visibilitychange', visibilitychangeEvent);
    return () => {
      document.removeEventListener('visibilityChange', visibilitychangeEvent);
    };
  }, []);

  return (
    <div>
      {formatSeconds(count)}
    </div>
  );
};

export default Countdown;

倒计时setInterval版

(使用requestAnimationFrame 发现在h5切到后台后,倒计时不会执行,导致倒计时结束后无法触发自动提交, 所以还是使用setInterval实现)

/* eslint-disable react-hooks/exhaustive-deps */
import React, {
  RefObject,
  useCallback, useEffect, useRef, useState,
} from 'react';
import { Dialog, Toast } from 'antd-mobile';
import { HrCandidateExaminationSubmitVO, candidateExaminationSubmit } from '@/api';
import { BaseApiResponse } from '@/utils/fetchExt';
// 辅助函数,用于在数字前面添加前导零
const pad = (num: string | number, size = 2) => String(num).padStart(size, '0');

interface ExaminationObj {
  duration: number;
  examinationId: number;
  name: string;
  endDate: string;
}
interface PropsDTO {
  clearCountDown: () => void;
  setsubmitVisible: (val: any) => void;
  examinationObj: ExaminationObj;
  requestRef: RefObject<number>;
}
const Countdown = (props: PropsDTO) => {
  const {
    examinationObj, requestRef, setsubmitVisible, clearCountDown,
  } = props;
  const [count, setCount] = useState<number>(0);
  const TimeRef = useRef(null);

  // 提交答案
  const onSubmit = () => {
    const { examinationId, name } = examinationObj;
    const answerList = JSON.parse(localStorage.getItem('answerList') || '[]') as HrCandidateExaminationSubmitVO[];
    candidateExaminationSubmit(examinationId, answerList).then(async (res) => {
      if (res.code === '0') {
        await Dialog.alert({
          content: '考试结束',
          // bodyClassName: styles.confirm,
          onConfirm: () => {
            setsubmitVisible(false);
            clearCountDown();
            window.location.href = '/ExaminationList';
          },
        });
      }
    }).catch((err) => {
      Toast.show({
        content: (err as BaseApiResponse).message,
        afterClose: () => {
          clearCountDown();
          window.location.href = '/ExaminationList';
        },
      });
    });
  };
  // 倒计时
  function formatSeconds(value: number) {
    let second = parseInt((value / 1000) as unknown as string, 10); //  秒
    let minute = 0; //  分
    let hour = 0 as number | string; //  时
    if (second > 59) {
      minute = parseInt((second / 60) as unknown as string, 10);
      second = parseInt((second % 60) as unknown as string, 10);
      if (minute > 59) {
        hour = parseInt((minute / 60) as unknown as string, 10);
        minute = parseInt((minute % 60) as unknown as string, 10);
        if (hour < 10) hour = `0${hour}`; // 小时补零
      }
    }
    const result = `${pad(hour)}:${pad(minute)}:${pad(second)}`;
    return result;
  }

  // 倒计时
  const countDownTime = (time: number) => {
    TimeRef.current = setInterval(() => {
      time -= 1000;
      setCount(time);
      if (time <= 1000) {
        onSubmit();
        clearInterval(TimeRef.current!);
      }
    }, 1000);
  };
  // 计算倒计时
  const calculatedCountdownFn = () => {
    const timer = JSON.parse(localStorage.getItem('totalDuration') || 'null') as number; // 剩余倒计时
    const startTime = JSON.parse(localStorage.getItem('startTime') || 'null') as string || new Date().getTime(); // 首次考试开始时间
    const nowTime = new Date().getTime(); // 当前时间
    const Countdduration = examinationObj?.duration * 60 * 1000; // 倒计时毫秒
    const CountdownEndTime = (Number(startTime)) + Countdduration; // 倒计时结束时间 = 首次考试时间 + 倒计时分钟
    const fixedEndTime = new Date(examinationObj?.endDate).getTime(); // 固定结束时间
    localStorage.setItem('startTime', JSON.stringify(startTime)); // 保存首次考试时间

    // 没有设置倒计时-设置了考试结束时间
    if (!examinationObj.duration && examinationObj.endDate) {
      localStorage.setItem('totalDuration', JSON.stringify(fixedEndTime - nowTime));
      setCount(fixedEndTime - nowTime);
      countDownTime(fixedEndTime - nowTime);
    } else if ((fixedEndTime > CountdownEndTime) || (!examinationObj.endDate)) { // 倒计时时间比固定结束时间结束快时
      localStorage.setItem('totalDuration', JSON.stringify(CountdownEndTime - nowTime));
      setCount(CountdownEndTime - nowTime);
      countDownTime(CountdownEndTime - nowTime);
    } else { // 固定时间比倒计时时间结束快时
      localStorage.setItem('totalDuration', JSON.stringify(fixedEndTime - nowTime));
      setCount(fixedEndTime - nowTime);
      countDownTime(fixedEndTime - nowTime);
    }
  };

  // 更新时间的定时器
  useEffect(() => {
    calculatedCountdownFn();
    // 清除定时器的函数,组件卸载时执行
    return () => {
      clearInterval(TimeRef.current!);
    };
  }, []); // 由于我们不依赖任何state或props的变化,所以这里也是空依赖数组

  return (
    <div>
      {formatSeconds(count)}
    </div>
  );
};

export default Countdown;

参考:https://juejin.cn/post/7187193005063798844?searchId=2024062816014818EC910885549A70E361

https://juejin.cn/post/7022636375136534565?searchId=20240628161156E7EA8E9D0A3B457099AF

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值