EN
Shopify 知识库 · 指南

Dawn 的组件组织:Custom Elements 加 25 行 pubsub

基于 Dawn v15.3.0 源码,分析不使用前端框架时 Dawn 如何用轻 DOM 自定义元素承载服务端 HTML、用 25 行发布订阅连接商品页与购物车,以及 Promise.all 等待订阅者、生命周期退订的写法和一处订阅泄漏、异常中断渲染的风险。
历史资料
请结合文中的适用版本和来源阅读。

本文基于 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();
  }
}

(为节省篇幅合并了部分换行,逻辑未改。)三处设计值得注意:

  1. subscribe 返回退订函数。调用方不必保存回调引用,只需保存返回值。
  2. 订阅列表不可变。增删都生成新数组,publish 遍历的是调用当时的数组;回调里退订或新增订阅,不影响本轮通知。
  3. 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-changeVariantSelectsProductInfo
variant-changeProductInfoprice-per-item、recipient-form
quantity-updateProductInfo.setQuantityBoundriesQuantityInput
cart-updateproduct-form、CartItems、quick-add-bulk、quick-order-listCartItems、ProductInfo、price-per-item、recipient-form、quick-add-bulk、quick-order-list
cart-errorproduct-formrecipient-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 都是全局变量,没有命名空间,第三方脚本可以覆盖。

迁移到自己主题时

  1. 保留“订阅返回退订函数”和“生命周期内订阅、退订”这两条约定。
  2. 在 publish 内逐个 try/catch 回调,或改用 Promise.allSettled,避免一个订阅者拖垮发布者。
  3. 统一事件载荷结构,并在常量文件里同时写下每个事件的字段说明。
  4. 一个组件持有多个订阅时,用数组保存退订函数,在 disconnectedCallback 中统一调用,避免字段名写错导致的泄漏。

建议的验证范围

本篇均未执行:在含礼品卡收件人表单的商品页,于主题编辑器里反复重载商品分区,统计 subscribers['cart-update'].length 的变化;人为让一个 cart-update 订阅者抛错,确认抽屉是否打开。

源码基线