Impact 的购物车事件总线:加购方不需要知道谁在等结果
本篇基于 Impact 7.2.0,涉及 assets/theme.js 中的 ProductForm(第 2892 行)、LineItemQuantity(第 2059 行)、CartDrawer(第 1805 行)、fetchCart(第 1214–1230 行)等,以及 sections/variant-added.liquid。
要解决的耦合
点击“加入购物车”后,页面上可能有好几处需要更新:
- 购物车抽屉的商品列表和小计;
- 头部的商品数量角标;
- 免运费进度条;
- “已加入购物车”的通知弹层;
- 读屏软件的播报。
而且哪些 UI 存在,取决于商家的设置(购物车类型是抽屉、弹层还是独立页面)和当前页面。最直接的写法是让加购代码挨个调用这些组件,结果是加购代码要知道所有组件。Impact 用事件把双方解开了。
第一步:让需要刷新的组件自己报名
Shopify Ajax API 支持在 /cart/add.js 和 /cart/change.js 中传 sections 参数,响应里会附带这些 section 渲染好的 HTML,省去额外的请求。问题是:加购代码怎么知道要传哪些 section?
Impact 的答案是在请求之前派发一个“报名”事件(简化):
let sectionsToBundle = ["variant-added"];
document.documentElement.dispatchEvent(
new CustomEvent("cart:prepare-bundled-sections", { detail: { sections: sectionsToBundle } })
);
formData.set("sections", sectionsToBundle.join(","));
formData.set("sections_url", `${Shopify.routes.root}variants/${this.id.value}`);
事件对象的 detail 里放着一个数组,监听者直接往里 push 自己的 section ID。购物车抽屉的监听函数只有一行:
_onPrepareBundledSections(event) {
event.detail.sections.push(extractSectionId(this));
}
dispatchEvent 是同步执行的,它返回时所有监听者都已经执行完,数组也就填好了。这相当于用事件实现了一次同步的“询问”。页面上没有购物车抽屉,就没有人报名,请求也不会多带一个 section。
sections_url 指向刚加购的变体,sections/variant-added.liquid 里读取的是 product_variant。用于通知弹层的这段 HTML 因此是针对这个变体渲染的,这个 section 也就不需要接收额外参数。
修改购物车数量的 LineItemQuantity 也用同一个事件,所以抽屉在加购和改数量两种场景下的刷新路径是一样的。
第二步:用两层事件分发结果
ProductForm 加购成功后,会先请求一次 /cart.js 拿到完整的购物车,把加购响应中的 sections 挂到这个对象上,然后派发两个事件:
| 事件 | 派发位置 | 用途 | 监听者 |
|---|---|---|---|
variant:add | 加购的表单,向上冒泡 | “这个表单加购了某些商品” | 购物车抽屉(决定是否打开)、通知弹层、快速购买抽屉、购买按钮的读屏播报 |
cart:change | document.documentElement | “购物车变了,这是最新内容” | 购物车抽屉(替换内容)、数量角标、免运费进度条、fetchCart 缓存 |
cart:change 带有 baseEvent 字段,标明这次变化是加购(variant:add)还是改数量(line-item:change),监听者可以据此调整动画。
失败时,表单上派发 cart:error,由购买按钮显示错误横幅;同时在 document 上派发 cart:refresh,让所有购物车 UI 从服务端重新拉取一次,防止前端状态与真实购物车不一致。
第三步:组件之间互相让路
有几种情况下,同一次加购不应该触发所有反应。Impact 让上游组件在事件对象上写入一个标志,下游读取这个标志:
// 快速购买抽屉:自己已经展示了加购结果,不要再弹出购物车抽屉
_onVariantAdded(event) {
event.detail.blockCartDrawerOpening = true;
...
}
// 购物车抽屉
_onVariantAdded(event) {
if (window.themeVariables.settings.cartType !== "drawer" || event.detail?.blockCartDrawerOpening) return;
this.show();
}
快速购买抽屉监听自己身上的 variant:add,在事件冒泡到 document 之前就写入了标志;购物车抽屉监听的是 document,这时一定能读到它。这个约定依赖的是 DOM 事件冒泡的顺序,而不是组件之间的直接引用。
购物车类型的判断也放在监听者一侧:抽屉只在 cartType === "drawer" 时打开,通知弹层只在 cartType === "popover" 时显示。加购代码只在 cartType === "page" 时例外,直接跳转到 /cart。
第四步:数量角标的一致性
数量角标 <cart-count> 需要处理三种来源:
cart:change:直接使用事件中的item_count;cart:refresh:重新从服务端读取;- 页面从浏览器往返缓存(bfcache)恢复:用户在另一页加购后按“后退”,看到的是旧页面快照,数量是旧的。
它依赖一个模块级的 fetchCart(简化):
let fetchCart = fetch("/cart.js").then((r) => r.json());
document.addEventListener("cart:refresh", () => { fetchCart = fetch("/cart.js").then((r) => r.json()); });
document.addEventListener("cart:change", (e) => { fetchCart = e.detail.cart; });
window.addEventListener("pageshow", (e) => { if (e.persisted) fetchCart = fetch("/cart.js").then((r) => r.json()); });
fetchCart 有时是 Promise,有时是普通对象。消费方统一写 await fetchCart,两种情况都能正确处理。页面加载时只请求一次 /cart.js,多个组件共享结果;购物车一有变化,缓存就被替换成最新值。
值得借鉴的点
- 用同步事件收集参数。
cart:prepare-bundled-sections让请求方在不认识任何组件的情况下,凑齐一次请求需要的全部 section。 - 事件分两层: 表单层表示“谁做了什么”,document 层表示“全局状态变了”。监听者按自己关心的粒度订阅。
- 在事件对象上写标志,让组件之间协调,不需要互相引用。
- 认真处理 bfcache。 购物车类 UI 很容易在“后退”时显示旧数据,Impact 在抽屉和数量角标上都处理了
pageshow的persisted情况。
局限
- 加购后多一次请求。
/cart/add.js只返回刚加入的商品,所以每次加购后都要再请求一次/cart.js来拿完整的购物车。 - 没有说明的延迟。 改数量后,购物车抽屉要等 1250 毫秒才替换商品列表(加购时是 0)。代码里没有注释说明原因,看起来是为了让行项目的加载和移除动画先播完。
- 在购物车页面直接整页刷新。 在
/cart页面改数量时,LineItemQuantity会直接location.reload(),前面的事件机制在这个页面没有用上。 - 错误信息未转义。 改数量失败时,服务端返回的
description被直接拼进insertAdjacentHTML。这段文字来自 Shopify,风险较低,但不宜作为写法范例。