背景
最近做了一个周末嘉年华的活动【免费领取「王者荣耀千元账号」】,效果图如下。玩法也很简单:点击开始,计时器开始计时,点击停止,点击开始按钮后会变成停止,当计时结束时,秒表显示时间为 10:00 时,即可获取 「价值千元的王者荣耀账号」!
编组
点我体验 !!!
若遇到活动未开始或者活动结束,可以前往转转app搜索【游戏】即可参与更多活动,各种福利拿到手软!
需求分析
从图上可以看出来,核心就是一个正向计时器。通过js实现一个普通的正向计时器很简单,大多数想到都是使用setInterval来实现。那么还有没有其他的实现方式呢?又怎么去实现一个高精度的毫秒级正向计时器呢?
最近看了vant4的倒计时组件的源码,发现其并没有使用setInterval, 而是封装了requestAnimationFrame 和利用 Date.now()来处理毫秒级渲染和倒计时实现。那么能不能通过requestAnimationFrame来实现一个正向计时器呢?
先看看效果图,接下来将会一步步去实现:
体验地址: https://suyxh.github.io/timer-demo/
setInterval版
首先呢,来看看使用setInterval是如何实现的。在网上看了很多文章,大多都是使用的 setInterval 去实现,大致效果如下:
setinterval
从效果图上我们可以发现,最后一位始终为0,甚至还是有些小bug,很明显不是我们想要的。具体代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" cnotallow="IE=edge">
<meta name="viewport" cnotallow="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<input type="text" id="timetext" value="00时00分00秒" readonly>
<br>
<br>
<button type="button" notallow="start()">开始</button>
<button type="button" notallow="stop()">暂停</button>
<button type="button" notallow="Reset()">重置</button>
<script>
//初始化变量
let hour, minute, second;//时 分 秒
hour = minute = second = 0;//初始化
let millisecond = 0;//毫秒
let int;
//重置函数
function Reset () {
window.clearInterval(int);
millisecond = hour = minute = second = 0;
document.getElementById('timetext').value = '00时00分00秒000毫秒';
}
//开始函数
function start () {
int = setInterval(timer, 50);//每隔50毫秒执行一次timer函数
}
//计时函数
function timer () {
millisecond = millisecond + 50;
if (millisecond >= 1000) {
millisecond = 0;
second = second + 1;
}
if (second >= 60) {
second = 0;
minute = minute + 1;
}
if (minute >= 60) {
minute = 0;
hour = hour + 1;
}
document.getElementById('timetext').value = hour + '时' + minute + '分' + second + '秒' + millisecond + '毫秒';
}
//暂停函数
function stop () {
window.clearInterval(int);
}
</script>
</body>
</html>
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
requestAnimationFrame版
上文中提到vant的CutDown组件,主要就是利用 Date.now() 会自己走的原理,结合 requestAnimationFrame 去做时间计算;那么正向计时器则是利用了 requestAnimationFrame 回调函数的参数去做时间计算,从而实现毫秒级的计时器。
「window.requestAnimationFrame()」 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行,当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数 (即你的回调函数)。
「注意:」 若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用 window.requestAnimationFrame()
MDN requestAnimationFrame
「参数」
- callback下一次重绘之前更新动画帧所调用的函数 (即上面所说的回调函数)。该回调函数会被传入DOMHighResTimeStamp参数,该参数与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻。
「返回值」
一个 long 整数,请求 ID,是回调列表中唯一的标识。是个非零值,没别的意义。你可以传这个值给 window.cancelAnimationFrame() 以取消回调函数。
测试版
通过 requestAnimationFrame API可以知道,回调函数中的参数就是一个 DOMHighResTimeStamp参数,该参数与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻。
那我们直接使用该值不就可以了吗?试试看:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" cnotallow="IE=edge">
<meta name="viewport" cnotallow="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">hello world</div>
<div id="status">这里显示倒计时状态</div>
<button class="start">开始</button>
<br />
<script>
const render = (time) => {
document.querySelector("#status").innerHTML = Math.floor(time) / 1000
}
const useCountUp = () {
let rafId;
let endTime;
const step = (timestamp) => {
console.log('timestamp', timestamp)
render(timestamp)
rafId = window.requestAnimationFrame(step)
}
const start = () {
rafId = window.requestAnimationFrame(step)
}
return {
start,
}
}
const { start } = useCountUp();
document.querySelector('.start').addEventListener('click', () => {
start()
})
</script>
</body>
</html>
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
效果如下:
测试版
虽然比较简陋,但是并没有出现 setInterval版 的bug,接下来在一步步优化。
简易版
我们加上格式化时间的函数 parseTime() 和 parseFormat(), 代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" cnotallow="IE=edge">
<meta name="viewport" cnotallow="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">hello world</div>
<div id="status">这里显示倒计时状态</div>
<button class="start">开始</button>
<br />
<script>
/**
* @description: 补0操作
* @param {*} num
* @param {*} targetLength
* @return {*}
*/
function padZero (num, targetLength = 2) {
let str = num + ''
while (str.length < targetLength) {
str = '0' + str
}
return str
}
/**
* @description: 解析时间
* @param {*} time
* @return {*}
*/
function parseTime (time) {
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const days = Math.floor(time / DAY)
const hours = Math.floor((time % DAY) / HOUR)
const minutes = Math.floor((time % HOUR) / MINUTE)
const seconds = Math.floor((time % MINUTE) / SECOND)
const milliseconds = Math.floor(time % SECOND)
return {
total: time,
days,
hours,
minutes,
seconds,
milliseconds,
}
}
/**
* @description: 格式化时间
* @param {*} format
* @param {*} currentTime
* @return {*}
*/
function parseFormat (format, currentTime) {
const { days } = currentTime
let { hours, minutes, seconds, milliseconds } = currentTime
if (format.includes('DD')) {
format = format.replace('DD', padZero(days))
} else {
hours += days * 24
}
if (format.includes('HH')) {
format = format.replace('HH', padZero(hours))
} else {
minutes += hours * 60
}
if (format.includes('mm')) {
format = format.replace('mm', padZero(minutes))
} else {
seconds += minutes * 60
}
if (format.includes('ss')) {
format = format.replace('ss', padZero(seconds))
} else {
milliseconds += seconds * 1000
}
if (format.includes('S')) {
const ms = padZero(milliseconds, 3)
if (format.includes('SSS')) {
format = format.replace('SSS', ms)
} else if (format.includes('SS')) {
format = format.replace('SS', ms.slice(0, 2))
} else {
format = format.replace('S', ms.charAt(0))
}
}
return format
}
/**
* @description: 渲染时间
* @param {*} time
* @return {*}
*/
const render = (time) => {
time = parseFormat('HH:mm:ss:SSS', parseTime(time))
document.querySelector("#status").innerHTML = time
}
const useCountUp = () {
let rafId;
let endTime;
const step = (timestamp) => {
console.log('timestamp', timestamp)
// render(timestamp)
rafId = window.requestAnimationFrame(step)
}
const start = () {
rafId = window.requestAnimationFrame(step)
}
return {
start,
}
}
const { start } = useCountUp();
document.querySelector('.start').addEventListener('click', () => {
start()
})
</script>
</body>
</html>
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
- 95.
- 96.
- 97.
- 98.
- 99.
- 100.
- 101.
- 102.
- 103.
- 104.
- 105.
- 106.
- 107.
- 108.
- 109.
- 110.
- 111.
- 112.
- 113.
- 114.
- 115.
- 116.
- 117.
- 118.
- 119.
- 120.
- 121.
- 122.
- 123.
- 124.
- 125.
- 126.
- 127.
- 128.
- 129.
- 130.
- 131.
- 132.
- 133.
- 134.
- 135.
- 136.
- 137.
- 138.
- 139.
- 140.
- 141.
- 142.
- 143.
- 144.
- 145.
- 146.
- 147.
- 148.
- 149.
- 150.
- 151.
- 152.
效果如下:
简易版
又看到了我们熟悉的时间格式啦, 格式化的方法也是来源于vant的CutDown组件中的格式化代码!
格式化虽然是完成了,但是怎么去停止呢?能不能支持暂停、继续、重置呢?
接下来继续完善。
进阶版
我们直接通过 window.cancelAnimationFrame() 去取消回调函数即可!在 useCountUp函数中添加一下 pause 即可!
const pause = () {
if (rafId) {
window.cancelAnimationFrame(rafId)
}
}
效果如下:
进阶版
不少的小伙伴已经发现,停止虽然是没问题了,当再次点击开始的时候,时间怎么不对了?有瑕疵!
因为我们少算补时时间,做如下修改,添加startTime 、 stopTime 和 goOn 方法:
const useCountUp = () {
let rafId;
let startTime;
let stopTime;
const step = (timestamp) => {
console.log('timestamp', timestamp)
render(timestamp - startTime)
rafId = window.requestAnimationFrame(step)
}
const start = () {
startTime = performance.now()
rafId = window.requestAnimationFrame(step)
}
const pause = () {
stopTime = performance.now()
if (rafId) {
window.cancelAnimationFrame(rafId)
}
}
const goOn = () {
startTime += performance.now() - stopTime
rafId = window.requestAnimationFrame(step)
}
return {
start,
pause,
goOn
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
这里基本上已经完成了暂停和继续的功能了,但是仍是有些bug的,可以多次点击继续试试🙈 。
完整版
接下来,我们来修复上述的bug,方法:添加一个变量来表示当前计时器的状态。
在增加几个新功能:
- 添加 重置 方法: 其实我们只需要调用一下暂停,在清理一下时间即可
- 支持 配置:比如 正香计时的时间, 计时结束的函数
核心代码如下,其他部分代码不变:
const useCountUp = (options) => {
let rafId, startTime, stopTime, curentTime, counting = false
const step = (timestamp) => {
curentTime = timestamp - startTime
render(curentTime)
options.onChange?.(curentTime);
if (options.time) {
if (Math.floor(curentTime) < options.time) {
rafId = window.requestAnimationFrame(step)
} else {
pause()
options.onFinish?.()
}
} else {
rafId = window.requestAnimationFrame(step)
}
}
const start = () {
// 计时中 或者 已经开始过计时想要重新开始计时,应该先点击一下 重置 再开始计时
if (counting || curentTime) {
return
}
counting = true
startTime = performance.now()
rafId = window.requestAnimationFrame(step)
}
const pause = () {
// 已经暂停后,屏蔽掉点击
if (!counting) {
return
}
counting = false
stopTime = performance.now()
if (rafId) {
window.cancelAnimationFrame(rafId)
}
}
const goOn = () {
// 已经在计时中,屏蔽掉点击
if (counting) {
return
}
counting = true
startTime += performance.now() - stopTime
rafId = window.requestAnimationFrame(step)
}
const reset = () {
pause()
curentTime = 0
startTime = 0
stopTime = 0
render(0)
}
return {
start,
pause,
goOn,
reset
}
}
const { start, pause, goOn, reset } = useCountUp({
time: 3 * 1000,
onChange: current console.log('change', current),
onFinish: () console.log('finish'),
});
document.querySelector('.start').addEventListener('click', () => {
start()
})
document.querySelector('.pause').addEventListener('click', () => {
pause()
})
document.querySelector('.goOn').addEventListener('click', () => {
goOn()
})
document.querySelector('.reset').addEventListener('click', () => {
reset()
})
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
到此基本上就是实现了一个毫秒级的正向计时器!
vue版
只是对js的逻辑进行了一些封装
代码:https://github.com/SuYxh/timer-demo
预览:https://suyxh.github.io/timer-demo/
总结
正向毫秒级计时器主要就是利用了window.requestAnimationFrame的回调函数的参数为DOMHighResTimeStamp,且与performance.now()的返回值相同;在实现暂停、继续时,需要计算一下补时时间。