教程
从零实现 Shopify 促销倒计时组件
你是否见过某些 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天的毫秒数),36e5 是 3600000(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-numeric | tabular-nums | 数字等宽,防止跳动时布局抖动 |
min-width | 2ch | 个位数时不跳位(如 5 不会比 52 窄) |
transition | all 0.3s ease | 数字切换带有过渡感 |
animation | blink 1s step-end infinite | 冒号闪烁效果 |
width | calc(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 的 namespace 和 key 拼写是否与代码里完全一致(大小写敏感);二是该 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 渲染 → 👤 用户体验
五个核心步骤:
- Metafield 存结束时间 —
date_time类型带时区,后台可改,无需动代码 - Liquid 条件渲染 + data 属性传参 —
if != blank控制显隐,data-end把数据交给 JS setInterval每秒计算时间差 — 毫秒差整除换算,立即执行一次避免闪白- CSS
tabular-nums防数字抖动 — 等宽字体 +min-width,进度条用 CSS 变量驱动 visibilitychange+onExpired兜底 — 后台暂停节省性能,到期自动清除或替换内容
💡 这套「Metafield → Liquid → JS → CSS」的分层架构,是 Shopify 定制开发的典型思路。倒计时只是一个具体案例,理解了这个模式,类似的动态组件都可以用同样的方式拆解实现。