背景
最近做了一個(gè)周末嘉年華的活動(dòng)【免費(fèi)領(lǐng)取「王者榮耀千元賬號(hào)」】,效果圖如下。玩法也很簡(jiǎn)單:點(diǎn)擊開(kāi)始,計(jì)時(shí)器開(kāi)始計(jì)時(shí),點(diǎn)擊停止,點(diǎn)擊開(kāi)始按鈕后會(huì)變成停止,當(dāng)計(jì)時(shí)結(jié)束時(shí),秒表顯示時(shí)間為 10:00 時(shí),即可獲取 「價(jià)值千元的王者榮耀賬號(hào)」!

編組
點(diǎn)我體驗(yàn) !!!
若遇到活動(dòng)未開(kāi)始或者活動(dòng)結(jié)束,可以前往轉(zhuǎn)轉(zhuǎn)app搜索【游戲】即可參與更多活動(dòng),各種福利拿到手軟!
需求分析
從圖上可以看出來(lái),核心就是一個(gè)正向計(jì)時(shí)器。通過(guò)js實(shí)現(xiàn)一個(gè)普通的正向計(jì)時(shí)器很簡(jiǎn)單,大多數(shù)想到都是使用setInterval來(lái)實(shí)現(xiàn)。那么還有沒(méi)有其他的實(shí)現(xiàn)方式呢?又怎么去實(shí)現(xiàn)一個(gè)高精度的毫秒級(jí)正向計(jì)時(shí)器呢?
最近看了vant4的倒計(jì)時(shí)組件的源碼,發(fā)現(xiàn)其并沒(méi)有使用setInterval, 而是封裝了requestAnimationFrame 和利用 Date.now()來(lái)處理毫秒級(jí)渲染和倒計(jì)時(shí)實(shí)現(xiàn)。那么能不能通過(guò)requestAnimationFrame來(lái)實(shí)現(xiàn)一個(gè)正向計(jì)時(shí)器呢?
先看看效果圖,接下來(lái)將會(huì)一步步去實(shí)現(xiàn):

體驗(yàn)地址: https://suyxh.github.io/timer-demo/
setInterval版
首先呢,來(lái)看看使用setInterval是如何實(shí)現(xiàn)的。在網(wǎng)上看了很多文章,大多都是使用的 setInterval 去實(shí)現(xiàn),大致效果如下:

setinterval
從效果圖上我們可以發(fā)現(xiàn),最后一位始終為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時(shí)00分00秒" readonly>
<br>
<br>
<button type="button" notallow="start()">開(kāi)始</button>
<button type="button" notallow="stop()">暫停</button>
<button type="button" notallow="Reset()">重置</button>
<script>
//初始化變量
let hour, minute, second;//時(shí) 分 秒
hour = minute = second = 0;//初始化
let millisecond = 0;//毫秒
let int;
//重置函數(shù)
function Reset () {
window.clearInterval(int);
millisecond = hour = minute = second = 0;
document.getElementById('timetext').value = '00時(shí)00分00秒000毫秒';
}
//開(kāi)始函數(shù)
function start () {
int = setInterval(timer, 50);//每隔50毫秒執(zhí)行一次timer函數(shù)
}
//計(jì)時(shí)函數(shù)
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 + '時(shí)' + minute + '分' + second + '秒' + millisecond + '毫秒';
}
//暫停函數(shù)
function stop () {
window.clearInterval(int);
}
</script>
</body>
</html>
requestAnimationFrame版
上文中提到vant的CutDown組件,主要就是利用 Date.now() 會(huì)自己走的原理,結(jié)合 requestAnimationFrame 去做時(shí)間計(jì)算;那么正向計(jì)時(shí)器則是利用了 requestAnimationFrame 回調(diào)函數(shù)的參數(shù)去做時(shí)間計(jì)算,從而實(shí)現(xiàn)毫秒級(jí)的計(jì)時(shí)器。
「window.requestAnimationFrame()」 告訴瀏覽器——你希望執(zhí)行一個(gè)動(dòng)畫(huà),并且要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動(dòng)畫(huà)。該方法需要傳入一個(gè)回調(diào)函數(shù)作為參數(shù),該回調(diào)函數(shù)會(huì)在瀏覽器下一次重繪之前執(zhí)行,當(dāng)你準(zhǔn)備更新動(dòng)畫(huà)時(shí)你應(yīng)該調(diào)用此方法。這將使瀏覽器在下一次重繪之前調(diào)用你傳入給該方法的動(dòng)畫(huà)函數(shù) (即你的回調(diào)函數(shù))。
「注意:」 若你想在瀏覽器下次重繪之前繼續(xù)更新下一幀動(dòng)畫(huà),那么回調(diào)函數(shù)自身必須再次調(diào)用 window.requestAnimationFrame()
MDN requestAnimationFrame
「參數(shù)」
- callback?下一次重繪之前更新動(dòng)畫(huà)幀所調(diào)用的函數(shù) (即上面所說(shuō)的回調(diào)函數(shù))。該回調(diào)函數(shù)會(huì)被傳入DOMHighResTimeStamp參數(shù),該參數(shù)與performance.now()的返回值相同,它表示requestAnimationFrame() 開(kāi)始去執(zhí)行回調(diào)函數(shù)的時(shí)刻。
「返回值」
一個(gè) long 整數(shù),請(qǐng)求 ID,是回調(diào)列表中唯一的標(biāo)識(shí)。是個(gè)非零值,沒(méi)別的意義。你可以傳這個(gè)值給 window.cancelAnimationFrame() 以取消回調(diào)函數(shù)。
測(cè)試版
通過(guò) requestAnimationFrame API可以知道,回調(diào)函數(shù)中的參數(shù)就是一個(gè) DOMHighResTimeStamp參數(shù),該參數(shù)與performance.now()的返回值相同,它表示requestAnimationFrame() 開(kāi)始去執(zhí)行回調(diào)函數(shù)的時(shí)刻。
那我們直接使用該值不就可以了嗎?試試看:
<!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">這里顯示倒計(jì)時(shí)狀態(tài)</div>
<button class="start">開(kāi)始</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>
效果如下:

測(cè)試版
雖然比較簡(jiǎn)陋,但是并沒(méi)有出現(xiàn) setInterval版 的bug,接下來(lái)在一步步優(yōu)化。
簡(jiǎn)易版
我們加上格式化時(shí)間的函數(shù) 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">這里顯示倒計(jì)時(shí)狀態(tài)</div>
<button class="start">開(kāi)始</button>
<br />
<script>
/**
* @description: 補(bǔ)0操作
* @param {*} num
* @param {*} targetLength
* @return {*}
*/
function padZero (num, targetLength = 2) {
let str = num + ''
while (str.length < targetLength) {
str = '0' + str
}
return str
}
/**
* @description: 解析時(shí)間
* @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: 格式化時(shí)間
* @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: 渲染時(shí)間
* @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>
效果如下:

簡(jiǎn)易版
又看到了我們熟悉的時(shí)間格式啦, 格式化的方法也是來(lái)源于vant的CutDown組件中的格式化代碼!
格式化雖然是完成了,但是怎么去停止呢?能不能支持暫停、繼續(xù)、重置呢?
接下來(lái)繼續(xù)完善。
進(jìn)階版
我們直接通過(guò) window.cancelAnimationFrame() 去取消回調(diào)函數(shù)即可!在 useCountUp函數(shù)中添加一下 pause 即可!
const pause = () {
if (rafId) {
window.cancelAnimationFrame(rafId)
}
}效果如下:

進(jìn)階版
不少的小伙伴已經(jīng)發(fā)現(xiàn),停止雖然是沒(méi)問(wèn)題了,當(dāng)再次點(diǎn)擊開(kāi)始的時(shí)候,時(shí)間怎么不對(duì)了?有瑕疵!
因?yàn)槲覀兩偎阊a(bǔ)時(shí)時(shí)間,做如下修改,添加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
}
}這里基本上已經(jīng)完成了暫停和繼續(xù)的功能了,但是仍是有些bug的,可以多次點(diǎn)擊繼續(xù)試試?? 。
完整版
接下來(lái),我們來(lái)修復(fù)上述的bug,方法:添加一個(gè)變量來(lái)表示當(dāng)前計(jì)時(shí)器的狀態(tài)。
在增加幾個(gè)新功能:
- 添加 重置 方法: 其實(shí)我們只需要調(diào)用一下暫停,在清理一下時(shí)間即可
- 支持 配置:比如 正香計(jì)時(shí)的時(shí)間, 計(jì)時(shí)結(jié)束的函數(shù)
核心代碼如下,其他部分代碼不變:
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 = () {
// 計(jì)時(shí)中 或者 已經(jīng)開(kāi)始過(guò)計(jì)時(shí)想要重新開(kāi)始計(jì)時(shí),應(yīng)該先點(diǎn)擊一下 重置 再開(kāi)始計(jì)時(shí)
if (counting || curentTime) {
return
}
counting = true
startTime = performance.now()
rafId = window.requestAnimationFrame(step)
}
const pause = () {
// 已經(jīng)暫停后,屏蔽掉點(diǎn)擊
if (!counting) {
return
}
counting = false
stopTime = performance.now()
if (rafId) {
window.cancelAnimationFrame(rafId)
}
}
const goOn = () {
// 已經(jīng)在計(jì)時(shí)中,屏蔽掉點(diǎn)擊
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()
})到此基本上就是實(shí)現(xiàn)了一個(gè)毫秒級(jí)的正向計(jì)時(shí)器!
vue版
只是對(duì)js的邏輯進(jìn)行了一些封裝
代碼:https://github.com/SuYxh/timer-demo
預(yù)覽:https://suyxh.github.io/timer-demo/
總結(jié)
正向毫秒級(jí)計(jì)時(shí)器主要就是利用了window.requestAnimationFrame的回調(diào)函數(shù)的參數(shù)為DOMHighResTimeStamp,且與performance.now()的返回值相同;在實(shí)現(xiàn)暫停、繼續(xù)時(shí),需要計(jì)算一下補(bǔ)時(shí)時(shí)間。