Horizon 的分区刷新与 morph:SectionRenderer 的并发控制和 DOM 合并
本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。
筛选商品、切换变体、修改购物车数量,Horizon 的做法都一样:请求 Section Rendering API 拿到新的 section HTML,再用 morph 把差异应用到现有 DOM 上。没有客户端模板,也没有 JSON 到 DOM 的映射代码。涉及两个文件:
- assets/section-renderer.js:请求、并发与缓存
- assets/morph.js:DOM 差异合并
各功能怎样调用这条链路,见筛选与分页、变体选择与购物车;Dawn 的同类做法见 Dawn 的分区渲染。本文只看这两个文件本身。
为什么不直接 innerHTML
innerHTML 替换会丢失焦点、输入框里正在输入的值、<details> 的展开状态、已初始化的自定义元素实例及其事件监听,还会重新请求图片。morph 的目标是只改变化的部分,其余节点原样保留。
SectionRenderer:一个 section 同时只有一个渲染
sectionRenderer.renderSection(sectionId, { cache, mode, url, shouldRender, injectStylesheet })
1. 新请求取消旧请求
每个 sectionId 对应一个 AbortController。新的 renderSection 进来时,先 abort 同一 section 的上一次请求;morph 之前还会再检查一次 signal.aborted。用户快速连点筛选项时,只有最后一次的结果会被应用,不会出现"旧响应晚到、覆盖新结果"。
被取消的调用者通常也不会拿到空结果。#renderSection 的 catch 分支里,如果发现同一 section 已经有更新的 pending render,就直接返回那个 promise:
if (abortController.signal.aborted) {
const pendingRender = this.#pendingRendersBySectionId.get(sectionId);
if (pendingRender && pendingRender.abortController !== abortController) {
return pendingRender.promise;
}
return sectionHTML;
}
于是,被新请求顶掉的 await renderSection() 调用方拿到的是最新一次渲染的 HTML,不需要关心自己是否被取消了。只有通过 abortRender 单纯取消、没有后继渲染时,才返回空字符串。
2. 稳定的缓存键
url.searchParams.set('section_id', normalizeSectionId(sectionId));
url.searchParams.sort();
sort() 让 ?a=1&b=2 和 ?b=2&a=1 得到同一个缓存键。筛选参数的顺序取决于用户点击顺序,这个小细节能让同一组条件命中同一条缓存。
3. 首屏 HTML 预存为缓存
window.addEventListener('load', this.#cachePageSections.bind(this));
页面加载完后,把每个 .shopify-section 的 outerHTML 存进缓存,键就是当前 URL 对应的分区渲染地址。用户筛选后再回到初始 URL 时,可以直接命中缓存,不发请求。包含 Shadow Root 的 section 不缓存,因为 outerHTML 不会序列化 Shadow DOM。
renderSection 的 cache 默认值是 !Shopify.designMode:在主题编辑器里默认不用缓存,避免商家改了设置却看到旧内容。
4. 请求去重(有限)
getSectionHTML 用 #pendingPromises 合并同一 URL 的并发请求,但只有没传 signal 的调用才会把自己登记进去。renderSection 总是传 signal,所以它自己的请求不会被别人复用;反过来,它在发请求前会先查 #pendingPromises,遇到同一 URL 已有未传 signal 的请求在途时,直接复用那个 promise。
典型受益者是筛选项的预取:facets.js 在 pointerenter 防抖 200ms 后、或 pointerdown 时立即调用 getSectionHTML 预取(不传 signal)。用户真正点下去时,响应要么已经进了缓存,要么还在途、被 renderSection 直接接上。代价是:接上的这个预取请求不受 abort 控制,只是结果到达后不会被 morph。
morph:揉合而不是替换
morph 的主体是常见的子节点对比:按 tagName 和 key(默认用 id)判断两个节点是否"相同";相同就递归更新,不同就在后续兄弟节点里找匹配项,再决定移动、插入还是替换。这部分和 morphdom、nanomorph 类似,不展开。值得写的是它针对电商主题做的特化。
1. 子树相同则整棵跳过,但要补表单状态
if (oldNode.isEqualNode(newNode)) {
syncFormControlsInSubtree(newEl, oldEl);
return oldNode;
}
isEqualNode 能让大部分没有变化的子树直接跳过。但它比较的是序列化的标记:服务端两次都输出 value="1",用户在输入框里改成了 3,两棵树仍被判为"相等"。所以跳过之前,要再遍历一遍子树里的 input、option、textarea,同步 value、checked、selected 等实时状态。
源码注释把这一点讲得很清楚:结构相同就意味着控件按下标一一对应,可以直接按索引配对。
2. "用户正在编辑"保护
购物车里,用户正在输入数量的同时,另一个请求返回、触发了 cart-items 的 morph。如果直接覆盖,输入到一半的数字就没了。
Horizon 用一个只在客户端存在的标记 data-skip-value-update 解决,但判断条件不只是"有没有标记":
function skipsValueUpdate(newNode, oldNode) {
return (
oldNode.matches(':focus') &&
!newNode.matches(':disabled') &&
newNode.hasAttribute('data-skip-value-update') &&
oldNode.hasAttribute('data-skip-value-update')
);
}
为什么还要求 :focus?注释里有一段完整的推理:服务端可能把这个输入框渲染成 disabled;disabled 被复制过去会让输入框失焦,blur 处理器随即清除标记;但 onBeforeUpdate 已经先把标记保留到了新节点上,同一轮 copyAttributes 又把它写回来。只看标记的话,它永远清不掉,输入框从此冻结。以实时焦点作为"正在编辑"的权威信号,就把这种泄漏从永久故障降级为无害。
这段注释值得原文细读,它示范了怎样为一个状态标记写清楚生命周期。
还有一个更隐蔽的点:输入框的 dirty value flag 为 false 时,value attribute 的变化会直接反映到 value property,也就是用户看到的值上。注释指出 focus 加 select() 并不会把这个 flag 置为 true,所以 copyAttributes 在复制或删除 value attribute 时也做了同样的跳过。
3. 保留运行时状态
合并之前,morph 把旧节点上"服务端不知道"的状态抄到新节点上,分在两处:
MORPH_OPTIONS.onBeforeUpdate:
- 一份名单内的属性:
product-grid-view、data-current-checked、data-previous-checked、cart-summary-sticky,以及上面的data-skip-value-update; floating-panel-component与fieldset.variant-option上运行时写入的 inlinestyle;- 临时的
style.viewTransitionName。
updateNode(仅在新旧属性不同时):
<details>、<dialog>的open,除非新节点带declarative-open,由服务端显式声明;slot和sizes:slot由 overflow-list.js 在运行时分配,sizes会被 results-list.js 按布局改写。
另外两条和性能相关:
src、href、srcset、poster的值不变时不重写,避免触发重复的网络请求;attributesEqual按位置浅比较属性。同一模板的重新渲染,属性顺序一致;顺序不同只会导致"误判不等"、多做一次复制,不会"误判相等"。注释特意说明了这种不对称的安全性。
4. 三个跳过开关
| 属性 | 效果 |
|---|---|
data-skip-node-update | 新旧节点都带时,不改这个节点本身,但递归处理子节点 |
data-skip-subtree-update | 新旧节点都带时,节点自身属性照常更新,子节点不动 |
data-skip-value-update | 只保留编辑中输入框的值,其余照常更新;生效条件见上文 |
前两个要求新旧节点同时带有标记,意味着服务端模板必须显式输出它,是一种"服务端声明哪些区域归客户端管"的契约。data-skip-value-update 相反,只由客户端设置,靠 onBeforeUpdate 抄到新节点上。
5. 平台特例
<shopify-accelerated-checkout-cart>直接跳过:快捷支付按钮由 Shopify 自己的脚本管理;- 主题编辑器里分区渲染注入的
<!--shopify:rendered_by_section_api-->注释会被丢弃; - 已经 attach 过 Shadow Root 的组件,新 HTML 里的
<template shadowrootmode="open">会被拒绝; - morph 结束后,重建
.shopify-app-block里带src的<script>。注释说明:浏览器不会重新执行被 morph 过的脚本,而 App 脚本往往在初始化时缓存了 DOM 引用。
值得商榷的地方
- 通用 morph 里写死了业务组件。
onBeforeUpdate里有FLOATING-PANEL-COMPONENT、.variant-option和一份保留属性名单。每加一个需要保留运行时状态的组件,都要回来改 morph 的默认配置。更好的做法是让组件自己声明需要保留的属性,比如统一的data-preserve-attrs约定。 #cachePageSections遇到条件就整体return。循环里遇到已缓存或含 Shadow Root 的 section,用的是return而不是continue,后面的 section 都不会被缓存。从函数意图看,这更像疏漏而非设计,但源码没有说明。onAfterUpdate收到的是新树节点。walk调用onAfterUpdate(newNode),而分区刷新的新树来自DOMParser,其中的自定义元素不会升级,updatedCallback因此很可能不会触发,详见 Web Components 基类。未在浏览器中验证。- morph 是全量对比。大列表重新渲染时,即使只改了一个商品,也要走完整个子树(命中
isEqualNode的部分除外)。对主题的数据规模来说够用,但不适合超长列表。
小结
这套方案的本质是 "服务端是唯一模板,客户端只做差异合并":
- Liquid 只写一遍,没有前后端双份模板;
- 商家在主题编辑器里改的设置,局部刷新后自动生效;
- 代价是每次交互都要一次网络往返,前端也需要一个足够聪明、能保留运行时状态的 morph。
Horizon 在 morph 上投入的大量边界处理(表单状态、焦点、open、slot、App 脚本)说明:这条路能走通,但"聪明的 morph"本身就是一个需要持续维护的子系统。建立在 morph 之上的另一种模式见带键的延迟水合。
Horizon 的 LICENSE.md 禁止分发基于其代码的衍生主题,本文只做源码分析,借鉴思路请自行实现。