给 Dawn 增加倒计时分区:同源截止时间、服务端时钟与编辑器清理
本文基于 Dawn v16.0.0,源码固定到 bc39a7d(核验于 2026-10-08:v16.0.0 标签指向该提交,config/settings_schema.json 的 theme_version 为 16.0.0,是 GitHub Releases 上的最新发布)。与本库其他 Dawn 文章不同,这一篇不是解读既有功能,而是新增一个主题里没有的功能。实现代码只做了静态检查,未在真实店铺运行;每一处未验证的判断都在文内标出。
倒计时该不该做、什么时候做,不在本文范围:真实性红线、合规与无障碍要求以 Countdown Timer 为准,本文只回答「在 Dawn 里怎样按它的要求实现」。
先确认 Dawn 没有倒计时
在固定提交全仓库搜索 countdown、count-down、timer(不区分大小写):命中只有 assets/constants.js 的 ON_CHANGE_DEBOUNCE_TIMER(购物车与批量加购的防抖常量)和几个语言文件里的无关词(如挪威语 Estimert totalsum)。搜索 setInterval 只命中 global.js 的轮播自动播放与 BulkAdd 请求队列;<time 元素在 sections、snippets、layout 中无命中。结论:Dawn v16.0.0 没有内置倒计时,也没有可以直接复用的计时组件,需要新增文件,不需要改动既有分区。
先定数据来源,再写代码
Countdown Timer 的第一条红线是「同一时间源」:倒计时读的截止时刻必须就是折扣实际结束的那份数据。这在 Dawn 里绕不开一个平台限制:
- Liquid 读不到折扣的起止时间。
discount_application对象只有target_selection、target_type、title、total_allocated_amount、type、value、value_type(shopify.dev,核验于 2026-10-07),描述的是购物车里已经生效的折扣;shop对象没有任何折扣或促销日程属性。折扣的startsAt/endsAt只在 Admin GraphQL(如discountAutomaticAppCreate的返回)里出现,店面主题拿不到。 - 分区设置没有日期类型。 输入设置类型列表里只有
text、number、range、select等基础类型和product、metaobject等资源类型,没有 date 或 datetime(shopify.dev 输入设置页,核验于 2026-10-07)。 - Liquid 里的
now会被缓存。date过滤器文档写明,'now'输出的是 Liquid 最近一次渲染的时间,「可能不会在每次页面访问时更新」。所以剩余时间不能在 Liquid 里算。
因此本实现把截止时间放在 shop 级元字段 promo.ends_at(类型 date_time)里,分区只读不写:
| 方案 | 做法 | 与「同源」的距离 |
|---|---|---|
| 元字段(本文采用) | 应用或 Shopify Flow 在创建、修改折扣时把 endsAt 写入 shop.metafields.promo.ends_at;主题读它 | 最近:主题与折扣之间只隔一个同步动作。同步本身不在主题内,本文没有实现,Flow 能否直接写该元字段未核验 |
| 分区文本设置(本文仅作回退) | 商家在编辑器里手填 ISO 8601 字符串 | 最远:两处手填,必然出现不一致;只在没有元字段时使用,编辑器文案里明确要求与后台折扣一致 |
| 应用代理或 Storefront API 实时查询 | 前端请求应用接口取 endsAt | 同源但引入外部依赖与额外请求;超出本文范围 |
date_time 元字段的值是「不预设时区的 ISO 8601 字符串,默认按 GMT」(shopify.dev 元字段类型页);Liquid 里 metafield.value 对 date_time 返回日期字符串,可以接 date 过滤器(shopify.dev metafield 对象页)。本实现据此把没有 Z 或 + 偏移的值视为 UTC,补上 Z 后再交给浏览器解析;date 过滤器解析这种字符串时按哪个时区取值,本文未验证,所以服务端只用它判断「是否已经结束」,不用它算剩余时长。
文件与职责
下表的每一条都对应 Dawn 既有写法,便于与主题其余部分保持一致:
| 文件 | 职责 | 参照的 Dawn 写法 |
|---|---|---|
sections/countdown-timer.liquid(新增) | 读截止时间、判断三种状态、输出静态截止文案与数字骨架、{% schema %} | rich-text.liquid 的 section-{{ section.id }}-padding、color_scheme、page-width、t: 键;main-product.liquid 在分区内用 <script defer> 加载自己的脚本 |
assets/countdown-timer.js(新增) | 自定义元素:估计服务端时钟偏移、按两档间隔更新、到期收尾、隐藏按钮、状态播报 | global.js 的 customElements.define 与 matchMedia('(prefers-reduced-motion: reduce)');cart.js、product-info.js、cart-disclosure-modal.js 等在 disconnectedCallback 里清理 |
assets/section-countdown-timer.css(新增) | 分区样式;唯一的过渡放在 prefers-reduced-motion: no-preference 里 | base.css 的 --duration-short 与减少动态媒体查询 |
locales/en.default.json(追加) | 前台文案 sections.countdown_timer.* | 既有 sections.* 结构 |
locales/en.default.schema.json(追加) | 编辑器文案 sections.countdown-timer.* | 既有 sections.rich-text.* 结构 |
layout/theme.liquid(追加 4 行) | 把两条播报文案暴露为 window.countdownStrings | 同文件已有的 window.accessibilityStrings 等 |
分区 Liquid
{{ 'section-countdown-timer.css' | asset_url | stylesheet_tag }}
<script src="{{ 'countdown-timer.js' | asset_url }}" defer="defer"></script>
{%- liquid
assign ends_at = shop.metafields.promo.ends_at.value
if ends_at == blank
assign ends_at = section.settings.ends_at_fallback
endif
assign ends_at = ends_at | strip
assign last_char = ends_at | slice: -1
assign has_offset = false
if last_char == 'Z' or ends_at contains '+'
assign has_offset = true
endif
if ends_at != blank and has_offset == false
assign ends_at = ends_at | append: 'Z'
endif
assign ends_at_unix = ends_at | date: '%s' | plus: 0
assign rendered_at_unix = 'now' | date: '%s' | plus: 0
assign state = 'running'
if ends_at == blank or ends_at_unix == 0
assign state = 'missing'
elsif rendered_at_unix >= ends_at_unix
assign state = 'ended'
endif
-%}
{%- style -%}
.section-{{ section.id }}-padding {
padding-top: {{ section.settings.padding_top | times: 0.75 | round: 0 }}px;
padding-bottom: {{ section.settings.padding_bottom | times: 0.75 | round: 0 }}px;
}
@media screen and (min-width: 750px) {
.section-{{ section.id }}-padding {
padding-top: {{ section.settings.padding_top }}px;
padding-bottom: {{ section.settings.padding_bottom }}px;
}
}
{%- endstyle -%}
{%- if state == 'missing' -%}
{%- if request.design_mode -%}
<div class="page-width section-{{ section.id }}-padding">
<p class="caption-with-letter-spacing">{{ 'sections.countdown_timer.missing_end_time' | t }}</p>
</div>
{%- endif -%}
{%- else -%}
<div class="color-{{ section.settings.color_scheme }} gradient section-{{ section.id }}-padding">
<countdown-timer
class="countdown-timer page-width"
data-ends-at="{{ ends_at | escape }}"
data-state="{{ state }}"
data-section-id="{{ section.id }}"
>
{%- if section.settings.heading != blank -%}
<h2 class="countdown-timer__heading inline-richtext {{ section.settings.heading_size }}">
{{ section.settings.heading }}
</h2>
{%- endif -%}
<p class="countdown-timer__ends-at">
{{ 'sections.countdown_timer.ends_at_html' | t: ends_at: ends_at | date: '%Y-%m-%d %H:%M UTC' }}
<time datetime="{{ ends_at | escape }}" data-ends-at-local hidden></time>
</p>
<div class="countdown-timer__clock" data-clock{% if state == 'ended' %} hidden{% endif %} aria-hidden="true">
{%- assign units = 'days,hours,minutes,seconds' | split: ',' -%}
{%- for unit in units -%}
{%- assign unit_key = 'sections.countdown_timer.' | append: unit -%}
<div class="countdown-timer__unit" data-unit-wrapper="{{ unit }}">
<span class="countdown-timer__value h2" data-unit="{{ unit }}">--</span>
<span class="countdown-timer__label caption-with-letter-spacing">{{ unit_key | t }}</span>
</div>
{%- endfor -%}
</div>
<p class="countdown-timer__ended" data-ended{% unless state == 'ended' %} hidden{% endunless %}>
{{ 'sections.countdown_timer.ended' | t }}
</p>
{%- if section.settings.show_hide_button and state != 'ended' -%}
<button type="button" class="link link--text countdown-timer__hide" data-hide hidden>
{{ 'sections.countdown_timer.hide' | t }}
</button>
{%- endif -%}
<p class="visually-hidden" role="status" data-status></p>
</countdown-timer>
</div>
{%- endif -%}
{% schema %}
{
"name": "t:sections.countdown-timer.name",
"tag": "section",
"class": "section",
"disabled_on": { "groups": ["footer"] },
"settings": [
{ "type": "paragraph", "content": "t:sections.countdown-timer.settings.paragraph.content" },
{
"type": "text",
"id": "ends_at_fallback",
"label": "t:sections.countdown-timer.settings.ends_at_fallback.label",
"info": "t:sections.countdown-timer.settings.ends_at_fallback.info"
},
{
"type": "inline_richtext",
"id": "heading",
"default": "t:sections.countdown-timer.settings.heading.default",
"label": "t:sections.countdown-timer.settings.heading.label"
},
{
"type": "select",
"id": "heading_size",
"options": [
{ "value": "h2", "label": "t:sections.all.heading_size.options__1.label" },
{ "value": "h1", "label": "t:sections.all.heading_size.options__2.label" },
{ "value": "h0", "label": "t:sections.all.heading_size.options__3.label" }
],
"default": "h1",
"label": "t:sections.all.heading_size.label"
},
{
"type": "checkbox",
"id": "show_hide_button",
"default": true,
"label": "t:sections.countdown-timer.settings.show_hide_button.label",
"info": "t:sections.countdown-timer.settings.show_hide_button.info"
},
{ "type": "color_scheme", "id": "color_scheme", "label": "t:sections.all.colors.label", "default": "scheme-1" },
{ "type": "header", "content": "t:sections.all.padding.section_padding_heading" },
{ "type": "range", "id": "padding_top", "min": 0, "max": 100, "step": 4, "unit": "px", "label": "t:sections.all.padding.padding_top", "default": 36 },
{ "type": "range", "id": "padding_bottom", "min": 0, "max": 100, "step": 4, "unit": "px", "label": "t:sections.all.padding.padding_bottom", "default": 36 }
],
"presets": [{ "name": "t:sections.countdown-timer.presets.name" }]
}
{% endschema %}
三种状态由服务端决定:
missing:没有截止时间,或字符串经date: '%s'得不到有效时间戳。前台不输出任何内容;只在request.design_mode下显示一行提示,告诉商家去哪里填。这对应通用契约里「数据缺失时隐藏倒计时」,同时沿用 Dawn 用request.design_mode区分编辑器的做法(主题编辑器适配)。ended:渲染时刻已经晚于截止时刻。数字骨架带hidden输出,直接显示「已结束」。因为'now'可能是缓存时间,这个判断只会偏保守:缓存页面渲染得早,服务端判断为未结束时,由浏览器脚本补判;反过来不会发生,所以不会出现「已结束又重新开始」。running:输出带时区的静态截止文案(UTC),<time datetime>承载机器可读值,再输出四个值为--的占位。没有 JavaScript 时访客看到的就是这一行绝对时间,没有假数字。
show_hide_button 产生的按钮带 hidden 输出,由脚本在确认能运行后再显示,避免无 JS 时出现没有作用的按钮。aria-hidden="true" 加在数字区上,让每分钟或每秒变化的数字不进入辅助技术的读取顺序;对读屏用户有意义的信息是上一行的绝对时间。
自定义元素
if (!customElements.get('countdown-timer')) {
customElements.define(
'countdown-timer',
class CountdownTimer extends HTMLElement {
static STORAGE_KEY = 'countdown-timer-hidden';
connectedCallback() {
this.endsAt = Date.parse(this.dataset.endsAt);
this.clock = this.querySelector('[data-clock]');
this.values = Object.fromEntries(
Array.from(this.querySelectorAll('[data-unit]')).map((element) => [element.dataset.unit, element])
);
this.secondsWrapper = this.querySelector('[data-unit-wrapper="seconds"]');
this.endedMessage = this.querySelector('[data-ended]');
this.status = this.querySelector('[data-status]');
this.hideButton = this.querySelector('[data-hide]');
this.localTime = this.querySelector('[data-ends-at-local]');
this.skew = 0;
this.announcedHour = false;
if (Number.isNaN(this.endsAt) || this.dataset.state === 'ended') {
this.finish(false);
return;
}
this.renderLocalTime();
this.hideButton?.addEventListener('click', this.onHide.bind(this));
if (this.hideButton && this.readHiddenPreference()) {
this.hideClock();
} else {
this.hideButton?.removeAttribute('hidden');
}
this.estimateSkew().finally(() => {
if (!this.isConnected) return;
this.tick();
});
}
disconnectedCallback() {
clearTimeout(this.timer);
}
async estimateSkew() {
try {
const sentAt = Date.now();
const response = await fetch(window.location.href, { method: 'HEAD', cache: 'no-store' });
const serverDate = Date.parse(response.headers.get('date'));
if (Number.isNaN(serverDate)) return;
const roundTrip = Date.now() - sentAt;
this.skew = serverDate + roundTrip / 2 - Date.now();
} catch {
this.skew = 0;
}
}
remainingMs() {
return this.endsAt - (Date.now() + this.skew);
}
tick() {
const remaining = this.remainingMs();
if (remaining <= 0) {
this.finish(true);
return;
}
const totalSeconds = Math.floor(remaining / 1000);
const parts = {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60,
};
const underOneHour = remaining < 3600 * 1000;
Object.entries(parts).forEach(([unit, value]) => {
const element = this.values[unit];
if (element) element.textContent = String(value).padStart(2, '0');
});
this.secondsWrapper?.toggleAttribute('hidden', !underOneHour);
if (underOneHour && !this.announcedHour) {
this.announcedHour = true;
this.announce(window.countdownStrings.lessThanOneHour);
}
const interval = underOneHour ? 1000 : 60 * 1000;
this.timer = setTimeout(this.tick.bind(this), interval - (remaining % interval || interval));
}
finish(announce) {
clearTimeout(this.timer);
this.dataset.state = 'ended';
this.clock?.setAttribute('hidden', '');
this.hideButton?.setAttribute('hidden', '');
this.endedMessage?.removeAttribute('hidden');
if (announce) this.announce(window.countdownStrings.ended);
}
announce(message) {
if (!this.status || !message) return;
this.status.textContent = '';
requestAnimationFrame(() => {
this.status.textContent = message;
});
}
renderLocalTime() {
if (!this.localTime || typeof Intl === 'undefined') return;
try {
const formatter = new Intl.DateTimeFormat(document.documentElement.lang || undefined, {
dateStyle: 'medium',
timeStyle: 'short',
timeZoneName: 'short',
});
this.localTime.textContent = ` (${formatter.format(new Date(this.endsAt))})`;
this.localTime.removeAttribute('hidden');
} catch {
// keep the server-rendered UTC text only
}
}
onHide() {
this.hideClock();
try {
sessionStorage.setItem(CountdownTimer.STORAGE_KEY, '1');
} catch {
// storage unavailable; hide for this page only
}
}
hideClock() {
this.clock?.setAttribute('hidden', '');
this.hideButton?.setAttribute('hidden', '');
clearTimeout(this.timer);
this.timer = setTimeout(this.tick.bind(this), 60 * 1000);
}
readHiddenPreference() {
try {
return sessionStorage.getItem(CountdownTimer.STORAGE_KEY) === '1';
} catch {
return false;
}
}
}
);
}
逐项说明它为什么这样写:
- 不以访客时钟为真值。
estimateSkew对当前页发一个HEAD且cache: 'no-store'的请求,用响应的Date头减去往返时间的一半,估出本机时钟与服务端的偏移;之后所有剩余时长都加上这个偏移。这是 Countdown Timer 里的设计建议,未在 Shopify 店面上验证:HEAD是否被店面与 CDN 正常响应、Date头是否可读,都要在店铺里实测。请求失败时偏移取 0,退回本机时钟,这是本实现里明确接受的残余风险。 - 不逐秒更新,也不逐秒朗读。 距离截止超过一小时时每分钟更新一次并隐藏秒位;最后一小时才每秒更新。
setTimeout的延时对齐到整分或整秒,避免累计漂移。role="status"的区域只在两个节点写入文案:进入最后一小时,以及到期;其余时间保持空白。Dawn 自己对价格块、筛选结果数也是用role="status"做一次性播报(无障碍写法)。 - 可以停止。 WCAG 2.2.2 要求自动更新的内容可以暂停、停止或隐藏。「隐藏倒计时」按钮把数字区藏起来,绝对截止时间仍然可见;选择记在
sessionStorage,同一会话内不再弹回。隐藏后脚本仍每分钟检查一次是否到期,到期时同样切换到「已结束」。 - 到期即收尾,不重置。
finish清掉定时器、隐藏数字与按钮、显示「已结束」,并把data-state改为ended。没有任何路径会在到期后重新开始计时;刷新后服务端会按缓存时间或浏览器会按真实时间再次得到ended。折扣本身是否失效由平台执行,倒计时不参与。 - 编辑器里不泄漏定时器。 主题编辑器在分区重渲染时先触发
shopify:section:unload再插入新 HTML,官方文档要求此时清理监听与变量。自定义元素被移出文档就会触发disconnectedCallback,这里只需要clearTimeout;Dawn 的cart.js、product-info.js等也是在这个回调里做清理,因此不需要再往theme-editor.js里加事件处理。estimateSkew回来时先检查isConnected,避免给已经卸载的元素起定时器。 - 减少动态。 数字只做文本替换,没有翻页或缩放动画;CSS 里唯一的透明度过渡放在
prefers-reduced-motion: no-preference下,与 base.css 处理动效的方式一致,所以脚本里不需要再读matchMedia。 - 本地时间只是补充。
renderLocalTime用Intl.DateTimeFormat按页面lang把截止时刻格式化成访客时区并带时区缩写,填进服务端留下的<time>;失败时保留 UTC 文案。页面lang来自 theme.liquid 的request.locale.iso_code。
样式与文案
.countdown-timer {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
text-align: center;
}
.countdown-timer__heading,
.countdown-timer__ends-at,
.countdown-timer__ended {
margin: 0;
}
.countdown-timer__clock {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
}
.countdown-timer__unit {
display: flex;
flex-direction: column;
align-items: center;
min-width: 6rem;
}
.countdown-timer__value {
margin: 0;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: no-preference) {
.countdown-timer__value {
transition: opacity var(--duration-short) ease;
}
}
.countdown-timer__hide {
background: none;
border: 0;
cursor: pointer;
font-size: 1.2rem;
}
tabular-nums 让数字等宽,更新时不会左右跳动。h2、caption-with-letter-spacing、link link--text、visually-hidden、page-width、color-* 与 gradient 都是 Dawn 既有类,不需要新写。
locales/en.default.json 在 sections 下追加:
"countdown_timer": {
"days": "Days",
"hours": "Hours",
"minutes": "Minutes",
"seconds": "Seconds",
"ends_at_html": "Ends {{ ends_at }}",
"ended": "This offer has ended.",
"less_than_one_hour": "Less than one hour left.",
"hide": "Hide countdown",
"missing_end_time": "Countdown: set the shop metafield promo.ends_at or the fallback end time to show this section."
}
locales/en.default.schema.json 在 sections 下追加 countdown-timer 节点,含 name、settings.paragraph.content(写明值必须等于后台折扣的结束时间)、settings.ends_at_fallback.label / .info(只在元字段为空时使用,ISO 8601 UTC 示例)、settings.heading.label / .default、settings.show_hide_button.label / .info 与 presets.name。其他语言文件没有同步追加,Dawn 的 .theme-check.yml 关闭了 MatchingTranslations,缺失键会回退到默认语言;正式上线前应补齐店铺启用的语言。
layout/theme.liquid 在 window.accessibilityStrings 之前追加:
window.countdownStrings = {
ended: `{{ 'sections.countdown_timer.ended' | t }}`,
lessThanOneHour: `{{ 'sections.countdown_timer.less_than_one_hour' | t }}`,
};
对照通用契约
| Countdown Timer 的要求 | 本实现 | 状态 |
|---|---|---|
| 同一时间源 | 读 shop.metafields.promo.ends_at;文本设置只作回退 | 部分:元字段与折扣之间的同步要由应用或 Flow 完成,本文未实现 |
| 不用客户端时钟作真值 | HEAD 请求的 Date 头估计偏移 | 设计已落实,店面实测未做 |
| 不循环重置 | 只有绝对截止时刻,到期进入 ended 且无重启路径 | 已落实 |
| 绝对时间始终可读、机器可读 | UTC 文案 + <time datetime> + 访客时区文本 | 已落实 |
| 只服务一个活动 | 一个 shop 级元字段只能存一个时刻 | 已落实;多活动场景不支持 |
| 不逐秒朗读 | 数字区 aria-hidden,role="status" 仅两次写入 | 已落实,读屏实测未做 |
| WCAG 2.2.2 可暂停 / 停止 / 隐藏 | 「隐藏倒计时」按钮,会话内记忆 | 已落实;「必要」例外是否成立未判断 |
| WCAG 2.2.1 时限 | 本分区只用于营销截止,不限制访客操作 | 是否适用需无障碍确认 |
| 减少动态 | 无动画;唯一过渡在 no-preference 下 | 已落实 |
| 已结束 | 服务端与浏览器两处判定;撤下数字与按钮 | 已落实;折扣失效与缓存页撤下由平台和商家流程负责 |
| 无 JavaScript | 服务端输出绝对截止时间,按钮默认隐藏 | 已落实 |
| 时钟偏差过大或数据缺失 | 缺失则不输出;偏移估计失败则退回本机时钟 | 部分:无法检测的本机时钟错误仍会显示错误数字 |
安装与配置
- 把三个新文件放进
sections/与assets/,追加两个语言文件与theme.liquid的改动。 - 为店铺创建 shop 级元字段定义
promo.ends_at,类型date_time;由应用在折扣创建或修改时写入endsAt,或在没有应用时手动填写(手动填写就失去了同源保证,属于退而求其次)。后台创建 shop 级定义的具体路径本文未逐步核验。 - 在主题编辑器里添加「Countdown timer」分区。没有元字段也没有回退值时,编辑器内只会看到一行提示,前台不输出。
- 后台折扣的结束时间必须与元字段一致;按 Shopify 折扣 与促销兑现核对到期后的购物车行为。
读源码时发现的问题
- Dawn 的
theme-editor.js只处理轮播、商品弹窗和图片缩放脚本,对分区级定时器没有通用清理机制;任何带setInterval/setTimeout的新分区都得自己在disconnectedCallback里收尾。 announcement-bar.liquid的轮播是主题里唯一按时间自动更新的内容,它用aria-live="polite"并在自动播放时切到off(global.js 的play/pause);倒计时没有沿用这一套,因为每分钟变化的数字即使polite也会打扰读屏用户。
建议的验证范围
本篇只做了静态检查(theme check 与 node --check),以下均未执行:
- 在开发店铺把
date_time元字段分别填为2026-10-31T15:59:00与2026-10-31T15:59:00Z,核对服务端ended判断与前台静态文案的时区。 - 在店面对当前页发
HEAD请求,确认有Date响应头且不被缓存层改写;故意调错本机时钟,核对数字按服务端时间显示。 - 在主题编辑器里反复修改分区设置、拖动顺序、删除分区,用浏览器性能面板确认没有残留定时器。
- 用屏幕阅读器确认数字区不被读出,进入最后一小时与到期时各只播报一次。
- 开启系统的减少动态设置,确认数字只做文本替换。
- 禁用 JavaScript,确认只显示绝对截止时间,没有占位数字与按钮。
- 把折扣结束时间与元字段故意设成不同值,确认流程上能发现并纠正;这是同源要求的最后一道人工检查。
- 同页放两个分区实例,确认各自独立计时、隐藏按钮互不影响。