EN
Shopify 知识库 · 概念

Impact 的四个行为组件:超高 sticky、滚动阴影、视觉行拆分与可暂停计时器

精读 Impact 7.2.0 中四个小型自定义元素:让比视口还高的 sticky 元素可以完整滚动的 SafeSticky,按剩余滚动距离渐隐的 ScrollShadow,按视觉行拆分文字的 SplitLines,以及标签页切到后台时自动暂停的 Player。
历史资料
请结合文中的适用版本和来源阅读。

本篇基于 Impact 7.2.0,四个组件都在 assets/theme.js 中。它们各自只有几十行,解决的都是常见但容易写错的问题。

SafeSticky:比视口还高的 sticky 元素

SafeSticky 在第 648 行,用于商品信息栏(snippets/product-info.liquid)、购物车汇总(sections/main-cart.liquid)和集合页筛选栏(sections/main-collection.liquid)。

问题。 position: sticky; top: 24px 适合比视口矮的元素。如果商品信息栏比视口还高,它会一直贴在顶部,底部的加购按钮和说明在 sticky 期间永远滚不出来,只能等整个容器滚完。

做法。 根据滚动方向动态调整 top:

  • 向下滚动时,top 跟着减小,可以变成负值,元素随页面上移,直到底部露出来(距视口底部留 20px)后停住;
  • 向上滚动时,top 跟着增大,元素随页面下移,直到回到 CSS 中设置的原始 top 后停住。

核心计算(简化):

#checkPosition() {
  if (this.#position !== "sticky") return this.style.removeProperty("top");

  const minTop = this.clientHeight - window.innerHeight + 20;   // 最多向上移这么多
  this.#currentTop -= window.scrollY - this.#lastKnownY;          // 跟随滚动的距离
  // maxTop 由元素在容器中的位置算出,此处省略
  this.#currentTop = Math.min(Math.max(this.#currentTop, -minTop), maxTop, this.#initialTop);
  this.#lastKnownY = window.scrollY;
  this.style.top = `${Math.round(this.#currentTop)}px`;
}

元素比视口矮时,-minTop 是正数,被 #initialTop 截住,top 始终等于原始值,行为与普通 sticky 完全一样。所以可以无条件地给任何 sticky 元素套上这个组件。

值得学的三个细节:

  1. 由 CSS 决定是否启用。 它读取 computed style 中的 position,只有值为 sticky 时才工作。在移动端用媒体查询取消 sticky,JS 会自动停下,不需要在 JS 里再写一遍断点。
  2. 按需监听。 用 inView(Motion 库提供,带 500px 边距)在元素接近视口时才注册 scroll 监听和 ResizeObserver,离开后注销。
  3. 按帧节流。 scroll 回调经过基于 requestAnimationFrame 的 throttle,每帧最多计算一次。

ScrollShadow:随滚动距离变化的边缘渐隐

ScrollShadow 在第 874 行,用于商品图片的缩略图列表(snippets/product-gallery.liquid)。

问题。 可横向滚动的列表需要提示“还有更多”。常见做法是固定的边缘渐变,但滚到头之后渐变依然存在,会挡住最后一项的边缘。

做法。 用 Shadow DOM 在内容上方叠一层不接收指针事件的 <s> 元素,它的背景是四个方向的线性渐变,每个渐变的尺寸由 CSS 变量控制:

s {
  position: absolute; inset: 0; pointer-events: none;
  background-image: var(--scroll-shadow-top, linear-gradient(to bottom, rgb(var(--background)), rgb(var(--background) / 0))), …;
  background-size: 100% var(--top, 0), 100% var(--bottom, 0), var(--left, 0) 100%, var(--right, 0) 100%;
}

JS 只负责计算四个方向各自还能滚动多少像素,然后把 min(剩余距离, --scroll-shadow-size) 写进 --top、--bottom、--left、--right。离边缘越近,渐变越窄,滚到头时正好为 0。

渐变颜色取自 --background,自动适配当前 section 的配色。如果需要别的效果,可以通过 --scroll-shadow-top 等变量整体替换。

SplitLines:按视觉行拆分文字

SplitLines 在第 899 行,在 snippets/styled-text.liquid 中使用:主题设置 heading_apparition 选择了拆分类入场效果、且标题没有使用渐变时,标题文字会被包进 <split-lines>。

问题。 逐行入场动画需要知道文字在当前宽度下被折成了哪几行,而这只有浏览器排版后才知道。

做法。 先把每个字符包进一个 <span>,读取每个 <span> 的 getBoundingClientRect().top,top 相同的字符属于同一行;再把每行重新组装成一个容器:

this.shadowRoot.innerHTML = this.textContent.replace(/./g, "<span>$&</span>");
const lines = new Map();
for (const letter of this.shadowRoot.children) {
  const key = Math.round(letter.getBoundingClientRect().top);
  lines.set(key, (lines.get(key) || "") + letter.textContent);
}
this.shadowRoot.replaceChildren(...Array.from(lines.values(), (line) =>
  fragment(`<span style="display:inline-block"><span style="display:block">${line}</span></span>`)
));

外层的 inline-block 作为遮罩,内层的 block 用于位移,这是逐行“从下方滑入”动画的标准结构。拆分结果放在 Shadow DOM 中,light DOM 里的原始文字保持不变,主题代码和 Theme Editor 读到的仍是原文。

两个触发重新拆分的时机:

  • 只在窗口宽度变化时重新拆分。 移动端滚动时地址栏伸缩会触发 resize,但宽度不变,没必要重新计算。
  • 用 MutationObserver 监听文字内容(characterData),Theme Editor 中修改文案后能自动重新拆分。

Player:标签页切到后台时自动暂停的计时器

Player 在第 131 行,由 EffectCarousel 创建,公告栏、幻灯片、媒体报道轮播等继承 EffectCarousel 的组件都通过它实现自动播放。

问题。 用 setTimeout 实现自动轮播,暂停后再继续时通常会从头计时;切到别的标签页再回来,轮播可能已经跳了好几张。

做法。 Player 继承 EventTarget,记录开始时间和剩余时间:

  • pause():剩余时间减去已经过去的时间;
  • resume():用剩余时间重新设置 setTimeout;
  • start():重置为完整时长。

它监听 visibilitychange,页面隐藏时自动暂停,回到前台后从剩余时间继续。计时结束时派发 player:end,各阶段也会派发 player:start、player:pause 等事件,携带时长和剩余时间,供进度条显示动画。

轮播组件 EffectCarousel(第 1048 行)使用方式如下:

this._player = new Player(this.getAttribute("autoplay"));
this._player.addEventListener("player:end", this.next.bind(this));
inView(this, () => this._player.resume(true));

计时与轮播完全解耦。Player 不知道轮播的存在,轮播也不需要处理暂停时的时间计算。在 Theme Editor 中选中某张幻灯片的 block 时,轮播会调用 stop(),方便商家编辑。

局限

  • ScrollShadow 的 slotchange 没有绑定 this。 第 881 行 addEventListener("slotchange", this.start) 传的是未绑定的方法,回调里的 this 是 <slot> 元素,this.firstElementChild 为 null,什么也不会做。初始化之后再替换内部的滚动元素,阴影就不再跟着更新。
  • SplitLines 把文字当作 HTML 插入。 组装每一行时,${line} 直接拼进 HTML 字符串。标题文字中如果有 < 加字母的组合,就会被解析成标签。这段文字来自商家输入,但仍不宜作为写法范例。
  • SplitLines 用 . 拆分字符。 不带 u 标志的正则会把 emoji 等代理对拆成两个 <span>,测量阶段会出现无法显示的半个字符,好在最终按行重组时会拼回原样。
  • Player 的 visibilitychange 监听从不移除。 每个实例都在 document 上注册一个监听,没有注销的方法。另外,resume() 只排除了 stopped 状态:一个尚未进入视口、从未启动的轮播,在标签页从后台切回时也会开始计时。