面试官:你先实现个 CountDown 计时器组件吧!

9,888 阅读11分钟

前言

欢迎关注同名公众号《熊的猫》,文章会同步更新,也可快速加入前端交流群!

最近有个同学在面试时被要求手写一个 CountDown 计时器组件,但可能是因为之前没有了解过,所以思路上没有那么顺畅,过后他询问我应该怎么写(哈哈,我也没写过),于是就有了本篇文章,希望本篇文章对你有所帮助!!!

2A4973BC.png

不难看出,要求手写一个 CountDown 计时器组件 目的无非考察如下几个方面(谁也不知道面试官在想什么):

  • 组件封装能力
    • 组件输入,即对应组件 内部 Props 的 设计 和 考量
    • 组件输出,即对应组件 对外提供 的 属性 或 方法
    • 逻辑复用,即指组件内部逻辑的 可组合性
  • 时间相关的敏感度
    • 倒计时的实现方式有多种,例如 setInterval、setTimeout、requestAnimationFrame 等等,那么哪种更合适?
    • 获取当前时间可以用 Date.now()、performance.now(),那么该怎么选?

setInterval & setTimeout & requestAnimationFrame

倒计时功能必然需要一个不断执行的 异步过程没疑问吧),这可以使用运行时环境提供的 API,即 setInterval、setTimeout、requestAnimationFrame,那么到底该选择谁更合适呢?

0AC4B74C.jpg

下面进行逐个分析!

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>

1.gif

但为了 避免代码杂乱无章保证代码的可维护性,还是更推荐使用相互匹配的 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 的超时,并可能完全卸载它们
    • 例如,若标签中包含 AudioContextFirefox 不会对非活动标签进行节流
  • 追踪型脚本的节流
    • 例如,Firefox 对它识别为追踪型脚本的脚本 实施额外节流,即当在 前台运行 时,节流的最小延迟是 4ms
    • 当在 后台标签 中,节流的最小延迟是 10000ms(即 10s),在文档首次加载后 30s 开始生效
  • 在加载页面时推迟超时
    • 当前标签页正在加载时,Firefox 将推迟触发 setTimeout() 计时器,直到主线程被认为是空闲 的(类似于 window.requestIdleCallback())或 直到 加载事件触发完毕,才开始触发

requestAnimationFrame

是什么?

window.requestAnimationFrame()  会告诉浏览器我们希望执行一个 动画,并且要求浏览器在下次 重绘之前 调用指定的回调函数 更新动画,即每 16.67ms 执行一次回调函数。

回调函数的参数

回调方法在会接收到一个 DOMHighResTimeStamp 参数,它是一个 十进制数,单位为毫秒,最小精度为 1ms(1000μs)

同一帧 中的 多个回调函数 都会接受到一个 相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间,因此要确保总是使用 第一个参数(或其他一些获取当前时间的方法) 来计算动画在一帧中的进度,否则动画在 高刷新率 的屏幕中会 运行得更快

暂停调用

为了提高性能和电池寿命,在大多数浏览器里,当 requestAnimationFrame() 运行在 后台标签页隐藏的 <iframe> 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。

到底选谁?

从以上内容来看,似乎没有一个完美的方案呀,这不是更加大难度了?

0B00E6F4.jpg

莫慌!既然都不完美,那么也要从矮个子中挑个高个子。

先不考虑别的,setIntervalsetTimeout 有一个致命的缺点:

  • 最大延时限制
    • 延时时间一旦大于 2147483647 ms(约 24.8 天) 时会产生溢出,导致定时器将会被 立即执行

别的不说,就这个缺点就导致使用它们来做倒计时组件不太现实,难不成不允许用户的倒计时超过 25 天 吗?

0B085ABA.jpg

所以这里选择 requestAnimationFrame() + 递归 来实现!

Date.now() & performance.now()

同样的道理,获取当前日期的时间戳也有 Date.now()performance.now() 两种方式,又该选谁呢?

0B16EF55.gif

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
// ...

到底该选谁?

好家伙,说白了就还是没有一个完美的选择呗!

4801FB4D.jpg

在这里选 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

效果展示

1.gif

具体代码

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 组件 的实现,感兴趣可以直接去看其对应的源码。

希望本文对你有所帮助!!!

4977341A.jpg