EN
Shopify 知识库 · 概念

Impact 的变体切换:服务端重渲染、预取与按区块局部替换

Impact 7.2.0 切换商品变体时不在前端拼价格和库存,而是用 option_values 与 section_id 请求 Shopify 渲染好的 HTML,在视口可见和悬停时预取,并按区块类型局部替换以保留数量与焦点。
历史资料
请结合文中的适用版本和来源阅读。

本篇基于 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(",")}&section_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() 按下面的顺序处理:

  1. 把 HTML 解析成文档片段,找到新的 <variant-picker>,从它里面的 <script data-variant type="application/json"> 读出新变体。变体数据跟着 HTML 一起由服务端给出,前端不需要自己查。
  2. 把新变体 ID 写入表单的 id 字段,并手动派发 change 事件。
  3. 如果有 update-url 属性(商品主页面),用 history.replaceState 更新地址栏中的 ?variant=。
  4. 在表单上派发 product:rerender,携带解析好的片段。
  5. 在表单上派发冒泡的 variant:change,携带新旧变体,给画廊等其他组件使用。
  6. 调用 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 为真时),这类选项直接渲染成链接,点击后整页跳转。

值得借鉴的点

  1. 展示逻辑只在 Liquid 里写一次,JS 只负责请求和替换,新增展示块不需要改 JS。
  2. 按用户最可能的下一步做线性预取,再加上悬停预取和共享 Promise 的缓存,切换几乎没有等待。
  3. 用 data-block-type 加白名单做局部替换,保留用户输入和焦点,同时避免替换不该动的区块。

局限

  • 声明了但没用到的过期时间。 第 3432 行定义了 CACHE_EVICTION_TIME(5 分钟),每条缓存也记录了 timestamp,但全文没有任何地方读取它们。缓存只按数量淘汰,从不按时间淘汰。页面停留较久时,库存和价格可能是旧的。
  • 请求量不小。 一个有 3 个选项、每个 10 个值的商品,进入视口后会发出 27 次 section 请求,再加上悬停触发的请求。选项很多的商品需要评估这些请求带来的服务端负担。
  • 一个永远成立的比较。 画廊里 galleryMarkup.filteredIndex !== this.filteredIndexes(第 3157 行)读取的是一个不存在的属性,与数组比较永远为真,所以每次都会重新应用过滤。结果是对的,只是做了多余的工作。
  • createContextualFragment 会执行脚本。 与 innerHTML 不同,用它解析出的 <script> 在插入 DOM 后会执行。section 里如果有内联脚本,每次切换变体都会再执行一次。