Impact 的四个行为组件:超高 sticky、滚动阴影、视觉行拆分与可暂停计时器
本篇基于 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 元素套上这个组件。
值得学的三个细节:
- 由 CSS 决定是否启用。 它读取 computed style 中的
position,只有值为sticky时才工作。在移动端用媒体查询取消 sticky,JS 会自动停下,不需要在 JS 里再写一遍断点。 - 按需监听。 用
inView(Motion 库提供,带 500px 边距)在元素接近视口时才注册scroll监听和ResizeObserver,离开后注销。 - 按帧节流。
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状态:一个尚未进入视口、从未启动的轮播,在标签页从后台切回时也会开始计时。