前言
欢迎关注同名公众号《熊的猫》,文章会同步更新,也可快速加入前端交流群!最近有个同学在面试时被要求手写一个 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></code></pre><p></p><p>但为了 <strong>避免代码杂乱无章</strong>、<strong>保证代码的可维护性</strong>,还是更推荐使用相互匹配的 <strong><code>clearInterval()</code></strong> 和 <strong><code>clearTimeout()</code></strong>。</p><h4>延迟限制</h4><p>当 <strong><code>setInterval()</code></strong> 定时器是产生 <strong>嵌套使用</strong> 时,且 <strong>嵌套超过 <code>5</code> 层深度</strong> 时:</p><ul><li>浏览器将 <strong>自动强制</strong> 设置定时器的 <strong><code>最小时间间隔为 4 毫秒</code></strong></li><li>若尝试将 <strong>深层嵌套</strong> 中调用 <strong><code>setInterval()</code></strong> 的延迟设定为 <strong><code>小于 4 毫秒</code></strong> 的值,其将 <strong>被固定为 <code>4</code> 毫秒</strong></li></ul><p>浏览器这样的行为会使得 <strong><code>setInterval()</code></strong> 产生延迟性,原因是 <strong>为了减轻嵌套定时器对性能产生的潜在影响</strong>。</p><h4>setTimeout + 递归 更合适?</h4><p>如果 <strong>代码逻辑执行时间</strong> 可能大于 <strong>定时器时间间隔</strong>,那么建议你使用 <strong>递归调用<code>setTimeout()</code></strong> 的方式来实现。</p><pre><code class="js">(function loop(){
setTimeout(function() {
// Your logic here
loop();
}, delay);
})();</code></pre><p>例如,如果你要使用 <strong><code>setInterval()</code></strong> 以 <strong><code>5s</code></strong> 轮询服务器,可能因 <strong>网络延迟、服务器无响应</strong> 或许多其他的问题而导致请求 <strong>无法在指定时间内完成</strong>,因此可能会出现排队的 <strong>XHR</strong> 请求 <strong>没有按顺序返回</strong> 的问题。</p><h3 id="item-0-4">setTimeout</h3><h4>是什么?</h4><p><strong><code>setTimeout()</code></strong> 方法用于设置一个定时器,该定时器在 <strong>定时器到期后</strong> 执行 <strong>一个函数</strong> 或 <strong>指定的一段代码</strong>,并且会返回一个 <strong>正整数</strong> 的 <strong><code>timeoutID</code></strong>,表示由 <strong><code>setTimeout()</code></strong> 调用创建的定时器的编号,可通过调用 <a href="https://link.segmentfault.com/?enc=sV53Yu8y%2Fvug99mR4X48DA%3D%3D.hbRDjF5IkboYF3ttrolG448H%2B0RLFepgmf%2F0y6lmP4SyRMzEaTOIT82PXUgSCz0XmFaFnUi1zR0Ja4WWwr2Z9w%3D%3D" rel="nofollow" target="_blank"><strong><code>clearTimeout()</code></strong></a> 来取消定时器。</p><h4>最大延时值</h4><p>浏览器内部以 <strong>32 位带符号整数</strong> 存储延时,这会导致如果一个延时大于 <strong><code>2147483647 ms(大约 24.8 天)</code></strong> 时会产生溢出,导致定时器将会被 <strong>立即执行</strong>,这个限制适用于 <strong><code>setInterval()</code></strong> 和 <strong><code>setTimeout()</code></strong>。</p><h4>延时比指定值更长的原因</h4><p>有很多因素会导致 <strong><code>setTimeout</code></strong> 的 <strong>回调函数</strong> 执行 <strong>比设定的预期值更久</strong>:</p><ul><li><p><strong>嵌套超时</strong></p><ul><li>一旦对 <strong><code>setTimeout</code></strong> 的 <strong>嵌套调用达到 <code>5</code> 层</strong>,浏览器将强制执行 <strong><code>4 毫秒的最小超时</code></strong></li></ul></li><li><p><strong>非活动标签的超时</strong></p><ul><li>为了优化 <strong>后台标签的加载损耗(如 降低耗电量)</strong>,浏览器会在非活动标签中强制执行一个 <strong>最小的超时延迟</strong></li><li>例如,<strong><code>Firefox</code></strong> 桌面版 和 <strong><code>Chrome</code></strong> 不活动标签都有一个 <strong><code>1s</code></strong> 的最小超时值</li><li>例如,安卓版 <strong><code>Firefox</code></strong> 浏览器对不活动的标签有一个至少 <strong><code>15m</code></strong> 的超时,并可能完全卸载它们</li><li>例如,若标签中包含 <a href="https://link.segmentfault.com/?enc=lEHj1xAJPUF9k5WGhZI%2BEw%3D%3D.%2BdpbYGXLsnlTaPbh7kCZBqqC2AdFtNptkQMukhZkC6GJKW4%2B454NM8qZtUt%2BqYFVu85M3thXPvspNealAkVlbQ%3D%3D" rel="nofollow" target="_blank"><strong><code>AudioContext</code></strong></a>,<strong><code>Firefox</code></strong> 不会对非活动标签进行节流</li></ul></li><li><p><strong>追踪型脚本的节流</strong></p><ul><li>例如,<strong><code>Firefox</code></strong> 对它识别为追踪型脚本的脚本 <strong>实施额外节流</strong>,即当在 <strong>前台运行</strong> 时,<strong>节流的最小延迟是 <code>4ms</code></strong></li><li>当在 <strong>后台标签</strong> 中,<strong>节流的最小延迟是 <code>10000ms(即 10s)</code></strong>,在文档首次加载后 <strong><code>30s</code></strong> 开始生效</li></ul></li><li><p><strong>在加载页面时推迟超时</strong></p><ul><li>当前标签页正在加载时,<strong><code>Firefox</code></strong> 将推迟触发 <strong><code>setTimeout()</code></strong> 计时器,<strong>直到主线程被认为是空闲</strong> 的(类似于 <a href="https://link.segmentfault.com/?enc=K04rv6UHDEGhJaxtFCB4dQ%3D%3D.T4qMrmQ8opInMOzgulItHyLynCO4z59d%2Byr5%2BNaHxw3ojGSDw7ky1ODVafm5kuuibR9Vyk4j1ULj74xDpNaoLs0F9RkTrkXOZKA8WCHNm1k%3D" rel="nofollow" target="_blank"><strong><code>window.requestIdleCallback()</code></strong></a>)或 直到 <strong>加载事件触发完毕</strong>,才开始触发</li></ul></li></ul><h3 id="item-0-5">requestAnimationFrame</h3><h4>是什么?</h4><p><strong><code>window.requestAnimationFrame()</code></strong> 会告诉浏览器我们希望执行一个 <strong>动画</strong>,并且要求浏览器在下次 <strong>重绘之前</strong> 调用指定的回调函数 <strong>更新动画</strong>,即每 <strong><code>16.67ms</code></strong> 执行一次回调函数。</p><h4>回调函数的参数</h4><p>回调方法在会接收到一个 <a href="https://link.segmentfault.com/?enc=YaNAs9JntG4QiYbCfd6G%2Bg%3D%3D.17tVVurpocIbdc3fYlQM7YgT%2Fms%2F8qdlN1Kuny4WbPcTqqsYx1eqW5m%2FHDhygVTWU0ds2uCcAAGnYqRElWooOoN3wxamBCY0Z%2BLwH9iCbq0%3D" rel="nofollow" target="_blank"><strong><code>DOMHighResTimeStamp</code></strong></a> 参数,它是一个 <strong>十进制数</strong>,单位为毫秒,最小精度为 <strong><code>1ms(1000μs)</code></strong>。</p><p><strong>同一帧</strong> 中的 <strong>多个回调函数</strong> 都会接受到一个 <strong>相同的时间戳</strong>,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间,因此要确保总是使用 <strong>第一个参数(或其他一些获取当前时间的方法)</strong> 来计算动画在一帧中的进度,否则动画在 <strong>高刷新率</strong> 的屏幕中会 <strong>运行得更快</strong>。</p><h4>暂停调用</h4><p>为了提高性能和电池寿命,在大多数浏览器里,当 <strong><code>requestAnimationFrame()</code></strong> 运行在 <strong>后台标签页</strong> 或 <strong>隐藏的 <code><iframe></code></strong> 里时,<strong><code>requestAnimationFrame()</code></strong> 会被暂停调用以提升性能和电池寿命。</p><h3 id="item-0-6">到底选谁?</h3><p>从以上内容来看,似乎没有一个完美的方案呀,这不是更加大难度了?</p><p></p><p>莫慌!既然都不完美,那么也要从矮个子中挑个高个子。</p><p>先不考虑别的,<strong>setInterval</strong> 和 <strong>setTimeout</strong> 有一个致命的缺点:</p><ul><li><p><strong>最大延时限制</strong></p><ul><li>延时时间一旦大于 <strong><code>2147483647 ms(约 24.8 天)</code></strong> 时会产生溢出,导致定时器将会被 <strong>立即执行</strong></li></ul></li></ul><p>别的不说,就这个缺点就导致使用它们来做倒计时组件不太现实,难不成不允许用户的倒计时超过 <strong>25 天</strong> 吗?</p><p></p><p>所以这里选择 <strong>requestAnimationFrame() + 递归</strong> 来实现!</p><h2 id="item-0-7">Date.now() & performance.now()</h2><p>同样的道理,获取当前日期的时间戳也有 <strong>Date.now()</strong> 和 <strong>performance.now()</strong> 两种方式,又该选谁呢?</p><p></p><h3 id="item-0-8">Date.now()</h3><h4>是什么?</h4><p><strong><code>Date.now()</code></strong> 方法返回自 <strong><code>1970 年 1 月 1 日 00:00:00 (UTC)</code></strong> 到当前时间的毫秒数。</p><h4>时间精度被降低</h4><p>为了提供针对定时攻击和指纹追踪的保护,<strong><code>Date.now()</code></strong> 的精度可能会根据浏览器的高级设置项目而被取整。</p><p>例如,在 <strong><code>Firefox</code></strong> 中,默认启用 <strong><code>privacy.reduceTimerPrecision</code></strong> 设置项,在 <strong><code>Firefox 59</code></strong> 中,默认被取整至 <strong><code>20 微秒</code></strong>;在 <strong><code>Firefox 60</code></strong> 中,则被取整至 <strong><code>2 毫秒</code></strong>。</p><h3 id="item-0-9">performance.now()</h3><h4>是什么?</h4><p><strong><code>performance.now()</code></strong> 方法返回一个 <strong>double 类型</strong> 的、用于存储 <strong>毫秒级</strong> 的时间值。</p><h4>获取当前日期时间戳</h4><p><strong><code>performance.now()</code></strong> 主要是用来描述 <strong>离散时间点</strong> 或 <strong>一段时间</strong>(两个离散时间点间的时间差),因此它的返回值并不是当 <strong>前日期的时间戳</strong>,即 <strong><code>performance.now()</code> != <code>Date.now()</code></strong>。</p><p>但可以通过换算的方式得到,即</p><pre><code class="js">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</code></pre><h4>时间精度降低</h4><p>为了提供对定时攻击和指纹的保护,<strong><code>performance.now()</code></strong> 的精度可能会根据浏览器的设置而被舍弃,在 <strong><code>Firefox</code></strong> 中,<strong><code>privacy.reduceTimerPrecision</code></strong> 偏好是默认启用的,默认值为 <strong><code>1ms</code></strong>。</p><pre><code class="js">// 降低时间精度 (1ms) 在 Firefox 60
performance.now();
// 8781416
// 8781815
// 8782206
// ...
// 降低时间精度 当 `privacy.resistFingerprinting` 启用
performance.now();
// 8865400
// 8866200
// 8866700
// ...</code></pre><h4>到底该选谁?</h4><p>好家伙,说白了就还是没有一个完美的选择呗!</p><p></p><p>在这里选 <strong><code>Date.now()</code></strong>,毕竟 <strong><code>performance.now()</code></strong> 还得做转换,还有一个原因是 <a href="https://link.segmentfault.com/?enc=k%2FgDgIEKvDzJBxMzFEu8Jg%3D%3D.VHuerA5DhugfdPforP9o7JAcOdq5CFfL6B9cbFrrquUxV0kGj1dBcsmRAWn2c1CC7Un0g7WAe4VzP%2Ftp9kroWg%3D%3D" rel="nofollow" target="_blank"><strong>vant-count-down</strong></a> 组件也是用的 <strong><code>Date.now()</code></strong>(<del><strong><code>借鉴借鉴</code></strong></del>)。</p><h2 id="item-0-10">实现 CountDown 计时器组件</h2><h3 id="item-0-11">组件输入 — Props</h3><p>针对一个 <strong>CountDown 计时器组件</strong> 的 <strong>props</strong> 应该要包含如下几个内容:</p><ul><li><strong><code>time</code></strong>,即需要倒计时的时间</li><li><strong><code>format</code></strong>,即输出的时间格式,支持 <strong>DD:HH:mm:ss:SSS</strong> 格式</li><li><strong><code>finish</code> 事件</strong>,即倒计时结束时会被执行的事件</li><li><strong><code>slot</code> 默认插槽</strong>,即需要展示的组件内容视图,可接收到内部的倒计时格式输出</li></ul><p>其中时间我们可以直接限制为 <strong>时间戳</strong>,数值类型,当然如果你想支持更多格式,可以自己在写一个方法处理允许外部传入的各种格式,但实际在组件内部使用时必定是保持是同一种类型,因此在这里我们直接限定类型,让外部去进行转换。</p><h3 id="item-0-12">组件输出</h3><p>由于是一个基本的 <strong>CountDown 计时器组件</strong>,我们可以不考虑那么多输出,但至少要向外部暴露如下两个内容:</p><ul><li><strong><code>start()</code></strong> 方法,便于使用时可以基于任意时间开始进行倒计时</li><li><p><strong>格式化的倒计时</strong>,便于外部直接用于展示处理,或自定义展示,返回格式如下</p><pre><code class="js"> {
format, // 对应格式化的结果
days, // 天数
hours, // 小时
minutes, //分钟
seconds, // 秒数
milliseconds, // 毫秒
}</code></pre></li></ul><h3 id="item-0-13">具体实现</h3><h4>基本思路</h4><ul><li>根据 <strong>传入时间 <code>time</code></strong> 派生出 <strong>剩余时间 <code>remain</code></strong>,并计算出对应的 <strong>结束时间 <code>endTime</code></strong></li><li>通过 <strong>requestAnimationFrame + 递归</strong> 的方式更新 <strong><code>remain</code></strong> 值,即 <strong><code>remain = endTime - Date.now()</code></strong></li><li><p>根据最新的 <strong><code>remain</code></strong> 值,通过 <strong><code>parseTime()</code></strong> 和 <strong><code>formatTime()</code></strong> 方法进行转换返回对应的结果</p><ul><li><strong><code>parseTime()</code></strong> 负责将 <strong><code>remain</code></strong> 值转换成 <strong>天数/小时/分钟/秒/毫秒</strong> 等值</li><li><strong><code>formatTime()</code></strong> 负责将输出结果格式化,例如 <strong>不足位补 0</strong> 等</li></ul></li></ul><h4>效果展示</h4><p></p><h4>具体代码</h4><h5>src\components\CountDown\index.vue</h5><pre><code class="js"><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></code></pre><h5>src\components\CountDown\composable\useCountDown\index.ts</h5><pre><code class="js">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
}
}</code></pre><h5>src\components\CountDown\utils\index.ts</h5><pre><code class="js">// 常量
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
}</code></pre><h2 id="item-0-14">最后</h2><blockquote><strong>欢迎关注同名公众号《<code>熊的猫</code>》,文章会同步更新,也可快速加入前端交流群!</strong></blockquote><p>以上就是一个基本的计时器的实现了,其中肯定有不足之处,不过大家只需要抓住核心思想即可,很多内容都借鉴了 <a href="https://link.segmentfault.com/?enc=bVvB3b%2BDF%2FXUY%2FSX52PAUg%3D%3D.0g46ouJ1Z7%2BCP%2BcqOu2qmoDqLzy3Id804CFny%2Fq2z1Urupi3kmCDQ6pZC0fX9tgHS6aHXyW5iLkm0%2FyZ3bnwmg%3D%3D" rel="nofollow" target="_blank"><strong>vant-count-down 组件</strong></a> 的实现,感兴趣可以直接去看其对应的源码。</p><p><strong>希望本文对你有所帮助!!!</strong></p><p></p>