Back

使用 JavaScript 构建节日倒计时器

使用 JavaScript 构建节日倒计时器

每年十二月,开发者都会面临同样的需求:为圣诞节、新年前夜或其他节日构建倒计时器。这听起来很简单,直到你的计时器偏差了几分钟、在用户切换标签页时出错,或者在午夜过后显示负数。

本文将向你展示如何构建一个可靠的 JavaScript 倒计时器,正确处理这些边界情况。你将了解为什么在每次计时时重新计算时间比递减计数器更好,如何正确处理 JavaScript 时区,以及如何避免大多数实现中常见的陷阱。

核心要点

  • 在每次计时时使用 Date.now() 重新计算剩余时间,而不是递减计数器,以避免计时器漂移
  • 使用 new Date(year, month, day) 在用户本地时区创建日期,无需外部库
  • 始终存储并清除定时器 ID 以防止内存泄漏,特别是在单页应用程序中
  • 通过基于实际时间差的计算来构建对浏览器标签页节流的弹性

为什么递减计数器会失败

构建节日倒计时 JavaScript 小部件的简单方法看起来是这样的:设置一个变量为剩余秒数,然后使用 setInterval 每秒减一。这在实践中会出问题。

问题在于 setInterval 计时器漂移。JavaScript 计时器不保证精确执行。当用户切换标签页时,浏览器会节流计时器以节省资源——有时每秒触发一次,有时每分钟触发一次。你的计数器继续递减,就好像时间正常流逝一样,但实际上并没有。

解决方法很简单:在每次计时时通过将 Date.now() 与目标日期进行比较来重新计算剩余时间。这样,即使你的计时器触发延迟,显示的时间仍然保持准确。

设置目标日期

对于节日倒计时,你通常希望在用户本地时区的特定日历日期的午夜。以下是正确设置的方法:

function getTargetDate(month, day) {
  const now = new Date()
  const year = now.getFullYear()
  
  // If the holiday has passed this year, target next year
  const target = new Date(year, month - 1, day, 0, 0, 0)
  if (target <= now) {
    target.setFullYear(year + 1)
  }
  
  return target
}

const christmasTarget = getTargetDate(12, 25)

使用 new Date(year, month, day) 会自动在用户的本地时区创建日期。这无需任何库即可处理 JavaScript 时区——倒计时显示的是距离用户所在位置 12 月 25 日午夜的时间。

夏令时注意事项:当你以这种方式构造日期时,JavaScript 的 Date 对象会自动处理夏令时转换。即使时钟发生变化,倒计时仍然保持准确。

核心倒计时逻辑

这是一个解决常见问题的生产就绪实现:

function createCountdown(targetDate, onUpdate, onComplete) {
  let intervalId = null
  
  function calculateRemaining() {
    const now = Date.now()
    const difference = targetDate.getTime() - now
    
    // Prevent negative values
    if (difference <= 0) {
      clearInterval(intervalId)
      onComplete()
      return null
    }
    
    return {
      days: Math.floor(difference / (1000 * 60 * 60 * 24)),
      hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
      minutes: Math.floor((difference / (1000 * 60)) % 60),
      seconds: Math.floor((difference / 1000) % 60)
    }
  }
  
  function tick() {
    const remaining = calculateRemaining()
    if (remaining) {
      onUpdate(remaining)
    }
  }
  
  // Run immediately, then every second
  tick()
  intervalId = setInterval(tick, 1000)
  
  // Return cleanup function
  return () => clearInterval(intervalId)
}

这种方法同时解决了几个问题:

  • 无漂移:每次计时时都会重新计算时间
  • 无负值:倒计时完成时停止并清除定时器
  • 正确清理:返回的函数让你可以在需要时停止计时器
  • 标签页节流弹性:即使计时延迟,显示的时间仍然保持正确

连接到 DOM

将倒计时连接到你的 HTML:

const display = document.getElementById('countdown')
const cleanup = createCountdown(
  christmasTarget,
  ({ days, hours, minutes, seconds }) => {
    display.textContent = `${days}d ${hours}h ${minutes}m ${seconds}s`
  },
  () => {
    display.textContent = "It's here!"
  }
)

// Call cleanup() when navigating away in a SPA

处理边界情况

已经过去:getTargetDate 函数会在节日已过的情况下自动滚动到下一年。

清除定时器:始终存储定时器 ID 并在倒计时完成时清除它。不这样做会导致内存泄漏,特别是在单页应用程序中。

无障碍访问:考虑在倒计时容器中添加 aria-live="polite",以便屏幕阅读器在不造成干扰的情况下宣布更新。

展望未来

即将推出的 Temporal API 将使 JavaScript 日期和时间处理变得更加简单,内置了明确的时区支持。在它在所有浏览器中发布之前,本文展示的模式仍然是可靠的选择。

结论

使用时间差计算而不是递减计数器来构建节日倒计时 JavaScript 小部件。无论浏览器节流、标签页切换还是夏令时转换,你的计时器都将保持准确——并且你将避免更简单方法带来的调试难题。

常见问题

浏览器会节流后台标签页中的 JavaScript 计时器以节省资源。如果你每秒递减一个计数器,即使标签页处于非活动状态,计数也会继续,就好像时间正常流逝一样。相反,应该在每次计时时使用 Date.now() 与目标日期进行比较来重新计算剩余时间。这确保了无论计时器实际触发频率如何都能保持准确性。

使用带有数字参数的 Date 构造函数,如 new Date(year, month, day),自动在用户的本地时区创建日期。这种方法不需要外部库,并确保每个用户看到的是他们自己时区午夜的倒计时,而不是固定的 UTC 时间。

当倒计时完成后 setInterval 继续运行,或者当用户在单页应用程序中导航离开时,就会发生内存泄漏。始终存储 setInterval 返回的定时器 ID,并在倒计时完成或组件卸载时调用 clearInterval。从倒计时创建器返回一个清理函数以便于处理。

Temporal API 将通过明确的时区支持和更清晰的语义简化日期和时间处理。然而,重新计算时间差而不是递减计数器的核心原则仍然适用。在 Temporal 在所有浏览器中发布之前,本文中的模式提供了可靠的跨浏览器解决方案。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay