Dawn 的组件组织:Custom Elements 加 25 行 pubsub
本文基于 Dawn v15.3.0,源码固定到 ecb06c3(核验于 2026-10-01)。结论限于此提交;本篇为静态源码分析,未完成店铺运行验证。Dawn 上游已发布 v16.0.0,本库 Dawn 正文统一以 v15.3.0 为基线,升级前需重新核对。
Dawn 没有引入任何前端框架或构建步骤。assets/ 里的脚本由布局和分区直接以 <script defer> 引用(theme.liquid#L31-L40),通过全局函数和自定义元素协作。这套组织方式由两部分组成:自定义元素负责“一块界面的行为”,pubsub 负责“几块界面之间的通知”。
自定义元素:给服务端 HTML 加行为
Dawn 的组件全部使用轻 DOM(light DOM),即不使用 Shadow DOM。服务端渲染的 HTML 就是组件的内容,组件只在其中查找子元素并绑定事件,例如 ProductInfo 在构造函数里 querySelector('.quantity__input')(product-info.js#L14-L18)。这样做有三个直接好处:
- 渐进增强。脚本是
defer的,元素升级之前,服务端输出的链接、表单和<details>已经可以使用。 - 样式统一。主题 CSS 可以直接作用到组件内部,不需要穿透 shadow root。
- 局部替换后自动初始化。服务端返回的新 HTML 插入页面后,浏览器自动升级其中的自定义元素,不需要手动调用初始化函数。这正是 Section Rendering 能成立的前提。
配置通过 data-* 属性从 Liquid 传入:data-section、data-url、data-update-url(main-product.liquid#L1-L7)。组件之间通过标签名查找对方,例如 ProductInfo 的 get productForm() 返回 this.querySelector('product-form')(#L379-L381)。
防重复注册不统一。 一部分文件用 if (!customElements.get('...')) 包住 define(如 product-info.js、product-form.js、quick-add.js),另一部分直接 define(如 global.js、cart-drawer.js、facets.js)。前者可以被多个分区重复引用,后者如果被加载两次会抛出异常。需要被局部替换工具重建的脚本都属于前者。
25 行的 pubsub
pubsub.js 全文:
let subscribers = {};
function subscribe(eventName, callback) {
if (subscribers[eventName] === undefined) subscribers[eventName] = [];
subscribers[eventName] = [...subscribers[eventName], callback];
return function unsubscribe() {
subscribers[eventName] = subscribers[eventName].filter((cb) => cb !== callback);
};
}
function publish(eventName, data) {
if (subscribers[eventName]) {
const promises = subscribers[eventName].map((callback) => callback(data));
return Promise.all(promises);
} else {
return Promise.resolve();
}
}
(为节省篇幅合并了部分换行,逻辑未改。)三处设计值得注意:
subscribe返回退订函数。调用方不必保存回调引用,只需保存返回值。- 订阅列表不可变。增删都生成新数组,
publish遍历的是调用当时的数组;回调里退订或新增订阅,不影响本轮通知。 publish返回Promise.all。订阅者可以返回 Promise,发布者可以等待所有订阅者完成。加购后product-form就用它测量add:wait-for-subscribers(product-form.js#L69-L77),而CartItems的订阅者正是返回了刷新请求的 Promise(cart.js#L30-L37)。
事件名集中在 constants.js,共 5 个:
| 事件 | 发布者 | 订阅者 |
|---|---|---|
option-value-selection-change | VariantSelects | ProductInfo |
variant-change | ProductInfo | price-per-item、recipient-form |
quantity-update | ProductInfo.setQuantityBoundries | QuantityInput |
cart-update | product-form、CartItems、quick-add-bulk、quick-order-list | CartItems、ProductInfo、price-per-item、recipient-form、quick-add-bulk、quick-order-list |
cart-error | product-form | recipient-form |
cart-update 的载荷带 source 字段,订阅者靠它跳过自己发出的事件,例如 CartItems 忽略 source === 'cart-items'(cart.js#L31-L36)。
订阅放在生命周期回调里
订阅写在 connectedCallback,退订写在 disconnectedCallback:ProductInfo(#L20-L51)、QuantityInput(global.js#L230-L239)、CartItems(cart.js#L30-L43)都这样写。这一点在 Shopify 主题里尤其重要:主题编辑器修改设置时会重新渲染整个分区,旧元素被移除、新元素被插入;变体切换和局部刷新同样会替换元素。如果在构造函数里订阅且不退订,每次替换都会多留下一个指向已删除元素的回调。
风险与问题
均为静态阅读,未在浏览器复现:
- 一处订阅泄漏。
recipient-form.js在第 33 行把cart-update的退订函数存进cartUpdateUnsubscriber,第 45 行又把cart-error的退订函数存进同一个字段;cartErrorUnsubscriber声明了但从未赋值。元素移除时,cart-update订阅不会被释放。 - 一个订阅者出错会中断后续流程。
publish用map同步调用回调,回调同步抛错时,后面的订阅者不会执行,publish本身也会抛出。在product-form中,publish(cartUpdate)位于renderContents之前(product-form.js#L69-L97),任何一个订阅者同步抛错,都会跳到catch,抽屉或通知面板将不会打开。异步拒绝则使Promise.all拒绝,而这里的.then没有接catch。 - 载荷结构不统一。 选项和变体事件包在
{ data: {...} }里,购物车事件是扁平的{ source, cartData, ... },订阅者需要记住每个事件的形状。 - 全局作用域。
subscribers、subscribe、publish、PUB_SUB_EVENTS都是全局变量,没有命名空间,第三方脚本可以覆盖。
迁移到自己主题时
- 保留“订阅返回退订函数”和“生命周期内订阅、退订”这两条约定。
- 在
publish内逐个try/catch回调,或改用Promise.allSettled,避免一个订阅者拖垮发布者。 - 统一事件载荷结构,并在常量文件里同时写下每个事件的字段说明。
- 一个组件持有多个订阅时,用数组保存退订函数,在
disconnectedCallback中统一调用,避免字段名写错导致的泄漏。
建议的验证范围
本篇均未执行:在含礼品卡收件人表单的商品页,于主题编辑器里反复重载商品分区,统计 subscribers['cart-update'].length 的变化;人为让一个 cart-update 订阅者抛错,确认抽屉是否打开。