页面刷新导致倒计时/计时器不准确(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