前言
欢迎关注同名公众号《
熊的猫
》,文章会同步更新,也可快速加入前端交流群!
最近有个同学在面试时被要求手写一个 CountDown 计时器组件,但可能是因为之前没有了解过,所以思路上没有那么顺畅,过后他询问我应该怎么写(),于是就有了本篇文章,希望本篇文章对你有所帮助!!!哈哈,我也没写过
不难看出,要求手写一个 CountDown 计时器组件 目的无非考察如下几个方面():谁也不知道面试官在想什么
- 组件封装能力
- 组件输入,即对应组件 内部 Props 的 设计 和 考量
- 组件输出,即对应组件 对外提供 的 属性 或 方法
- 逻辑复用,即指组件内部逻辑的 可组合性
- 时间相关的敏感度
- 倒计时的实现方式有多种,例如
setInterval、setTimeout、requestAnimationFrame
等等,那么哪种更合适? - 获取当前时间可以用
Date.now()、performance.now()
,那么该怎么选?
- 倒计时的实现方式有多种,例如
setInterval & setTimeout & requestAnimationFrame
倒计时功能必然需要一个不断执行的 异步过程(),这可以使用运行时环境提供的 API,即 setInterval、setTimeout、requestAnimationFrame,那么到底该选择谁更合适呢?没疑问吧
下面进行逐个分析!
setInterval
是什么?
setInterval()
方法会重复调用 一个函数 或 执行一个代码片段,在每次调用之间具有固定的时间间隔,并会返回一个 interval ID
用于标识唯一的时间间隔,可通过调用 clearInterval()
来移除定时器。
共享同一个 ID 池
值得注意的是,setInterval()
和 setTimeout()
是 共享同一个 ID 池 的,所以说 clearInterval()
和 clearTimeout()
在技术上是可 互换使用 的:
<template>
<div class="count-down">
<h1>Count through setInterval:{{ countInterval }}</h1>
<button @click="stopInterval">Stopping through clearTimeout</button>
<hr>
<h1>Count through setTimeout:{{ countTimeout }}</h1>
<button @click="stopTimeout">Stopping through clearInterval</button>
</div>
</template>
<script setup lang='ts'>
import { ref } from 'vue'
// 1. Example for setInterval
const countInterval = ref(0)
const IntervalID = setInterval(() => countInterval.value++, 1000)
const stopInterval = () => {
console.log('ClearTimeout triggered in stopInterval method!')
clearTimeout(IntervalID)
}
// 2. Example for setTimeout
const countTimeout = ref(0)
let TimeoutID = 0
const addCount = () => {
TimeoutID = setTimeout(() => {
countTimeout.value++
addCount()
}, 1000)
}
addCount()
const stopTimeout = () => {
console.log('ClearInterval triggered in stopTimeout method!')
clearInterval(TimeoutID)
}
</script>
但为了 避免代码杂乱无章、保证代码的可维护性,还是更推荐使用相互匹配的 clearInterval()
和 clearTimeout()
。
延迟限制
当 setInterval()
定时器是产生 嵌套使用 时,且 嵌套超过 5
层深度 时:
- 浏览器将 自动强制 设置定时器的
最小时间间隔为 4 毫秒
- 若尝试将 深层嵌套 中调用
setInterval()
的延迟设定为小于 4 毫秒
的值,其将 被固定为4
毫秒
浏览器这样的行为会使得 setInterval()
产生延迟性,原因是 为了减轻嵌套定时器对性能产生的潜在影响。
setTimeout + 递归 更合适?
如果 代码逻辑执行时间 可能大于 定时器时间间隔,那么建议你使用 递归调用setTimeout()
的方式来实现。
(function loop(){
setTimeout(function() {
// Your logic here
loop();
}, delay);
})();
例如,如果你要使用 setInterval()
以 5s
轮询服务器,可能因 网络延迟、服务器无响应 或许多其他的问题而导致请求 无法在指定时间内完成,因此可能会出现排队的 XHR 请求 没有按顺序返回 的问题。
setTimeout
是什么?
setTimeout()
方法用于设置一个定时器,该定时器在 定时器到期后 执行 一个函数 或 指定的一段代码,并且会返回一个 正整数 的 timeoutID
,表示由 setTimeout()
调用创建的定时器的编号,可通过调用 clearTimeout()
来取消定时器。
最大延时值
浏览器内部以 32 位带符号整数 存储延时,这会导致如果一个延时大于 2147483647 ms(大约 24.8 天)
时会产生溢出,导致定时器将会被 立即执行,这个限制适用于 setInterval()
和 setTimeout()
。
延时比指定值更长的原因
有很多因素会导致 setTimeout
的 回调函数 执行 比设定的预期值更久:
- 嵌套超时
- 一旦对
setTimeout
的 嵌套调用达到5
层,浏览器将强制执行4 毫秒的最小超时
- 一旦对
- 非活动标签的超时
- 为了优化 后台标签的加载损耗(如 降低耗电量),浏览器会在非活动标签中强制执行一个 最小的超时延迟
- 例如,
Firefox
桌面版 和Chrome
不活动标签都有一个1s
的最小超时值 - 例如,安卓版
Firefox
浏览器对不活动的标签有一个至少15m
的超时,并可能完全卸载它们 - 例如,若标签中包含
AudioContext
,Firefox
不会对非活动标签进行节流
- 追踪型脚本的节流
- 例如,
Firefox
对它识别为追踪型脚本的脚本 实施额外节流,即当在 前台运行 时,节流的最小延迟是4ms
- 当在 后台标签 中,节流的最小延迟是
10000ms(即 10s)
,在文档首次加载后30s
开始生效
- 例如,
- 在加载页面时推迟超时
- 当前标签页正在加载时,
Firefox
将推迟触发setTimeout()
计时器,直到主线程被认为是空闲 的(类似于window.requestIdleCallback()
)或 直到 加载事件触发完毕,才开始触发
- 当前标签页正在加载时,
requestAnimationFrame
是什么?
window.requestAnimationFrame()
会告诉浏览器我们希望执行一个 动画,并且要求浏览器在下次 重绘之前 调用指定的回调函数 更新动画,即每 16.67ms
执行一次回调函数。
回调函数的参数
回调方法在会接收到一个 DOMHighResTimeStamp
参数,它是一个 十进制数,单位为毫秒,最小精度为 1ms(1000μs)
。
同一帧 中的 多个回调函数 都会接受到一个 相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间,因此要确保总是使用 第一个参数(或其他一些获取当前时间的方法) 来计算动画在一帧中的进度,否则动画在 高刷新率 的屏幕中会 运行得更快。
暂停调用
为了提高性能和电池寿命,在大多数浏览器里,当 requestAnimationFrame()
运行在 后台标签页 或 隐藏的 <iframe>
里时,requestAnimationFrame()
会被暂停调用以提升性能和电池寿命。
到底选谁?
从以上内容来看,似乎没有一个完美的方案呀,这不是更加大难度了?
莫慌!既然都不完美,那么也要从矮个子中挑个高个子。
先不考虑别的,setInterval 和 setTimeout 有一个致命的缺点:
- 最大延时限制
- 延时时间一旦大于
2147483647 ms(约 24.8 天)
时会产生溢出,导致定时器将会被 立即执行
- 延时时间一旦大于
别的不说,就这个缺点就导致使用它们来做倒计时组件不太现实,难不成不允许用户的倒计时超过 25 天 吗?
所以这里选择 requestAnimationFrame() + 递归 来实现!
Date.now() & performance.now()
同样的道理,获取当前日期的时间戳也有 Date.now() 和 performance.now() 两种方式,又该选谁呢?
Date.now()
是什么?
Date.now()
方法返回自 1970 年 1 月 1 日 00:00:00 (UTC)
到当前时间的毫秒数。
时间精度被降低
为了提供针对定时攻击和指纹追踪的保护,Date.now()
的精度可能会根据浏览器的高级设置项目而被取整。
例如,在 Firefox
中,默认启用 privacy.reduceTimerPrecision
设置项,在 Firefox 59
中,默认被取整至 20 微秒
;在 Firefox 60
中,则被取整至 2 毫秒
。
performance.now()
是什么?
performance.now()
方法返回一个 double 类型 的、用于存储 毫秒级 的时间值。
获取当前日期时间戳
performance.now()
主要是用来描述 离散时间点 或 一段时间(两个离散时间点间的时间差),因此它的返回值并不是当 前日期的时间戳,即 performance.now()
!= Date.now()
。
但可以通过换算的方式得到,即
Date.now() ≈ performance.timing.navigationStart + performance.now()
// 示例
const t1 = performance.timing.navigationStart + performance.now()
const t2 = Date.now();
console.log(t2, t1);
// t2 = 1686534658865 t1 = 1686534658865.2
时间精度降低
为了提供对定时攻击和指纹的保护,performance.now()
的精度可能会根据浏览器的设置而被舍弃,在 Firefox
中,privacy.reduceTimerPrecision
偏好是默认启用的,默认值为 1ms
。
// 降低时间精度 (1ms) 在 Firefox 60
performance.now();
// 8781416
// 8781815
// 8782206
// ...
// 降低时间精度 当 `privacy.resistFingerprinting` 启用
performance.now();
// 8865400
// 8866200
// 8866700
// ...
到底该选谁?
好家伙,说白了就还是没有一个完美的选择呗!
在这里选 Date.now()
,毕竟 performance.now()
还得做转换,还有一个原因是 vant-count-down 组件也是用的 Date.now()
()。借鉴借鉴
实现 CountDown 计时器组件
组件输入 — Props
针对一个 CountDown 计时器组件 的 props 应该要包含如下几个内容:
time
,即需要倒计时的时间format
,即输出的时间格式,支持 DD:HH:mm:ss:SSS 格式finish
事件,即倒计时结束时会被执行的事件slot
默认插槽,即需要展示的组件内容视图,可接收到内部的倒计时格式输出
其中时间我们可以直接限制为 时间戳,数值类型,当然如果你想支持更多格式,可以自己在写一个方法处理允许外部传入的各种格式,但实际在组件内部使用时必定是保持是同一种类型,因此在这里我们直接限定类型,让外部去进行转换。
组件输出
由于是一个基本的 CountDown 计时器组件,我们可以不考虑那么多输出,但至少要向外部暴露如下两个内容:
-
start()
方法,便于使用时可以基于任意时间开始进行倒计时 -
格式化的倒计时,便于外部直接用于展示处理,或自定义展示,返回格式如下
{ format, // 对应格式化的结果 days, // 天数 hours, // 小时 minutes, //分钟 seconds, // 秒数 milliseconds, // 毫秒 }
具体实现
基本思路
- 根据 传入时间
time
派生出 剩余时间remain
,并计算出对应的 结束时间endTime
- 通过 requestAnimationFrame + 递归 的方式更新
remain
值,即remain = endTime - Date.now()
- 根据最新的
remain
值,通过parseTime()
和formatTime()
方法进行转换返回对应的结果parseTime()
负责将remain
值转换成 天数/小时/分钟/秒/毫秒 等值formatTime()
负责将输出结果格式化,例如 不足位补 0 等
效果展示
具体代码
src\components\CountDown\index.vue
<template>
<div class="count-down">
<slot v-bind="currentTime">
<h1>{{ currentTime.format }}</h1>
</slot>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import useCountDown from './Composable/useCountDown'
const props = defineProps({
time: {
type: Number,
default: 0,
},
format: {
type: String,
default: 'DD:HH:mm:ss:SSS',
},
immediate: {
type: Boolean,
default: true,
},
})
const emits = defineEmits(['finish'])
const { start, currentTime } = useCountDown({
...props,
onFinish: () => emits('finish'),
})
// 判断是否需要立即执行
onMounted(() => {
if (props.immediate) start()
})
// 向外部暴露的内容
defineExpose({
start,
currentTime,
})
</script>
src\components\CountDown\composable\useCountDown\index.ts
import { computed, ref } from 'vue'
import { parseTime, formatTime } from '../../utils'
export default (options) => {
// 是否正在倒计时
let counting = false
// 剩余时间
const remain = ref(options.time)
// 结束时间
const endTime = ref(0)
// 格式化输出的日期时间
const currentTime = computed(() => formatTime(options.format, parseTime(remain.value)))
// 获取当前剩余时间
const getCurrentRemain = () => Math.max(endTime.value - Date.now(), 0)
// 设置剩余时间
const setRemain = (value) => {
// 更新剩余时间
remain.value = value
// 倒计时结束
if (value === 0) {
// 触发 Finish 事件
options.onFinish?.()
// 正在倒计时标志为 false
counting = false
}
}
// 倒计时
const tickTime = () => {
requestAnimationFrame(() => {
// 更新剩余时间
setRemain(getCurrentRemain())
// 倒计时没结束,就继续
if (remain.value > 0) {
tickTime()
}
})
}
// 启动
const start = () => {
// 正在倒计时,忽略多次调用 start
if (counting) return
// 正在倒计时标志为 true
counting = true
// 设置结束时间
endTime.value = Date.now() + remain.value
// 开启倒计时
tickTime()
}
return {
currentTime,
start
}
}
src\components\CountDown\utils\index.ts
// 常量
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
// 解析时间
export 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,
}
}
// 格式化时间
export const formatTime = (format, time) => {
let { days, hours, minutes, seconds, milliseconds } = time
// 判断是否需要展示 天数,需要则补 0,否则将 天数 降级加到 小时 部分
if (format.includes('DD')) {
format = format.replace('DD', padZero(days))
} else {
hours += days * 24
}
// 判断是否需要展示 小时,需要则补 0,否则将 小时 降级加到 分钟 部分
if (format.includes('HH')) {
format = format.replace('HH', padZero(hours))
} else {
minutes += hours * 60
}
// 判断是否需要展示 分钟,需要则补 0,否则将 分钟 降级加到 秒数 部分
if (format.includes('mm')) {
format = format.replace('mm', padZero(minutes))
} else {
seconds += minutes * 60
}
// 判断是否需要展示 秒数,需要则补 0,否则将 秒数 降级加到 毫秒 部分
if (format.includes('ss')) {
format = format.replace('ss', padZero(seconds))
} else {
milliseconds += seconds * 1000
}
// 默认展示 3位 毫秒数
if (format.includes('SSS')) {
const ms = padZero(milliseconds, 3)
format = format.replace('SSS', ms)
}
// 最终返回格式化的数据
return { format, days, hours, minutes, seconds, milliseconds }
}
// 不足位数用 0 填充
export const padZero = (str, padLength = 2) => {
str += ''
if (str.length < padLength) {
str = '0'.repeat(padLength - str.length) + str
}
return str
}
最后
欢迎关注同名公众号《
熊的猫
》,文章会同步更新,也可快速加入前端交流群!
以上就是一个基本的计时器的实现了,其中肯定有不足之处,不过大家只需要抓住核心思想即可,很多内容都借鉴了 vant-count-down 组件 的实现,感兴趣可以直接去看其对应的源码。
希望本文对你有所帮助!!!