跳到正文

手写练习/异步控制

GitHub

实现原生 JavaScript 倒计时组件

中等JavaScript异步定时器DOM

运行预览

代码实现

HTML122 行
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>倒计时组件</title>

    <style>
        body {
            margin: 0;
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        #box {
            width: 300px;
            height: 200px;
            border: 1px solid #000;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            gap: 16px;
        }

        #countdown {
            font-size: 32px;
            font-variant-numeric: tabular-nums;
        }

        .actions {
            display: flex;
            gap: 8px;
        }
    </style>
</head>

<body>
    <div id="box">
        <div id="countdown" role="timer" aria-live="polite">00:00:10</div>

        <div class="actions">
            <button id="start" type="button">开始 / 继续</button>
            <button id="pause" type="button">暂停</button>
            <button id="reset" type="button">重置</button>
        </div>
    </div>

    <script>
        const countdown = document.getElementById('countdown')
        const startButton = document.getElementById('start')
        const pauseButton = document.getElementById('pause')
        const resetButton = document.getElementById('reset')

        let timer = null
        let endTime = null

        const duration = 10 * 1000
        let remainingTime = duration

        function formatTime(seconds) {
            const hours = Math.floor(seconds / 3600)
            const minutes = Math.floor((seconds % 3600) / 60)
            const secs = seconds % 60

            return [hours, minutes, secs]
                .map(item => String(item).padStart(2, '0'))
                .join(':')
        }

        function stopTimer() {
            clearInterval(timer)
            timer = null
        }

        function render(milliseconds) {
            const totalSeconds = Math.ceil(milliseconds / 1000)
            countdown.innerText = formatTime(totalSeconds)
        }

        function update() {
            remainingTime = Math.max(0, endTime - Date.now())
            render(remainingTime)

            if (remainingTime === 0) {
                stopTimer()
                endTime = null
            }
        }

        // 开始 / 继续
        startButton.onclick = function () {
            if (timer) return
            if (remainingTime <= 0) return

            endTime = Date.now() + remainingTime
            update()
            timer = setInterval(update, 1000)
        }

        // 暂停
        pauseButton.onclick = function () {
            if (!timer) return

            remainingTime = Math.max(0, endTime - Date.now())
            stopTimer()
            endTime = null
            render(remainingTime)
        }

        // 重置
        resetButton.onclick = function () {
            stopTimer()
            endTime = null
            remainingTime = duration
            render(remainingTime)
        }
    </script>
</body>
</html>

笔记记录

原生 JavaScript 倒计时组件

是什么

这个倒计时组件从 10 秒开始,支持开始、暂停、继续和重置。

它最重要的设计不是每隔一秒把数字减一,而是记录目标结束时间,并在每次更新时用“结束时间减当前时间”重新计算剩余时长。

剩余时长 = 目标结束时间 - 当前时间

这样即使定时器晚执行了,下一次更新也会根据真实时间自动校准,而不会不断累积误差。

长什么样

组件包含一个时间显示区域和三个操作按钮:

┌──────────────────────────┐
│         00:00:10         │
│                          │
│  开始/继续  暂停  重置   │
└──────────────────────────┘

时间统一显示为 HH:MM:SS。例如,10 秒显示为 00:00:10。

状态怎么保存

代码中有三个关键状态:

  • timer:保存 setInterval 返回的定时器标识。它不为 null 时,说明倒计时正在运行。
  • endTime:倒计时运行时的目标结束时间。
  • remainingTime:剩余毫秒数。暂停时保存当前进度,继续时用它计算新的结束时间。

运行和暂停时,事实来源不同:

运行中:endTime 决定还剩多久
暂停时:remainingTime 记录停在何处

怎么执行

开始或继续

点击开始时,先用当前剩余时长计算目标结束时间:

endTime = Date.now() + remainingTime

随后立即渲染一次,再启动定时器。立即渲染可以避免必须等待第一个一秒周期后页面才更新。

如果已有定时器,或者剩余时间已经为零,就直接返回,避免重复启动多个定时器。

每次更新

定时器只负责定期唤醒 update,真正的剩余时间由系统时间计算:

remainingTime = Math.max(0, endTime - Date.now())

Math.max(0, ...) 确保时间不会显示成负数。到达零时清理定时器,并把结束时间恢复为 null。

渲染时使用 Math.ceil 把毫秒换算成秒。只要还剩一小部分时间,界面就仍显示一秒;真正结束后才显示 00:00:00。

暂停

暂停时先根据当前时间重新计算剩余时长,然后清理定时器。这样继续时可以从暂停的位置重新生成新的 endTime:

暂停:remainingTime = endTime - 当前时间
继续:endTime = 当前时间 + remainingTime

重置

重置会清理正在运行的定时器、移除结束时间,并把剩余时长恢复为最初的 10 秒。

为什么不能只做 totalSeconds--

setInterval(fn, 1000) 的含义是:时间到达后,把回调放进任务队列等待执行。它不保证回调一定能严格每隔一秒执行。

主线程繁忙、浏览器切到后台或设备进入省电状态时,定时器都可能被延迟。如果每次回调只执行 totalSeconds--,这些延迟会不断累积,页面上的倒计时就会比真实时间慢。

错误思路:剩余秒数 = 上一次秒数 - 1
当前实现:剩余时长 = 结束时间 - 当前时间

因此,setInterval 在这里是“更新界面的提醒器”,endTime 才是计算倒计时的时间基准。

记一下

  • 时间统一用毫秒保存,只在渲染时转换成秒,减少单位混用。
  • 开始前要防止重复创建定时器。
  • 暂停、结束和重置时都要清理定时器。
  • clearInterval(null) 不会报错,因此可以把清理逻辑集中到 stopTimer。
  • Date.now() 可能受到系统时间手动调整的影响。如果场景要求只测量当前页面内的稳定时间间隔,可以进一步考虑单调递增的 performance.now()。