Horizon 的加购竞态处理:变体切换未完成时点击加入购物车
本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。
问题
切换变体时,变体选择器先请求新的 section HTML,返回后才更新隐藏的 input[name="id"]、价格、库存和按钮状态(完整请求链见变体选择)。如果用户选完 M 立刻点"加入购物车",而请求还没返回,直接提交表单时 input[name="id"] 里还是上一个变体:用户以为加的是 M,购物车里却是 S;上一个变体若已售罄或达到购买上限,加购还会直接报错。
购买流程一文只指出了这条排队分支的存在。本文展开它的实现:assets/product-form.js 专门处理了这个窗口期,决策逻辑抽成了纯函数,放在 assets/variant-resolution.js。
整体流程
用户选择变体
└─ variant-picker 发出 ProductSelectEvent(携带 section 请求的 promise)
└─ product-form: generation++,variantChangeInProgress = true,记住 pendingVariantChange
用户点击加购(handleSubmit)
├─ 没有变体请求在途 → 正常提交
└─ 有变体请求在途 → 拍一张"快照"放进队列,按钮照常播放加购动画
变体请求结束(成功或失败)
└─ 如果自己仍是最新一代 → 清除 in-progress 标记 → 排空队列
└─ 逐个解析快照对应的变体 → 规范化数量 → 一次批量 POST
关键设计
1. 事件里带着 promise
variant-picker.js 的 fetchUpdatedSection 在发起请求之前就派发 ProductSelectEvent,事件对象上挂着这次请求结果的 promise:
const deferredEventPromise = ProductSelectEvent.createPromise();
this.dispatchEvent(new ProductSelectEvent({ ..., promise: deferredEventPromise.promise }));
fetch(requestUrl, { signal }) /* 成功时 resolve,失败或被 abort 时 reject */
这样,监听方不必等请求完成才知道"变体正在变化",可以立即进入"在途"状态,之后再 await event.promise 拿结果。ProductSelectEvent 从 @shopify/events 导入,importmap 把它指向 cdn.shopify.com/storefront/standard-events.js,是 Shopify 的标准事件模块,不是主题自定义的事件。
2. 代次计数,防止旧请求清掉新状态
用户可能连续切换 S → M → L,每次切换都会 abort 上一个请求。被取消的请求进入 finally 时,不能把"在途"标记清掉,因为 L 的请求还没回来:
const generation = ++this.#variantChangeGeneration;
this.#variantChangeInProgress = true;
this.#pendingVariantChange = event.promise;
try { /* await event.promise,更新按钮、价格等 */ }
finally {
if (generation === this.#variantChangeGeneration) {
this.#variantChangeInProgress = false;
await this.#drainAddToCartQueue();
}
}
只有最新一代的请求才有资格结束"在途"状态并排空队列。这是处理"可取消的异步操作 + 共享标志位"时的经典写法。
3. 队列里存快照,而不是存变体 ID
点击加购时变体还没确定,所以队列项记录的是解析变体所需的线索:
{
quantity, // 点击时的数量
generation, // 点击时的代次
intendedVariantId, // 当前选中项上的 data-variant-id(单选项商品有)
pendingVariantChange, // 点击时在途的那个请求 promise
variantResolutionUrl, // picker.buildRequestUrl(选中项),能重新解析这个选择的地址
}
关键在 pendingVariantChange:快照绑定的是用户点击那一刻的请求,而不是排空队列时选择器的最新状态。用户点了加购之后又切换了变体,队列里那次加购仍然对应点击时的选择。
4. 变体解析的优先级是一个纯函数
export function resolveVariantId({ resolvedVariantId, intendedVariantId, hiddenInputValue, available }) {
if (available === false) return null;
return normalize(resolvedVariantId) // ① 快照自己的请求解析出的变体
?? normalize(intendedVariantId) // ② 选中项上的 data-variant-id,永不滞后
?? normalize(hiddenInputValue) // ③ 隐藏 input,只在请求完成后才可信
?? null; // 都没有 → 放弃这次加购
}
以上为示意,源码逐项 if 判断,不是 ?? 链;normalizeVariantId 会把空白字符串也视为没有值。
几个值得注意的点:
- 返回
null表示放弃。宁可不加,也不提交一个过期的、空的或已达上限的变体 ID; - 隐藏 input 只有最新一代才读取。快照不是最新一代时,传入的是
null,因为此时 input 里的值属于别的选择;可售状态也只在最新一代时才回退到"按钮是否禁用"; - 快照的请求被取消了怎么办? 用户点击加购后又切换了变体,快照里的 promise 会因 abort 而 reject。此时用
variantResolutionUrl重新请求一次,从返回 HTML 里variant-picker内的 JSON 读出变体 ID 和可售状态;这次请求失败则视为不可售。
源码注释称其为 "#2756 fix"(未说明是 issue 还是 PR 编号),并说明抽成纯函数是为了脱离 DOM 和组件环境做单元测试。
5. 数量也要按新变体重新校正
不同变体可能有不同的最小购买量、步长和上限(B2B 的数量规则)。快照里的数量是按旧变体输入的,所以排空时会从快照对应的那份 HTML(原请求或重新解析的结果)里找到 quantity-selector-component 的数量输入框,读取 min、max、step 和 data-cart-quantity(购物车中已有数量),再做规范化:
// 有效上限 = max(上限 - 已在购物车中的数量, min)
// 先向下对齐到步长,再夹在 [min, 有效上限] 之间
if ((quantity - min) % step !== 0) normalized = min + Math.floor((quantity - min) / step) * step;
return Math.max(min, Math.min(effectiveMax ?? Infinity, normalized));
6. 合并为一次请求
解析后的所有队列项,最终通过一次 POST 提交到 Theme.routes.cart_add_url(JSON 体里是 items 数组),并用 sections 参数带回页面上各 cart-items-component 所属分区的 HTML。用户在在途期间连点了三次加购,也只会产生一次加购请求和一次购物车刷新;全部解析为 null 时不发请求。
7. 用户感知不到排队
入队时,按钮仍然会调用 animateAddToCart() 播放加购动画。从用户角度看,点击立即得到了反馈,背后的等待被隐藏了。
值得商榷的地方
product-form.js有 1122 行。加购、队列、变体切换后的 UI 更新、B2B 数量、错误提示都在一个类里,阅读成本很高。变体解析抽成纯函数是好的开始,队列管理也值得单独成模块。- 入队后就播放动画,可能在最后放弃时造成误导。如果最终解析为
null(选中的变体不可售),动画已经播过了,但什么都没加进去。#drainAddToCartQueue的注释解释说,不可售的选择会在#onProductSelect里把按钮禁用,所以不需要额外的 UI 处理。但"点击时按钮可用、排空时才发现不可售"这个窗口,用户看到的是加购成功的动画。 - 重新解析会多一次请求。快照的请求被后续切换取消时,需要再请求一次分区 HTML。这是正确性优先的取舍,但在网络差的环境下会进一步拉长加购时间。
- 队列项是串行解析的。
#drainAddToCartQueue在循环里逐个await,多个快照都需要重新解析时,请求会一个接一个发出。
小结
这个问题的难点不在技术,而在于意识到它存在:在高速网络下几乎复现不了,只有慢网络加上快速操作的用户才会遇到,而且表现为"加错了商品",很难被归因到主题代码上。
Horizon 的解法可以归纳为四条,适用于任何"异步更新的表单 + 提交"场景:
- 状态变化开始时就广播,并附带结果 promise;
- 用代次计数保护共享标志位;
- 提交时保存的是"意图快照",而不是当下的值;
- 决策逻辑抽成纯函数,单独测试。
验证这一行为的场景清单见购买流程中的"切换变体后立即点击"。
Horizon 的 LICENSE.md 禁止分发基于其代码的衍生主题,本文只做源码分析,借鉴思路请自行实现。