EN
Shopify 知识库 · 概念

Impact 的购物车事件总线:加购方不需要知道谁在等结果

Impact 7.2.0 用 cart:prepare-bundled-sections 事件让购物车抽屉等组件自报要刷新的 section,加购一次请求就拿回全部 HTML,再通过 variant:add 与 cart:change 分发,并用 Promise 单例和 bfcache 处理保持数量一致。
历史资料
请结合文中的适用版本和来源阅读。

本篇基于 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:changedocument.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,多个组件共享结果;购物车一有变化,缓存就被替换成最新值。

值得借鉴的点

  1. 用同步事件收集参数。 cart:prepare-bundled-sections 让请求方在不认识任何组件的情况下,凑齐一次请求需要的全部 section。
  2. 事件分两层: 表单层表示“谁做了什么”,document 层表示“全局状态变了”。监听者按自己关心的粒度订阅。
  3. 在事件对象上写标志,让组件之间协调,不需要互相引用。
  4. 认真处理 bfcache。 购物车类 UI 很容易在“后退”时显示旧数据,Impact 在抽屉和数量角标上都处理了 pageshow 的 persisted 情况。

局限

  • 加购后多一次请求。 /cart/add.js 只返回刚加入的商品,所以每次加购后都要再请求一次 /cart.js 来拿完整的购物车。
  • 没有说明的延迟。 改数量后,购物车抽屉要等 1250 毫秒才替换商品列表(加购时是 0)。代码里没有注释说明原因,看起来是为了让行项目的加载和移除动画先播完。
  • 在购物车页面直接整页刷新。 在 /cart 页面改数量时,LineItemQuantity 会直接 location.reload(),前面的事件机制在这个页面没有用上。
  • 错误信息未转义。 改数量失败时,服务端返回的 description 被直接拼进 insertAdjacentHTML。这段文字来自 Shopify,风险较低,但不宜作为写法范例。