Impact 的变体切换:服务端重渲染、预取与按区块局部替换
本篇基于 Impact 7.2.0,涉及 assets/theme.js 中的 VariantPicker(第 3434 行起)和 ProductRerender(第 3309 行起),以及 snippets/variant-picker.liquid、snippets/option-value.liquid、snippets/product-info.liquid、sections/main-product.liquid。
思路:前端只负责“问”,不负责“算”
传统做法是把商品的全部变体 JSON 输出到页面上,用户切换选项时,前端自己查出对应的变体,再逐个更新价格、库存、SKU、按钮状态和图片。这样每加一个和变体相关的展示块,就要在 JS 里再写一遍对应的逻辑,而且很容易与 Liquid 的输出不一致。
Impact 的做法是:用户选好一组选项后,前端直接问 Shopify:“这组选项对应的这个 section 长什么样?”拿回渲染好的 HTML,替换掉变化的部分。所有展示逻辑只在 Liquid 里写一次。
选项如何变成请求
每个选项都是一个 <input>,value 是选项值的 ID(option_value.id),并带上 data-option-position 标出它属于第几个选项:
<input type="radio" name="{{ param_name }}" value="{{ value | escape }}"
form="{{ form }}" data-option-position="{{ option_position }}" ...>
<variant-picker> 在 document.body 上委托监听这些 input 的 change 事件。触发后,按 data-option-position 排序所有已选中的 input,取出它们的值,再请求:
fetch(`${productUrl}?option_values=${optionValues.join(",")}§ion_id=${sectionId}`)
option_values 告诉 Shopify 这次选中了哪几个选项值,section_id 让 Shopify 只返回当前 section 的 HTML,而不是整页。
快速购买弹窗是个例外:它不带 section_id,请求的是整个商品页,然后在返回的 HTML 中查找 <template id="quick-buy-content">。为此主题写了一个能深入 <template> 内容查找元素的 deepQuerySelector(assets/theme.js 第 77 行)。
预取:在用户点击之前就把结果准备好
如果每次点击后才发请求,用户就要等一次网络往返。Impact 分两层提前请求:
进入视口时,预取所有“只改一个选项”的组合。 <variant-picker> 用 IntersectionObserver 观察自己,进入视口后用 requestIdleCallback(超时 2 秒)在浏览器空闲时,为每一个未选中的选项值发一次请求。组合方式是:这个值,加上其他选项当前选中的值。
注意请求数量是线性的。3 个选项、每个 5 个值时,只需要发 3 × 4 = 12 次请求,而不是 125 种组合全部请求一遍。因为用户下一步最可能做的,就是只改一个选项。
悬停或触摸时,预取那一个组合。 鼠标 pointerenter 或手指 touchstart 到某个选项的 label 上时,立即请求对应的组合。从手指按下到 change 触发之间的几十毫秒,足够请求先跑起来。
两层预取共用一个缓存(简化):
static #preloadedHtml = new Map();
#renderForCombination(optionValues) {
const key = `${optionValues.join(",")}-${this.getAttribute("section-id")}`;
if (!VariantPicker.#preloadedHtml.has(key)) {
const promise = fetch(url).then((r) => r.text());
VariantPicker.#preloadedHtml.set(key, { htmlPromise: promise, timestamp: Date.now() });
if (VariantPicker.#preloadedHtml.size > 100) {
VariantPicker.#preloadedHtml.delete(VariantPicker.#preloadedHtml.keys().next().value);
}
}
return VariantPicker.#preloadedHtml.get(key).htmlPromise;
}
缓存里存的是 Promise,不是 HTML 字符串。所以当悬停触发的请求还在进行中时,用户点击会直接拿到同一个 Promise,不会重复发请求。缓存是类上的静态字段,同一页面中的多个选择器共享,缓存键包含 section ID,避免互相串用;超过 100 条时删除最早写入的一条。
拿到 HTML 之后
selectCombination() 按下面的顺序处理:
- 把 HTML 解析成文档片段,找到新的
<variant-picker>,从它里面的<script data-variant type="application/json">读出新变体。变体数据跟着 HTML 一起由服务端给出,前端不需要自己查。 - 把新变体 ID 写入表单的
id字段,并手动派发change事件。 - 如果有
update-url属性(商品主页面),用history.replaceState更新地址栏中的?variant=。 - 在表单上派发
product:rerender,携带解析好的片段。 - 在表单上派发冒泡的
variant:change,携带新旧变体,给画廊等其他组件使用。 - 调用
Shopify.PaymentButton.init(),重新初始化动态结账按钮。
按区块局部替换
<product-rerender> 负责第 4 步之后的 DOM 替换。它通过 observe-form 属性关联表单,监听 product:rerender,在新片段里查找与自己 ID 相同的元素。
商品主页面上有两个 <product-rerender>(sections/main-product.liquid 第 45、59 行):
- 商品信息区带
allow-partial-rerender,只做局部替换; - 底部吸附购买栏不带这个属性,每次整体替换。
局部替换依赖 snippets/product-info.liquid 给每个区块加的两个属性:
<div class="product-info__block-item"
data-block-id="{{ block.id }}"
data-block-type="{{ block.type | replace: '_', '-' }}">
ProductRerender 维护一份“会随变体变化”的区块类型白名单,包括 sku、badges、price、payment-terms、variant-picker、quantity-selector、volume-pricing、inventory、buy-buttons、pickup-availability 和 liquid。只有这些类型的区块会按 data-block-id 一一替换,标题、描述、可折叠内容等不变的区块保持原样。有三个细节:
- 数量选择器:替换前记住当前数量,替换后写回,用户已经输入的数量不会被重置。
- 购买按钮:只替换内层的
<buy-buttons>元素,保留外层容器。 - 焦点:替换前记下
document.activeElement,替换后如果能按 ID 找到同一个元素,就把焦点还给它。键盘用户切换选项后不会丢失焦点位置。
商品图片画廊不在白名单里,不会被替换。它自己监听两个事件(assets/theme.js 第 3032–3033 行注册):
product:rerender:只从新片段里读取filtered-indexes属性,即“这个变体应该显示哪些媒体”,再通知轮播隐藏其余媒体;variant:change:如果新旧变体的主图不同,就把轮播切换到新主图所在的位置。
画廊本身从不重建,可以避免图片重新加载和闪烁。
组合商品(Combined Listings)
如果选项值属于另一个商品(option_value.product_url 不为空),选项 input 会带上 data-product-url。请求改发到那个商品的 URL,并标记 productChange: true。这时局部替换没有意义,<product-rerender> 会整体替换。在商品主页面(update_url 为真时),这类选项直接渲染成链接,点击后整页跳转。
值得借鉴的点
- 展示逻辑只在 Liquid 里写一次,JS 只负责请求和替换,新增展示块不需要改 JS。
- 按用户最可能的下一步做线性预取,再加上悬停预取和共享 Promise 的缓存,切换几乎没有等待。
- 用
data-block-type加白名单做局部替换,保留用户输入和焦点,同时避免替换不该动的区块。
局限
- 声明了但没用到的过期时间。 第 3432 行定义了
CACHE_EVICTION_TIME(5 分钟),每条缓存也记录了timestamp,但全文没有任何地方读取它们。缓存只按数量淘汰,从不按时间淘汰。页面停留较久时,库存和价格可能是旧的。 - 请求量不小。 一个有 3 个选项、每个 10 个值的商品,进入视口后会发出 27 次 section 请求,再加上悬停触发的请求。选项很多的商品需要评估这些请求带来的服务端负担。
- 一个永远成立的比较。 画廊里
galleryMarkup.filteredIndex !== this.filteredIndexes(第 3157 行)读取的是一个不存在的属性,与数组比较永远为真,所以每次都会重新应用过滤。结果是对的,只是做了多余的工作。 createContextualFragment会执行脚本。 与innerHTML不同,用它解析出的<script>在插入 DOM 后会执行。section 里如果有内联脚本,每次切换变体都会再执行一次。