Dawn 变体切换的局部更新协议:option_values、ID 约定与快速加购复用
本文基于 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直接抛错。
迁移到自己主题时
- 让服务端选变体,前端只提交选项值 ID;不要在 JS 里维护变体矩阵。
- 需要更新的元素统一用
<名称>-<分区 ID>作id,并区分“请求用的分区 ID”和“本地查找用的分区 ID”,同一份分区就能放进弹窗、抽屉或对比面板。 - 每次请求前中止上一次;替换后恢复焦点;失败时恢复按钮状态。
建议的验证范围
本篇均未执行:限速下连续切换选项,确认只渲染最后一次的结果;断网后切换,确认按钮状态;用键盘切换选项,确认焦点位置;在集合页打开快速加购,确认请求使用原分区 ID、弹窗内的 ID 带有 quickadd- 前缀。