Horizon 的带键延迟水合:只更新 data-hydration-key 标记的节点
本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。
分区刷新与 morph 讲了 Horizon 用 Section Rendering API 加 morph 做局部刷新。这一篇讲它在 morph 之上加的一种模式:hydration mode,源码注释里叫 "keyed lazy hydration"。涉及:
- assets/morph.js 中的
morphHydrationByKey - assets/section-hydration.js
- assets/section-renderer.js 中的
mode: 'hydration'
这里的"水合"和 React SSR 的 hydration 不是一回事。React 是给服务端 HTML 挂上客户端状态;Horizon 是页面加载后再向服务端要一次 HTML,只更新其中约定好的几块。
要解决的问题
有些内容放在首屏渲染里不划算,甚至根本拿不到:
- 商品推荐:Liquid 的
recommendations对象只有通过推荐专用的请求渲染时才有数据(recommendations.performed为 true),普通页面渲染时为空; - 离屏的重内容:比如 mega menu 里按集合拉取的精选商品,用户不展开菜单就看不到,却会拖慢每个页面的服务端渲染;
- 状态容器里的内容:购物车抽屉外层的
<dialog>带有打开状态、焦点陷阱等运行时状态,刷新时只想换里面的内容。
直接用 full 模式 morph 整个 section 也可行,但会碰到整个 section 的所有节点:已经初始化的组件、用户的焦点、展开状态,都要靠 morph 的各种保护逻辑去"保住"。hydration mode 换了个思路:只碰标记过的节点,其他一律不看。
契约
源码注释把规则写得很明确,归纳如下:
- 只有带
data-hydration-key="<非空值>"的元素才参与更新; - 新旧 DOM 之间只按 key 的值匹配,没有任何回退匹配,避免误更新到别的节点;
- 只更新已存在的目标:新 HTML 里有、旧 DOM 里没有的 key 会被忽略,不会插入;旧 DOM 里有、新 HTML 里没有的也不会删除;
- 匹配上之后,在目标内部做一次普通 morph,并且强制
childrenOnly: false,目标元素自身的属性也会更新。
function morphHydrationByKey(oldRoot, newRoot, options) {
// 收集 oldRoot(含自身)里所有带 key 的元素,按 key 分组
// 遍历 newRoot 里所有带 key 的元素:
// 在旧分组里取出同 key 的第一个(shift),没有就跳过
// morph(oldTarget, newTarget, { ...options, hydrationMode: false, childrenOnly: false })
}
用 shift() 取出,意味着同一个 key 出现多次时按出现顺序一一配对。
第 3 条是这个设计的核心:页面结构由首屏决定,水合只填内容。这样,挂在外层的组件实例、监听器和焦点都不受影响,目标容器之外的布局也不会变。
三个使用场景
1. 商品推荐:懒加载加水合
sections/product-recommendations.liquid 的根元素带 data-hydration-key="product-recommendations-{{ section.id }}",区块版 blocks/product-recommendations.liquid 则用 block.id。assets/product-recommendations.js 在组件接近视口时自己 fetch 推荐接口,再调用:
this.dataset.recommendationsPerformed = 'true';
morphSection(sectionId, result.data, { mode: 'hydration', injectStylesheet: true });
懒加载条件、缓存、失败与空结果回退,见商品推荐。这里只补一点:injectStylesheet: true 会在响应里查找 <style data-section-stylesheet>,找到就替换或插入到 section 包装元素中。水合模式只处理带 key 的节点,样式标签必须走这条单独的通道。全仓库搜索 data-section-stylesheet,只在 section-renderer.js 中命中,主题的 Liquid 文件里没有输出它的位置,源码也没有说明它的来源。
2. 购物车抽屉:只换内胆
snippets/cart-drawer.liquid 里,抽屉的内层容器带 data-hydration-key="cart-drawer-inner"。assets/component-cart-items.js 刷新购物车时,按当前是否在抽屉里选择模式:
mode: this.isDrawer ? 'hydration' : 'full'
购物车页面用 full,抽屉用 hydration。抽屉外层的 <dialog> 不参与 morph,打开状态与焦点陷阱也就不会被打断。
3. 页眉:空闲时补齐精选内容
sections/header.liquid 末尾,request.design_mode 为假时输出:
import { hydrate } from '@theme/section-hydration';
const url = new URL(window.location.href);
url.searchParams.delete('page');
hydrate('{{ section.id }}', url);
hydrate 等 DOM 就绪后,在 requestIdleCallback 里调用 renderSection(id, { cache: false, url, mode: 'hydration' }),完成后给 section 写上 data-hydrated="true",下次调用时据此跳过。目标在 blocks/_header-menu.liquid 里,共三个:header-menu(桌面菜单)、header-drawer-mobile(移动端抽屉)、header-menu-mobile-navigation-bar(移动端导航条)。
首屏确实刻意少渲染了一部分。snippets/mega-menu-list.liquid 与 snippets/header-drawer.liquid 都以 section.index == blank 判断"本次是分区渲染",只有这时才把 eager_loading 置真并输出精选商品等重内容。注释写明目的是让首次渲染保持轻量、减少对 TTFB 的影响,再由第二次渲染补齐。页眉精选内容的用户可见部分见页眉导航。
脚本里显式删掉了 page 参数,源码没有注释原因。hydrate 传的是 cache: false,所以这与缓存键无关。一个说得通的解释是:mega-menu-list.liquid 用 paginate 输出精选商品,而 paginate 会读取 URL 上的 page,删掉它可以让菜单在分页的集合页上仍取第一页。这是推断,未经实测。
设计上值得学的点
1. "不插入、不删除"让水合变得可预测。 普通 morph 的风险在于你不知道它会动哪些节点。水合模式把影响范围限定在标记过的子树内,审查一个模板时,只看 data-hydration-key 就知道哪些区域会被替换。
2. 匹配只认 key,不做回退。 很多 diff 算法在 key 不匹配时会退化为按位置匹配。Horizon 明确拒绝这么做:宁可不更新,也不更新错。
3. 模式由调用方选择,而不是由模板决定。 同一个购物车模板,页面上用 full,抽屉里用 hydration。模板只负责标注"哪些区域可以被水合",选择权留给场景。
4. 合并入口统一。 三个场景最终都走 morphSection 的 hydration 模式。页眉与购物车通过 renderSection 发请求,共享同分区的取消逻辑;商品推荐则自己 fetch,有实例内的缓存与 abort,只把合并交给 morphSection。
局限
- key 必须是唯一的稳定值。带上
section.id、block.id是常见做法;如果在循环里输出了重复的 key,靠shift()按顺序配对,结果取决于两边的 DOM 顺序是否一致。 - 首屏必须先渲染出目标容器。没有容器,水合时就找不到目标,这部分内容会被直接丢弃,而且没有任何警告。
- 多一次请求。页眉每页都有,意味着每次页面访问都会多一次空闲时的分区请求,用它换首屏少渲染精选商品。是否值得,取决于首屏省下的服务端渲染成本。
- 防重标记写在请求完成后。
data-hydrated在await renderSection()之后才写入,两次hydrate若在完成前先后触发,都会通过检查;同分区的取消逻辑会让前一次让位给后一次。
小结
带键的延迟水合本质上是把一次服务端渲染拆成两段:首屏渲染骨架和必要内容,空闲时或可见时再补齐昂贵或依赖上下文的部分。它的价值不在算法,而在契约:用一个属性声明"这里可以被替换",其余区域一律不动。
在 Liquid 主题里,循环查询集合和商品常常是服务端渲染的大头,Horizon 页眉的注释也把 TTFB 列为动机,这个模式值得借鉴。
Horizon 的 LICENSE.md 禁止分发基于其代码的衍生主题,本文只做源码分析,借鉴思路请自行实现。