Horizon 的 Web Components 基类:ref 收集、on: 事件委托与升级竞态
本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。
Horizon 的前端没有用 Alpine、Stimulus 或任何框架,仓库里也没有构建配置。交互组件都继承同一个基类 Component,定义在 assets/component.js,整个文件 392 行。
它解决什么问题
Liquid 在服务端输出 HTML,JS 只负责"认领"已有的 DOM 并挂上行为。这类场景下,虚拟 DOM、响应式渲染等框架能力用不上,真正需要的只有三件事:
- 方便地拿到子元素引用;
- 把 DOM 事件绑定到组件方法上;
- 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/close | document.querySelector('#cart-drawer') | (event) |
.card/select | element.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 或任何其他渠道分发基于其代码的衍生主题,只允许在服务项目中直接交付给商家自用。本文只做源码分析,借鉴思路请自行实现。