本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
【若川视野 x 源码共读】第46期 | 分析 vant4 源码,学会用 vue3 + ts 开发毫秒级渲染的倒计时组件。点击查看详情
倒计时组件我们在开发中也会经常用到,以vant
为例,他是怎么实现毫秒级渲染的呢
组件功能分析:
源码分析
首先,我们来到仓库查看对应的组件
github.com/youzan/vant...
整个源码很简单,利用了浏览器的requestAnimationFrame
来实现毫秒级渲染
developer.mozilla.org/en-US/docs/...
以显示器60hz为例,这个api能够实现16.67ms渲染一次,如果将倒计时的函数放进去,即可实现毫秒级渲染
tsx
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
import { raf, cancelRaf, inBrowser } from '../utils'
// 当前类型定义
export type CurrentTime = {
days: number
hours: number
total: number
minutes: number
seconds: number
milliseconds: number
}
export type UseCountDownOptions = {
time: number
millisecond?: boolean
// 改变触发
onChange?: (current: CurrentTime) => void
// 完成后回调
onFinish?: () => void
}
// 初始配置
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
// 格式化
function parseTime(time: number): CurrentTime {
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
}
}
function isSameSecond(time1: number, time2: number): boolean {
return Math.floor(time1 / 1000) === Math.floor(time2 / 1000)
}
export function useCountDown(options: UseCountDownOptions) {
let rafId: number
let endTime: number
let counting: boolean
let deactivated: boolean
const remain = ref(options.time)
const current = computed(() => parseTime(remain.value))
// 暂停
const pause = () => {
counting = false
cancelRaf(rafId)
}
// 获取当前剩余时间
const getCurrentRemain = () => Math.max(endTime - Date.now(), 0)
// 设置剩余时间
const setRemain = (value: number) => {
remain.value = value
options.onChange?.(current.value)
if (value === 0) {
pause()
options.onFinish?.()
}
}
const microTick = () => {
// 判断是否是浏览器环境,如果是浏览器环境,使用 requestAnimationFrame
// export function raf(fn: FrameRequestCallback): number {
// return inBrowser ? requestAnimationFrame(fn) : -1;
// }
rafId = raf(() => {
// in case of call reset immediately after finish
if (counting) {
setRemain(getCurrentRemain())
if (remain.value > 0) {
microTick()
}
}
})
}
const macroTick = () => {
rafId = raf(() => {
// in case of call reset immediately after finish
if (counting) {
const remainRemain = getCurrentRemain()
if (!isSameSecond(remainRemain, remain.value) || remainRemain === 0) {
setRemain(remainRemain)
}
if (remain.value > 0) {
macroTick()
}
}
})
}
const tick = () => {
// should not start counting in server
// see: https://github.com/vant-ui/vant/issues/7807
// 如果不是浏览器环境,直接返回
if (!inBrowser) {
return
}
// 是否启用毫秒级别的渲染
if (options.millisecond) {
microTick()
} else {
macroTick()
}
}
const start = () => {
if (!counting) {
endTime = Date.now() + remain.value
counting = true
tick()
}
}
const reset = (totalTime: number = options.time) => {
pause()
remain.value = totalTime
}
onBeforeUnmount(pause)
// https://vuejs.org/guide/built-ins/keep-alive.html#lifecycle-of-cached-instance
// 在 activated 钩子函数中重新开始定时器,避免缓存的定时器无法启动的问题
onActivated(() => {
if (deactivated) {
counting = true
deactivated = false
tick()
}
})
onDeactivated(() => {
if (counting) {
pause()
deactivated = true
}
})
return {
start,
pause,
reset,
current
}
}
简易实现
既然知道了他的原理,我们可以来简易实现一下
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<span id="countDown"></span>
<div>
<button id="start">start</button>
<button id="stop">stop</button>
<button id="reset">reset</button>
</div>
<script>
const TIMER = (initialMilliseconds) => {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const parseTime = (time) => {
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 { days, hours, minutes, seconds, milliseconds };
}
const updateCountDown = (time, countDownElement) => {
const { days, hours, minutes, seconds, milliseconds } = parseTime(time)
let timeStr = `剩余时间:${days}天 ${hours}小时 ${minutes}分钟 ${seconds}秒 ${milliseconds}毫秒`
countDownElement.innerHTML = timeStr;
}
let handler;
const start = (countDownElement) => {
let endTime = new Date().getTime() + initialMilliseconds;
const step = () => {
const now = new Date().getTime();
const msTillEnd = endTime - now;
if (msTillEnd > 0) {
updateCountDown(msTillEnd, countDownElement);
handler = window.requestAnimationFrame(step);
} else {
updateCountDown(0, countDownElement);
}
};
handler = window.requestAnimationFrame(step);
}
const stop = () => {
window.cancelAnimationFrame(handler);
}
return { start, stop, updateCountDown, initialMilliseconds };
};
const INITIAL_MILLISECONDS = 3000
const { start, stop, updateCountDown } = TIMER(INITIAL_MILLISECONDS);
const countDownElement = document.getElementById('countDown');
const startButtonElement = document.getElementById('start');
const stopButtonElement = document.getElementById('stop');
const resetButtonElement = document.getElementById('reset');
updateCountDown(INITIAL_MILLISECONDS, countDownElement)
startButtonElement.addEventListener('click', () => {
start(countDownElement);
});
stopButtonElement.addEventListener('click', () => {
stop();
});
resetButtonElement.addEventListener('click', () => {
updateCountDown(INITIAL_MILLISECONDS, countDownElement)
});
</script>
</body>
</html>
至此,我们就完成了毫米级倒计时组件的实现。
通过阅读源码,我们可以了解到组件使用requestAnimationFrame实现毫秒级渲染,并在onActive函数中注册事件以避免缓存的定时器无法启动的问题。同时还会判断是否处于浏览器环境,以避免服务端渲染时出错的问题。