EN
Shopify 知识库 · 指南

Horizon 的 Web Components 基类:ref 收集、on: 事件委托与升级竞态

基于 Horizon 4.2.0 源码,拆解不依赖框架的 Component 基类:ref 自动收集与归属判断、on: 属性的全局事件委托、自定义元素升级竞态、声明式 Shadow DOM 补水,以及 updatedCallback 与 morph 的配合和其中的疑点。
历史资料
请结合文中的适用版本和来源阅读。

本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。

Horizon 的前端没有用 Alpine、Stimulus 或任何框架,仓库里也没有构建配置。交互组件都继承同一个基类 Component,定义在 assets/component.js,整个文件 392 行。

它解决什么问题

Liquid 在服务端输出 HTML,JS 只负责"认领"已有的 DOM 并挂上行为。这类场景下,虚拟 DOM、响应式渲染等框架能力用不上,真正需要的只有三件事:

  1. 方便地拿到子元素引用;
  2. 把 DOM 事件绑定到组件方法上;
  3. DOM 被服务端重新渲染替换后,上面两件事仍然成立。

Component 就是这三件事的最小实现。

一、ref:声明式子元素引用

snippets/quantity-selector.liquid 的写法,简化后如下:

<quantity-selector-component>
  <button ref="minusButton" on:click="/decreaseQuantity">-</button>
  <input ref="quantityInput">
  <button ref="plusButton" on:click="/increaseQuantity">+</button>
</quantity-selector-component>

JS 里直接用 this.refs.quantityInput。属性值以 [] 结尾(如 ref="quantitySelectors[]")时,收集为数组。

实现上有几个值得注意的点:

  • 归属判断:收集 [ref] 时,只保留"最近的 Component 祖先就是自己"的元素,组件嵌套时内层组件的 ref 不会泄漏到外层。判断函数 getClosestComponent 借助 getAncestor 在遇到 Shadow Root 时跳到宿主元素继续向上找。
  • 按约定识别未升级组件:标签名以 -component 结尾的元素,即使 JS 还没执行,也被视为组件边界。这样,模块加载顺序不会影响 ref 的归属。
  • requiredRefs 快速失败:子类声明 requiredRefs = ['quantityInput', 'minusButton', 'plusButton'](component-quantity-selector.js 就是这样写的),缺失时直接抛出 MissingRefError,错误信息带组件标签名,不会等到运行时才报 undefined。
  • 延迟挂观察器:MutationObserver 在 requestIdleCallback 里才开始观察,首屏不承担这部分开销。观察范围只限 ref 属性和子节点增删。

二、on:事件:全局事件委托

<button on:click="/decreaseQuantity">
<button on:click="#cart-drawer/close">
<img on:click="#zoom-dialog-{{ block_id }}/open/{{ forloop.index0 }}">
<button on:click="/removeFilter?form=">

属性值的语法是 [选择器]/方法名[/数据 或 ?查询串]:

写法目标组件方法参数
/close最近的 Component 祖先(event)
#cart-drawer/closedocument.querySelector('#cart-drawer')(event)
.card/selectelement.closest('.card')(event)
/open/3最近的祖先(3, event),数字自动转换
/remove?form=a&x=true最近的祖先({form:'a', x:true}, event)

实现方式是:整个 document 只注册一次监听,每种事件类型一个捕获阶段监听器。事件到达时,先看事件目标自身是否带 on:<类型>,否则用 closest('[on\\:click]') 向上找声明元素,再解析属性值、定位组件、调用方法。

两个细节值得学:

用 Proxy 改写 event.target。 用户点的可能是按钮里的 <svg>,但方法里通常希望 event.target 就是声明了 on:click 的那个按钮。当两者不同时,Horizon 不新建事件对象,而是包一层 Proxy,只拦截 target,其余属性透传。方法类属性(preventDefault 等)会 bind 回原事件,否则调用时 this 会指向 Proxy 并报 Illegal invocation:

new Proxy(event, {
  get(target, property) {
    if (property === 'target') return element;
    const value = Reflect.get(target, property);
    return typeof value === 'function' ? value.bind(target) : value;
  },
});

区分冒泡和不冒泡的事件。 focus、blur 不冒泡,但在捕获阶段仍能在 document 上收到,所以单独列入 shouldBubble,照样用 closest 向上查找。pointerenter、pointerleave 被列为 expensiveEvents:只匹配事件目标本身,不做 closest 查找,避免指针移动时频繁遍历 DOM。

三、自定义元素升级竞态

页面上 <cart-drawer-component> 已经存在,但定义它的模块还没执行完,此时用户点击了按钮。instance instanceof Component 为 false,事件就被静默丢掉了,网络慢的用户会遇到"点了没反应"。

Horizon 的处理:

if (
  !(instance instanceof Component) &&
  instance instanceof HTMLElement &&
  instance.tagName.toLowerCase().endsWith('-component')
) {
  customElements.upgrade(instance);
  if (!(instance instanceof Component)) {
    console.warn(`[component] Dropped "${event.type}" on <...> — element not yet upgraded`);
    return;
  }
}

源码注释说明 customElements.upgrade 是同步、幂等的:类已注册但元素还没升级时立即升级;元素已升级或类还没注册时什么也不做。如果仍然失败,就打一条 warn,不静默吞掉。注释还提到,这类静默失败是他们近期 Playwright 测试不稳定的主要来源。

事件委托加模块异步加载的架构,都要考虑"DOM 先到、行为后到"这个窗口期。

四、声明式 Shadow DOM 的补水

Component 继承自同一文件里的 DeclarativeShadowElement:

connectedCallback() {
  if (!this.shadowRoot) {
    const template = this.querySelector(':scope > template[shadowrootmode="open"]');
    if (!(template instanceof HTMLTemplateElement)) return;
    this.attachShadow({ mode: 'open' }).append(template.content.cloneNode(true));
  }
}

<template shadowrootmode="open"> 只在 HTML 首次解析时由浏览器转换成 Shadow Root(文件头注释也这样说明)。通过 innerHTML、DOMParser 或 morph 插入的组件不会自动转换,必须手动 attach。Horizon 大量依赖 Section Rendering API 局部刷新,这一步不可或缺。overflow-list.js 里的 OverflowList 直接继承 DeclarativeShadowElement,就靠它工作。

五、与 morph 配合:updatedCallback

Horizon 用自研的 morph 做局部更新(见 分区刷新与 morph)。按设计,morph 修改组件子树后,会在微任务里调用组件的 updatedCallback():

updatedCallback() {
  this.#mutationObserver.takeRecords(); // 丢弃积压的 mutation,避免重复刷新
  this.#updateRefs();
}

morph 判断子树没有变化(isEqualNode)时整棵跳过,updatedCallback 也不会触发。这是有意为之:子树没变,ref 自然也不需要重建。

但按静态阅读,这个回调在分区刷新路径上可能根本不会被调用。morph.js 的 walk 最后执行的是 options.onAfterUpdate?.(newNode),传入的是新树里的节点;MORPH_OPTIONS.onAfterUpdate 只在 node instanceof Component 时排队调用 updatedCallback。而 section-renderer.js 的 morphSection 用 DOMParser 解析响应,按 HTML 规范,这种没有浏览上下文的文档里不会升级自定义元素,新树节点不是 Component 实例。ref 的同步在多数情况下仍由 MutationObserver 兜住(子节点增删会触发 #updateRefs),受影响的主要是覆写了 updatedCallback 的子类,如 results-list.js、disclosures-summary-fit.js。这一点未在浏览器中验证。

值得商榷的地方

  • 字符串 DSL 没有类型检查。on:click="/decreaseQuantity" 拼错了方法名,只会静默不执行(typeof callback === 'function' 为 false 时直接跳过),连 warn 都没有。前面费心处理了升级竞态,这里却没提示,标准不一致。
  • 约定大于配置的代价。"-component 结尾的标签就是组件边界"是隐式约定,不读源码很难知道。
  • 全局监听的事件是写死的 13 种(11 种常规事件加 pointerenter、pointerleave)。想用 dblclick、scroll 等,得改基类。

小结

Component 的设计取舍很清楚:HTML 是唯一的状态来源,JS 只做认领和绑定。它放弃了响应式,换来零依赖、零构建,以及与服务端整段重新渲染的天然兼容。

对 Liquid 主题来说,这比引入 Alpine 后再处理"服务端 HTML 替换导致 Alpine 状态丢失"更省事。代价是:一旦需要客户端状态驱动 UI,就只能手写 DOM 操作。

Horizon 的 LICENSE.md 禁止通过 Theme Store 或任何其他渠道分发基于其代码的衍生主题,只允许在服务项目中直接交付给商家自用。本文只做源码分析,借鉴思路请自行实现。

源码基线