小知识,大挑战!本文正在参与「程序员必备小知识」创作活动
本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金
前言:倒计时功能相信大家都很熟悉,脑海中最首先闪过的念头是使用setInterval实现,如此简单,两下子就实现了倒计时需求,开始了一天愉快的搬砖,然而了解了requestAnimationFrame后,发现使用它来实现更香
倒计时用 setInterval 实现
来了来了,先献上最简单的使用setInterval实现倒计时的代码方案
const totalDuration = 10 // 倒计时 10s
let duration = totalDuration
let countDownInterval = null
function countDown() {
countDownInterval = setInterval(() => {
duration = duration - 1
console.log(duration)
if (duration <= 0) {
clearInterval(countDownInterval)
}
}, 1000)
}
countDown()
setInterval
实现DEMO可点击
使用 setInterval 实现的缺点
但是,使用setInterval实现的倒计时准确吗?我们可以做个DEMO验证下,可以看到,每一秒大概有-2ms-7ms
的偏差值不等,「网上很多文章说每次setInterval 设置1000ms的间隔,实际执行是大于1000ms的」,但经过我用做DEMO实现,设置1000ms间隔,其实有可能是会小于1000ms
,因此用setInterval实现每秒的偏差值并不是直接简单累计,我们可以在倒计时结束后才计算结束时间距离开始时间的相差值
有可能出现时间间隔是小于1000ms
的
下图是倒计时100s的时间差8ms
setInterval 偏差值计算
实现DEMO可点击
const totalDuration = 100; // 100s
let duration = totalDuration;
let countDownInterval = null;
let startTime = new Date().getTime();
let endTime = startTime;
let prevEndTime = startTime
let timeDifferance = 0; // 每1s倒计时偏差值,单位ms
let totalTimeDifferance = 0; // 总共倒计时偏差值,单位ms
function countDown() {
countDownInterval = setInterval(() => {
duration = duration - 1;
prevEndTime = endTime
endTime = new Date().getTime();
console.log(`当前执行时间:${endTime}, 首次执行时间:${startTime}`);
console.log(`当前和首次执行的时间间隔差:${endTime - startTime}`)
timeDifferance = endTime - prevEndTime
document.getElementById("app").innerText = duration;
document.getElementById("differance").innerText = timeDifferance;
if (duration <= 0) {
totalTimeDifferance = endTime - startTime - totalDuration * 1000;
console.log(`累计时间差: ${totalTimeDifferance}`)
clearInterval(countDownInterval);
}
}, 1000);
}
countDown();
但是,假如前面加有段线程阻塞代码,每秒偏差值就会变得很大了,让人吃惊
setInterval( () => {
let n = 0;
while(n++ < 1000000000);
}, 0);
加了这段线程阻塞代码后,只是倒计时10s,累计时间差已经高达8s
了
总而言之,我们要尽量修正这个误差值,大概思路是下一次执行倒计时时,把执行的偏差值提前,那么,每次执行的时间间隔不是固定为1s的,因此使用setTimeout
替代setInterval
方案
使用 setTimeout 实现倒计时
按照上面所述,为了修正倒计时的计算误差,我们可把上面的代码修改成
const totalDuration = 100; // 100s
let duration = totalDuration;
let countDownInterval = null;
let startTime = new Date().getTime();
let endTime = startTime;
let prevEndTime = startTime;
let timeDifferance = 0; // 每1s倒计时偏差值,单位ms
let totalTimeDifferance = 0; // 总共倒计时偏差值,单位ms
let interval = 1000; // 1s
function countDown() {
duration = duration - 1;
endTime = new Date().getTime();
timeDifferance = endTime - prevEndTime;
console.log(`当前倒计时:${duration}, 每秒执行的偏差值:${timeDifferance}`)
let nextTime = interval - timeDifferance
// 如果下一次执行时间超过当前周期,需要特俗处理一下
if (nextTime < 0) {
nextTime = 0
}
document.getElementById("nextTime").innerText = nextTime;
if (duration <= 0) {
totalTimeDifferance = endTime - startTime - totalDuration * 1000;
console.log(`累计执行的偏差值:${totalTimeDifferance}`)
clearTimeout(countDownInterval);
} else {
countDownInterval = setTimeout(() => countDown(), nextTime);
}
}
countDownInterval = setTimeout(() => countDown(), interval);
注意:如果下次执行的时间间隔小于0秒,则需要特俗处理一下
如下图所示,累计时间差可以控制在1s内了
同样加了线程阻塞代码倒计时10s
,误差值可降低到2.5s
,说明还是优化效果是很明显的,当然实际变成过程中阻塞这么严重是很极端的情况,重点不再是去降低延迟,而是关注重构导致阻塞的代码啦
setTimeout 实现倒计时
实现DEMO可点击
那么除了使用setTimeout实现倒计时,是否有更好的解决方案呢?
requestAnimationFrame是什么
RAF主要是按照显示器的刷新频率(60Hz 或者 75Hz)对页面进行重绘,大概按照这个刷新频率同步重绘页面,就是大概1s最多重绘60次或者75次的频次,按照60Hz计算每次重绘大概16.67ms重绘一次,如果setInterval设置的频次低于16.67ms,会造成过渡绘制
的问题,如果高于16.67ms,有可能会出现掉帧
的情况
我们要知道的是,window.requestAnimationFrame(callback) 的执行时机是在浏览器下一次重绘前
调用RAF里的回调函数获取最新的动画计算结果
setTimeout 和 requestAnimationFrame 对比
现代浏览器的tab处于不被激活状态时,requestAnimationFrame是会停止执行的
当前tab页面不处于被「激活」状态,setTimeout会继续在后台执行任务,我们可以再上面的setTimeout的DEMO增加log,当我们切换去其他tab,当前tab页处于失焦状态,setTimeout是会继续执行的,然而使用requestAnimationFrame(以下简称RAF),可以看到log控制台,我们切换出去的时间段是没有执行的,这会利于提升性能
当然setTimeout可以通过监听window.visibilityChange()事件去优化处理
setTimeout,setInterval属于JS引擎,RAF属于GUI引擎
做项目过程中发现假如在加载一张很大的图,用setInterval制作的倒计时会出现卡顿然后突然加速
的情况,原因在于JS里的JS线程和GUI线程是互斥的,如果在执行GUI线程很久,会对JS线程进行阻塞,因此出现了这种情况,我们下面会做DEMO进行验证
使用 requestAnimationFrame 替代 setInterval 实现倒计时方案
const totalDuration = 10 * 1000;
let requestRef = null;
let startTime;
let prevEndTime;
let prevTime;
let currentCount = totalDuration;
let endTime;
let timeDifferance = 0; // 每1s倒计时偏差值,单位ms
let interval = 1000;
let nextTime = interval;
setInterval(() => {
let n = 0;
while (n++ < 1000000000);
}, 0);
const animate = (timestamp) => {
if (prevTime !== undefined) {
const deltaTime = timestamp - prevTime;
if (deltaTime >= nextTime) {
prevTime = timestamp;
prevEndTime = endTime;
endTime = new Date().getTime();
currentCount = currentCount - 1000;
console.log("currentCount: ", currentCount / 1000);
timeDifferance = endTime - startTime - (totalDuration - currentCount);
console.log(timeDifferance);
nextTime = interval - timeDifferance;
// 慢太多了,就立刻执行下一个循环
if (nextTime < 0) {
nextTime = 0;
}
console.log(`执行下一次渲染的时间是:${nextTime}ms`);
if (currentCount <= 0) {
currentCount = 0;
cancelAnimationFrame(requestRef);
console.log(`累计偏差值: ${endTime - startTime - totalDuration}ms`);
return;
}
}
} else {
startTime = new Date().getTime();
prevTime = timestamp;
endTime = new Date().getTime();
}
requestRef = requestAnimationFrame(animate);
};
requestRef = requestAnimationFrame(animate);
requestAnimationFrame
实现DEMO可点击
就算执行线程阻塞代码,偏差值也只是98ms
特别注意的点
兼容性问题
requestAnimationFrame兼容性可点击查看,目前我们兼容范围都在IE10及以上,大可不用再太担心兼容性问题了
离开页面时销毁监听事件
-
RAF使用cancelAnimationFrame销毁
-
wekit内核浏览器使用 webkitCancelRequestAnimationFrame 优于使用 webkitCancelAnimationFrame
使用react hooks和requestAnimationFrame实现倒计时案例
若要处理偏差值,可以参考上面的写法处理哦
import React, { useState, useEffect, useRef } from "react";
const [count, setCount] = useState<number>(0)
const [duration, setTotalDuration] = useState<number>(0)
const requestRef = useRef(null);
const previousTimeRef = useRef(null);
const currentCountRef = useRef<number>(0);
const animate = time => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
if (deltaTime > 1000) {
if (currentCountRef.current > 0) {
previousTimeRef.current = time;
setCount(prevCount => {
currentCountRef.current = prevCount - 1000
return prevCount - 1000
});
} else {
setCount(0)
cancelAnimationFrame(requestRef.current);
return
}
}
} else {
previousTimeRef.current = time;
}
requestRef.current = requestAnimationFrame(animate);
}
useEffect(() => {
const totalDuration = 60 * 1000
setCount(totalDuration)
setTotalDuration(totalDuration)
}, [])
useEffect(() => {
if (duration <= 0) {
return
}
currentCountRef.current = duration
previousTimeRef.current = undefined
if (requestRef.current) {
cancelAnimationFrame(requestRef.current)
}
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current)
}, [duration])
最后
以上是我使用requestAnimationFrame实现倒计时的一些小心得,希望能对大家有帮助~如果能获得一个小小的赞作为鼓励会十分感激!!
更多文章:「不再迷茫!看了这篇文章让你上手Vue3.0开发有丝滑般体验」
「欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章」