EN
Shopify 知识库 · 指南

Dawn 变体切换的局部更新协议:option_values、ID 约定与快速加购复用

基于 Dawn v15.3.0 源码,拆解 product-info 如何不下发变体矩阵、改用 option_values 让服务端选出变体,如何用 ID 后缀约定把同一份分区 HTML 复用到快速加购弹窗,以及中止请求、焦点恢复与媒体对齐的写法和遗留问题。
历史资料
请结合文中的适用版本和来源阅读。

本文基于 Dawn v15.3.0,源码固定到 ecb06c3(核验于 2026-10-01)。结论限于此提交;本篇为静态源码分析,未完成店铺运行验证。Dawn 上游已发布 v16.0.0,本库 Dawn 正文统一以 v15.3.0 为基线,升级前需重新核对。

变体切换的完整流程(价格、库存、按钮状态)见购买区篇。本文关注其中可以借鉴的协议设计:浏览器发什么、服务端返回什么、两边靠什么对上号。

浏览器只发选项值 ID

VariantSelects 在 change 时读取所有选中项的 data-option-value-id(selectedOptionValues),通过 pubsub 发布出去。这个 ID 由 Liquid 输出为 value.id,同时附带 data-product-url="{{ value.product_url }}"(product-variant-options.liquid#L50-L53)。

ProductInfo 把它们拼成 <商品 URL>?section_id=<分区>&option_values=<id,id>(product-info.js#L145-L155)。服务端据此选出变体并重新渲染分区,选中的变体以 JSON 形式放在 <script type="application/json" data-selected-variant> 中(product-variant-picker.liquid#L90-L92),由 getSelectedVariant 读出。

好处是前端不持有变体矩阵。哪些选项值可售(value.available,product-variant-options.liquid#L36-L39)、组合不存在时怎样处理,都由服务端渲染决定;JS 只做“请求、替换、读回所选变体”三件事。

三条分支由两个 data 属性决定

条件行为位置
选项值的 product_url 与当前 data-url 相同在当前商品内更新:选项、价格、SKU、库存、数量规则、媒体handleUpdateProductInfo
指向另一个商品,且 data-update-url="false"只替换 product-info 自身handleSwapProduct
指向另一个商品,且 data-update-url="true"请求整页(不带 section_id),替换 <main> 和 <title>同上,shouldFetchFullPage 见 #L70-L71

商品主分区声明 data-update-url="true"(main-product.liquid#L6),首页的精选商品分区声明 false(featured-product.liquid#L11)。同一个组件靠一个属性区分“这是页面的主角”还是“嵌在别的页面里”:前者跨商品时连同一模板里的推荐等其他分区一起更新,并改写地址栏;后者不动地址栏(updateURL)。

ID 后缀约定:同一份 HTML 用在两个地方

分区内需要局部更新的元素,id 都写成 <名称>-<section.id>,例如 price-template--123__main。更新时按名称在响应里找源、在本地找目标(product-info.js#L180-L193):

  • 源:html.getElementById(${id}-${this.sectionId})
  • 目标:this.querySelector(#${id}-${this.dataset.section})

两个 ID 来源不同,正是为快速加购准备的。快速加购请求整个商品页,取出其中的 product-info 放进弹窗,preventDuplicatedIDs 把所有出现分区 ID 的地方替换成 quickadd-<分区 ID>,再把原值存进 data-original-section。之后:

  • sectionId 取 originalSection || section(#L411-L413),请求服务端时用原分区 ID,因为服务端只认识它;
  • 本地查找用 dataset.section,即加了前缀的 ID,不会与页面上原本的商品分区冲突;
  • 弹窗同时被设为 data-update-url="false"(#L63-L65),并且不订阅 cartUpdate(product-info.js#L43-L45)。

快速加购因此不需要单独的模板。代价是字符串替换不区分上下文:innerHTML.replaceAll(oldId, newId) 会替换所有包含该分区 ID 的文本,包括可能出现在正文或 JSON 里的同名字符串。

竞态、焦点与媒体

中止旧请求。 renderProductInfo 每次先 abort() 上一个请求。用户快速连续点击时,只有最后一次的结果会生效。pendingRequestUrl 记录尚未返回的商品 URL,当触发元素没有 data-product-url 时作为回退(#L68-L69)。

恢复焦点。 选项区域会被整块替换(updateOptionValues),键盘用户的焦点会随旧节点一起消失。回调结束后,Dawn 按被点击输入框的 id 重新 focus()(#L127-L130)。这能成立,是因为替换工具给旧节点的 id 加了后缀,#id 只会命中新节点,见局部替换工具。

媒体对齐而非替换。 updateMedia 按 data-media-id 对比新旧列表:插入缺少的、删除多余的、按新顺序移动已有节点,最后激活变体主图。已加载的图片和视频节点得以保留,不会因切换变体重新下载。细节见商品媒体篇。

通知其他组件。 更新完成后发布 variantChange,附带分区 ID、整份响应文档和变体(#L204-L210),阶梯价、礼品卡收件人表单据此更新,而不用各自再发请求。

读源码时发现的问题

均为静态阅读,未在浏览器复现:

  • 请求失败后按钮一直不可用。 handleOptionValueChange 一开始就调用 resetProductFormState,把提交按钮设为 disabled(#L82-L86、product-form.js#L127-L135);网络错误时 catch 只打印日志,按钮要等下一次成功切换才会恢复。
  • 整页替换只更新 <title>。 跨商品替换 <main> 时,<head> 中的 canonical、描述与结构化数据保持旧商品的内容。
  • getSelectedVariant 不容错。 响应里找不到 product-info 时,productInfoNode.querySelector 直接抛错。

迁移到自己主题时

  1. 让服务端选变体,前端只提交选项值 ID;不要在 JS 里维护变体矩阵。
  2. 需要更新的元素统一用 <名称>-<分区 ID> 作 id,并区分“请求用的分区 ID”和“本地查找用的分区 ID”,同一份分区就能放进弹窗、抽屉或对比面板。
  3. 每次请求前中止上一次;替换后恢复焦点;失败时恢复按钮状态。

建议的验证范围

本篇均未执行:限速下连续切换选项,确认只渲染最后一次的结果;断网后切换,确认按钮状态;用键盘切换选项,确认焦点位置;在集合页打开快速加购,确认请求使用原分区 ID、弹窗内的 ID 带有 quickadd- 前缀。

源码基线