EN
Shopify 知识库 · 指南

Horizon 的布局抖动治理:按需测量、读写分批与帧级调度

基于 Horizon 4.2.0 源码,整理一组避免强制同步布局的做法:首帧前按需测量页眉高度、从 IntersectionObserver 取得尺寸、变体选择器读写分批、跳过首次回调的 ResizeNotifier、等待过渡的 Scheduler、让出主线程,以及用 CSS 动画合成实现飞入购物车弧线。
历史资料
请结合文中的适用版本和来源阅读。

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

布局抖动(layout thrashing)指的是 JS 交替地"写样式、读尺寸",每次读取都迫使浏览器立即重新计算布局。Horizon 没有为此做统一的框架,但在多个文件里能看到一致的处理思路,下面整理成七条。

一、首帧前测量页眉,但只在有人需要时

问题:几条 CSS 规则在首帧就依赖页眉高度,比如按剩余视口定高的 hero、透明页眉的负边距。如果等 header.js 加载后再测量,首帧用的是 sections/header.liquid 里 60px 的兜底值,随后内容会跳一下。

做法:layout/theme.liquid 在 header 分组之后内联了一个 window.measureHeaderHeights 函数,在解析过程中直接测量,把 --header-height 和 --header-group-height 写到 body 上。

但解析中途测量会强制布局,所以这个函数默认不执行,采用 opt-in:

  • 页眉是透明的,所有模板首帧都要用到,全局立即调用;
  • 其他情况下,由真正在首帧依赖这些变量的区块,自己渲染 snippets/measure-header-heights.liquid 来触发。目前有三处:hero 分区;_product-details 区块(仅高度设为 fill 时);商品媒体画廊(仅 constrain_to_viewport 时)。

这个片段还提供了收窄条件的参数,最多传一个:

{% render 'measure-header-heights', only_when_first_section: true %}
{% render 'measure-header-heights', only_on_desktop: true %}

only_when_first_section 检查 document.currentScript 所在的 section 是不是 #MainContent 的第一个子元素,只有排在页面最前面的 hero 才触发测量;only_on_desktop 只在 750px 以上触发。

函数内部用闭包里的 measured 标记保证只测一次。页面加载后,由 header.js 的 ResizeObserver 接手更新(吸顶与高度变量的维护见页眉导航)。

同一段脚本里还有一个对照:--top-row-height 只被页眉内部的底衬和子菜单使用,首帧并不需要。所以它放在 requestAnimationFrame 里测量,并且写在页眉元素上而不是 body 上,注释说明这样写入只会让页眉子树的样式失效。

测量是有成本的,要按"谁需要、何时需要"决定测量时机和变量的作用域。

二、从 IntersectionObserver 取得尺寸

getBoundingClientRect() 会强制同步布局。IntersectionObserver 的回调里自带 entry.boundingClientRect,它是浏览器在自己的渲染流程中算好的,读取不会触发额外的布局。

飞入购物车动画(assets/fly-to-cart.js)需要起点(商品图)和终点(购物车图标)的位置。它没有调用 getBoundingClientRect,而是:

const io = new IntersectionObserver((entries) => {
  // 从 entries 里分别取出 source 和 destination 的 boundingClientRect
  if (sourceRect && destinationRect) this.#animate(sourceRect, destinationRect);
  io.disconnect();
});
io.observe(this.source);
io.observe(this.destination);

observe 之后,浏览器会先回调一次,给出当前的几何信息。用完立即 disconnect。

溢出列表(assets/overflow-list.js)也用了同样的方法:观察列表的首尾元素,在它们接近视口时(rootMargin 上下扩展 640px、左右扩展 360px),把回调给出的条目高度作为首次计算时列表的初始高度。注释原话是 "get their height from the IntersectionObserver for free (without reflows)"。

三、读写分批

assets/variant-picker.js 要给每组选项设置"选中药丸"的宽度变量(--pill-width-current、--pill-width-previous),以实现滑动动画。代码分成 read phase(读 offsetWidth)和 write phase(写 CSS 变量)两个方法,批量调用时先全部读、再全部写:

updateVariantPickerCss() {
  // Batch all reads first across all fieldsets to avoid layout thrashing
  const measurements = fieldsets.map((_, i) => this.#getFieldsetMeasurements(i)).filter(Boolean);
  // Batch all writes after all reads
  for (const m of measurements) this.#applyFieldsetMeasurements(m);
}

如果按"每组读一次、写一次"的顺序执行,N 组选项最多会触发 N 次强制布局;分批之后只需要一次。

四、ResizeNotifier:跳过首次回调

export class ResizeNotifier extends ResizeObserver {
  #initialized = false;
  constructor(callback) {
    super((entries) => {
      if (this.#initialized) return callback(entries, this);
      this.#initialized = true;
    });
  }
}

原生 ResizeObserver 在 observe() 之后会先回调一次,即使元素尺寸没有变化。很多组件在 connectedCallback 里已经完成了初始布局,这第一次回调纯属浪费,而回调里往往又会读取尺寸。ResizeNotifier 只是吞掉第一次回调,名字准确地表达了语义:只在真正变化时通知。disconnect() 时会重置标记,重新 observe 后仍然跳过首次回调。变体选择器与溢出列表都用它。

注意标记是整个观察器共用的,不按元素区分:吞掉第一批回调之后再 observe 的元素,它的首次回调会照常传给组件。

五、帧级调度:合并任务,避开过渡动画

assets/utilities.js 的 Scheduler:

schedule = async (task) => {
  this.#queue.add(task);
  if (!this.#scheduled) {
    this.#scheduled = true;
    if (viewTransition.current) await viewTransition.current; // 等页面内过渡结束
    requestAnimationFrame(this.flush);
  }
};
flush = () => {
  for (const task of this.#queue) setTimeout(task, 0);
  this.#queue.clear();
  this.#scheduled = false;
};

三个细节:

  • 用 Set 作为队列,同一个函数引用在一次调度周期内重复加入只执行一次;
  • 先等 View Transition 结束。viewTransition.current 是页面内过渡的 finished promise(见跨文档 View Transitions 的页面内过渡部分)。过渡期间修改 DOM 会干扰快照,也会和动画争抢主线程;
  • requestAnimationFrame 加 setTimeout(0):先对齐到下一帧,再把任务放到这一帧渲染之后执行。任务里的布局读取发生在浏览器刚完成布局之后,通常不必再重算。

slideshow.js 用它来批量处理初始化时的读写,以及按可见性更新各张幻灯片的 aria-hidden。它同时挂在全局的 Theme.utilities.scheduler 上。

六、让出主线程

export const yieldToMainThread = () => {
  if ('yield' in scheduler) return scheduler.yield();
  return new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));
};

这里的 scheduler 是浏览器的全局对象,不是上一节的 Scheduler。优先使用 scheduler.yield():它让出主线程后,后续代码会优先于同优先级的其他排队任务恢复执行。不支持时,退回到 rAF 加 setTimeout。

典型用法见变体选择器:切换变体后用 history.replaceState 更新 URL,这个操作被放在 yieldToMainThread() 之后。URL 更新不影响用户看到的内容,没必要和点击处理挤在同一个任务里,拆出来有助于缩短这次交互的 INP。

七、飞入动画:用 CSS 合成弧线

飞入购物车的抛物线轨迹不是 JS 逐帧计算出来的。fly-to-cart.js 只写入四个 CSS 变量(起点坐标和位移量,useSourceSize 时另加宽高),动画全部交给 CSS(snippets/fly-to-cart-styles.liquid):

fly-to-cart {
  translate: var(--start-x, 0) var(--start-y, 0);
  animation-name: travel-x, travel-y, travel-scale;
  animation-timing-function: var(--x-timing), var(--y-timing), var(--scale-timing);
  animation-composition: accumulate;
}
@keyframes travel-x { to { translate: var(--travel-x, 0) 0; } }
@keyframes travel-y { to { translate: 0 var(--travel-y, 0); } }

水平和垂直位移是两个独立的动画,各自使用不同的 cubic-bezier,再通过 animation-composition: accumulate 叠加到起始位置上。主图加购时,X 方向的曲线带有一个很大的负值控制点(cubic-bezier(0.7, -5, 0.98, 0.5)),先向反方向回拉,再加速冲向终点;Y 方向的曲线前段上升快、后段放缓。两者叠加起来,就是一条先后撤、再抛向购物车的弧线。主图加购(--main)、快速加购(--quick)、吸底加购(--sticky)三种场景,主要是换缓动曲线和时长。

JS 写完变量后先 yieldToMainThread(),再等待 this.getAnimations() 全部结束后移除元素,不需要设定时器去猜动画时长。

值得商榷的地方

  • calculateHeaderGroupHeight 有两份。内联脚本和 utilities.js 各实现一次,注释写着 "keep them in sync"。和 View Transitions 的检测逻辑一样,这是内联脚本无法 import 模块的代价。
  • 溢出列表的每次 reflow 仍有一次强制布局。IntersectionObserver 只帮它省掉了首次的高度测量。真正的布局计算是:先把"更多"按钮通过 order: -1 移到最前面并开启换行,然后读取它和每一项的 getBoundingClientRect,top 大于"更多"按钮的项就是被挤到了第二行。写在前、读在后,单次 reflow 只强制一次布局,但每次尺寸或子节点变化都要重来一遍。这个思路本身很巧妙:让浏览器的 flex 换行来回答"放不放得下"。
  • overflow-list.js 里定义了 schedule getter,却没有使用它。#handleChange 直接写了 rAF 加 setTimeout。看起来是重构的遗留。
  • 测量片段在顶层用 const 声明变量。only_when_first_section 分支的内联脚本写的是 const section = ...。经典脚本的顶层 const 进入所有脚本共享的全局词法作用域,同一页面出现两个 hero 分区时,第二段脚本会因重复声明而抛错、整段不执行。第二个 hero 本来也不是首个分区,功能上无害,但控制台会多一条错误,也可能与其他脚本的同名全局声明冲突。这是按 JS 语义的静态推断,未在浏览器中复现。

小结

技巧解决的问题
首帧前按需测量首屏内容跳动,同时不让每个页面都承担测量成本
IntersectionObserver 取尺寸一次性取位置时避免强制布局
读写分批批量更新时避免 N 次强制布局
ResizeNotifier去掉无意义的首次回调
Scheduler同周期去重、避开过渡、在布局后执行
yieldToMainThread拆分长任务,改善 INP
CSS 动画合成复杂轨迹不需要逐帧 JS

这些技巧都不新,但 Horizon 把它们用在了合适的位置上,并且大多附带了解释原因的注释,值得对照源码阅读。

Horizon 的 LICENSE.md 禁止分发基于其代码的衍生主题,本文只做源码分析,借鉴思路请自行实现。

源码基线