返回博客列表

教程

从零实现 Shopify 促销倒计时组件

2026-05-13

你是否见过某些 Shopify 店铺的促销页面,商品旁边有一个红色的倒计时——每一秒都在跳动,每跳一次就多一份「再不下单就来不及了」的紧迫感?

这篇文章带你从零实现它。技术栈覆盖 Metafield 数据设计 → Liquid 条件渲染 → JavaScript 计时逻辑 → CSS 样式,最后还会讲三个让组件更健壮的进阶技巧,以及五个高频踩坑问题的解决方案。


为什么要做倒计时?

倒计时的本质是制造稀缺感。研究行为经济学的人把这个叫做「截止日期效应」——当用户意识到某个机会即将消失,决策速度会明显加快。

常见的使用场景有这几类:

  • 大促活动:双十一、黑五全场折扣,显示「距活动结束 02:14:38」
  • 限时闪购:每天 12:00 限时一小时,每秒都在倒计
  • 新品预售:发售前 72 小时预热,积累期待感
  • 限时免邮:今日免邮优惠还剩 XX 分钟,促进即时下单

一个好的倒计时组件,需要满足六个要求:实时更新(每秒刷新,不依赖页面重载)、响应式(桌面和移动端均美观)、可配置(结束时间在后台改,不动代码)、到期处理(归零后自动隐藏或切换文案)、与主题融合(样式贴合品牌色)、以及性能优先(离开页面自动暂停)。


第一步:设计数据结构(Metafield)

倒计时的结束时间不能写死在代码里,否则每次活动都要改代码重新发布。正确的做法是把它存进 Shopify Metafield,让运营在后台自己填写,开发者零介入。

数据流向

Shopify 后台填写时间 → Metafield 存储 → Liquid 读取渲染 → 用户看到倒计时

推荐的 Metafield 配置

namespace : custom
key       : sale_end_time
type      : date_time(ISO 8601 格式)

示例值:"2025-11-11T23:59:59+08:00"

为什么用 date_time 类型? 因为它原生支持时区,避免手动换算。Liquid 还可以直接用 | date 过滤器将其格式化显示,非常方便。

挂在哪个层级?

这取决于活动的范围:

  • 全站促销 → 挂在 Shop 级别或 Section settings
  • 单品活动 → 挂在 Product metafield
  • 分类活动 → 挂在 Collection metafield

另外建议额外建一个 sale_label 字段,存「双十一限时价」「闪购进行中」这类文案,一并通过 Liquid 输出,让组件更灵活。


第二步:Liquid 层——渲染 HTML 骨架,传参给 JS

Liquid 在这里做两件事:条件判断(有活动才显示)和 数据传递(把结束时间注入 HTML,供 JS 读取)。

{% assign end_time = product.metafields.custom.sale_end_time %}

{% if end_time != blank %}
  <div class="countdown-wrap"
       data-end="{{ end_time }}"
       data-label="{{ product.metafields.custom.sale_label }}">

    <p class="cd-label">
      {{ product.metafields.custom.sale_label }}
    </p>

    <div id="countdown-timer"></div>

  </div>
{% endif %}

代码解读:

{% assign end_time = ... %} 先把 metafield 赋给变量,缩短后续代码长度,保持可读性。

{% if end_time != blank %} 是关键的条件判断——只有当后台配置了结束时间,整个倒计时区块才会出现,不会留下令人困惑的空白区域。

data-end="{{ end_time }}" 是核心传参方式。Liquid 把 ISO 时间字符串写入 HTML 的 data 属性,JavaScript 直接用 dataset.end 读取,完全不需要 AJAX 请求,既简单又高效。

<div id="countdown-timer"></div> 是一个空壳,它的内容由 JavaScript 动态填充,对 SEO 没有影响。


第三步:JavaScript 层——setInterval 倒计时核心

JS 这层的思路非常清晰:目标时间戳 − 当前时间戳 = 毫秒差 → 拆分为天/时/分/秒 → 每秒渲染一次

const wrap    = document.querySelector('.countdown-wrap')
const endTime = new Date(wrap.dataset.end)

function tick() {
  const diff = endTime - Date.now()

  // 到期处理
  if (diff <= 0) { onExpired(); return }

  // 时间换算(科学计数法更简洁)
  const d = Math.floor(diff / 864e5)          // 天
  const h = Math.floor(diff % 864e5 / 36e5)   // 时
  const m = Math.floor(diff % 36e5  / 6e4)    // 分
  const s = Math.floor(diff % 6e4   / 1e3)    // 秒

  render(d, h, m, s)
}

const timer = setInterval(tick, 1000)
tick()  // 立即执行一次,避免页面加载时出现 1 秒空白

几个值得注意的细节:

new Date(wrap.dataset.end):从 data-end 属性读取时间字符串,new Date() 在解析带时区偏移的 ISO 格式时,会自动换算到本地时间,时区处理由浏览器完成,无需手动计算。

科学计数法864e5 就是 86400000(1天的毫秒数),36e53600000(1小时),写法更简洁,不容易数错零。

最后一行 tick():在 setInterval 启动前先立即调用一次,消除页面加载时的那 1 秒空白——用户打开页面就能看到数字,而不是先看到空容器。


第四步:CSS 层——让倒计时好看又稳定

两种主流风格

暗色翻牌风格:深色背景配白色等宽数字,红色描边,适合大促氛围。

.digit {
  background: #0d1929;
  color: #fff;
  border: 1px solid rgba(231, 76, 60, 0.4);
  border-radius: 10px;
  font-family: 'Courier New', monospace;
  font-size: 38px;
  font-weight: 900;
  width: 54px;
  height: 68px;
}

亮色胶囊风格:品牌红底白字,圆角胶囊,简洁现代,适合日常活动。

.digit {
  background: #E74C3C;
  color: #fff;
  border-radius: 10px;
  padding: 8px 12px;
  font-size: 22px;
  font-weight: 900;
}

五个关键 CSS 属性

属性作用
font-variant-numerictabular-nums数字等宽,防止跳动时布局抖动
min-width2ch个位数时不跳位(如 5 不会比 52 窄)
transitionall 0.3s ease数字切换带有过渡感
animationblink 1s step-end infinite冒号闪烁效果
widthcalc(var(--pct) * 1%)用 CSS 变量驱动进度条动态缩短

其中 font-variant-numeric: tabular-nums 是最容易被忽视、但影响最大的一个属性。没有它,数字从 10 变为 9 时,因为字符宽度不同,整个布局会轻微抖动,肉眼可见,非常影响体验。


第五步:三个进阶升级点

基础功能跑通之后,再加这三个处理,组件就真正达到生产级别了。

1. 页面可见性优化(性能)

用户切换到其他标签页时,倒计时在后台继续空转,白白消耗资源,而且切回来时时间可能已经积累了一些误差。解决方法是监听 visibilitychange 事件:

document.addEventListener('visibilitychange', () => {
  document.hidden ? clearInterval(timer) : tick()
})

页面隐藏时清除计时器,页面重新可见时立即调用 tick() 重启——既节省资源,又自动修正误差。

2. 到期后的自动处理(体验)

倒计时归零时,如果不处理,界面会显示「00:00:00:00」或者出现负数,非常难看。给 onExpired 函数提供三种选择方案:

function onExpired() {
  clearInterval(timer)

  // 方案一:直接隐藏整块
  wrap.remove()

  // 方案二:替换为结束提示
  // wrap.innerHTML = '<p>活动已结束</p>'

  // 方案三:触发价格变更提示
  // document.querySelector('.price').classList.add('original-price')
}

根据业务场景选择对应方案,在 tick() 开头判断 if (diff <= 0) 时调用并 return,防止继续执行渲染逻辑。

3. 多个倒计时并存(扩展)

商品列表页可能每件商品都有自己的促销倒计时。这时需要用 querySelectorAll 找到所有计时器容器,分别初始化,互不干扰:

document.querySelectorAll('.countdown-wrap')
  .forEach(el => initCountdown(el))

将初始化逻辑封装成接收 el 参数的 initCountdown 函数,每个容器各自持有独立的 timer 引用,互不影响。


常见踩坑与解决方案

Q:时间显示差 8 小时,和预期对不上?

Metafield 中 date_time 类型存的是 UTC 时间。如果填写时没有带上时区偏移量,JS 解析时会将其当作 UTC 处理,在中国时区就会显示差 8 小时。解决方法是值中务必带上 +08:00,如 2025-11-11T23:59:59+08:00

Q:数字跳动时页面宽度一直在抖?

给数字元素加上 font-variant-numeric: tabular-nums 保证等宽,再加 min-width: 2ch 保证个位数时不跳位,两个属性配合使用可以完全消除抖动。

Q:Liquid 的 end_time 读出来是 blank?

检查两处:一是 Metafield 的 namespacekey 拼写是否与代码里完全一致(大小写敏感);二是该 Metafield 是否已在 Shopify 后台启用并且已经填写了值——新建的 Metafield 定义不等于有值。

Q:主题 JS 文件压缩后 querySelector 失效?

原因是初始化代码在 DOM 渲染完成之前就执行了。将初始化逻辑包在 DOMContentLoaded 事件内,或者给 <script> 标签加 defer 属性,确保 DOM 已经渲染完毕再执行:

<script src="{{ 'countdown.js' | asset_url }}" defer></script>

Q:活动结束后计时器变成负数?

tick() 函数开头最先判断 if (diff <= 0),立即调用 onExpired()return,阻止后续的 render() 执行。顺序很关键——判断必须在换算和渲染之前。


完整流程回顾

这个组件的技术架构可以用一条清晰的链路来总结:

⚙️ 后台配置  →  { } Liquid 层  →  ⚡ JS 计算  →  🎨 CSS 渲染  →  👤 用户体验

五个核心步骤:

  1. Metafield 存结束时间date_time 类型带时区,后台可改,无需动代码
  2. Liquid 条件渲染 + data 属性传参if != blank 控制显隐,data-end 把数据交给 JS
  3. setInterval 每秒计算时间差 — 毫秒差整除换算,立即执行一次避免闪白
  4. CSS tabular-nums 防数字抖动 — 等宽字体 + min-width,进度条用 CSS 变量驱动
  5. visibilitychange + onExpired 兜底 — 后台暂停节省性能,到期自动清除或替换内容

💡 这套「Metafield → Liquid → JS → CSS」的分层架构,是 Shopify 定制开发的典型思路。倒计时只是一个具体案例,理解了这个模式,类似的动态组件都可以用同样的方式拆解实现。