How to Build an AJAX Cart Drawer in Shopify (the 2026 Way)

TL;DR · AI 摘要
How to Build an AJAX Cart Drawer in Shopify the 2026 Way July 3, 2026 / shopify baslefeber Add a product to a Shopify st...
核心要点
- 主题聚焦:How to Build an AJAX Cart Drawer in Shopify the
- 来源:freeCodeCamp.org,建议结合原文判断细节。
- AI 分析暂不可用,本条为保底评分与摘要。
如何在Shopify上构建AJAX购物车抽屉(2026年方法)
2026年7月3日
/
#shopify
baslefeber
默认情况下,将商品添加到Shopify商店时,整个页面会重新加载。用户正在查看商品,点击“加入购物车”后,浏览器会丢弃当前页面并重新加载。在网速较慢的情况下,这会导致两到三秒的空白屏幕。有时用户甚至会被重定向到/cart页面,与原本要购买的商品页面完全分离,这种购物动量就此消失。
购物车抽屉可以解决这个问题。它是一种滑出面板,当用户添加商品时会显示出来:购物车实时更新,结账按钮就在眼前,用户无需离开当前页面。
几乎所有高转化率的Shopify商店都配备了这种功能,而且无需依赖第三方应用即可实现。你只需要两个Ajax端点和不到一百行的JavaScript代码即可完成。
关键在于,大多数教程和AI编码工具都采用脆弱的实现方式。它们通过JavaScript变量维护商品数量,并手动更新DOM。这种方式在演示中看起来没问题,但一旦出现商品变体售罄、折扣应用或两条购物车记录合并等情况,整个结构就会崩溃。
本指南将采用资深开发者的方式进行构建,始终以服务器作为数据源的权威。随后,我们将展示2026年的升级方案,使你的抽屉能够使用与应用和AI购物代理相同的语言进行交互。
如果你想跟着代码实现,打开一个可编辑的开发主题,随着教程逐步构建每个功能模块。所有内容均可在未安装任何应用的标准Online Store 2.0主题(如Horizon或Dawn)上运行。
最终实现效果:添加商品到购物车时无需页面刷新,面板滑出显示真实购物车内容。
目录
- 你将构建的内容
- 第1步:抽屉的HTML结构
- 第2步:无需刷新页面的添加到购物车
- 第3步:从服务器真实数据渲染抽屉
- 第4步:使用事件委托实现数量增减
- 第5步:移除商品行和清空购物车
- 第6步:使用区块渲染API进行重新渲染(可部署版本)
- 2026年升级:标准商店事件和操作
- 为什么这很重要
- 完整文件代码
- 总结
你将构建的内容
最终,你将拥有一个抽屉,具备以下功能:
- 通过Ajax添加商品到购物车,无需页面刷新
- 在每次操作后重新读取购物车数据,并将响应结果视为权威数据源
- 自主渲染内容并滑出显示
- 通过单一委托监听器处理数量增减
- 支持移除商品行和清空购物车
- 最终升级:通过Shopify新标准商店操作接口暴露自身,使应用和AI代理能够控制它
先决条件
- 可编辑的Shopify主题(示例基于Online Store 2.0主题,如Horizon或Dawn)
- 熟悉fetch和Promise
- 基础Liquid模板知识
- 不需要应用、框架或构建步骤
第1步:抽屉的HTML结构
你将把抽屉构建为一个区块,使其在每个页面上都能渲染,然后在任何JavaScript运行之前,使用Liquid从服务器端渲染当前购物车数据。
这很重要:如果访客带着已有商品进入商店,抽屉在首次渲染时就会显示正确的数据,JavaScript只需在发生变更后进行更新。这是渐进增强的实现方式,而这是大多数自制方案会忽略的关键点。
下面的data-*属性是HTML结构与脚本之间的契约。JavaScript所有操作都通过这些属性定位元素,而不是通过类名或标签位置。
{%- comment -%} sections/cart-drawer.liquid {%- endcomment -%}
<button type="button" class="cart-toggle" data-cart-toggle>
购物车 <span class="cart-count" data-cart-count>{{ cart.item_count }}</span>
</button>
<aside class="drawer" data-drawer aria-label="Cart" aria-hidden="true">
<div class="drawer__head">
<h2>您的购物车</h2>
<button type="button" class="drawer__close" data-drawer-close aria-label="关闭购物车">×</button>
</div>
<div class="drawer__body">
<p class="drawer__empty" data-drawer-empty {% if cart.item_count > 0 %}style="display:none"{% endif %}>
您的购物车为空。
</p>
<ul class="drawer__items" data-drawer-items>
{%- for item in cart.items -%}
<li class="drawer__item" data-line data-line-key="{{ item.key }}" data-quantity="{{ item.quantity }}">
{{ item.image | image_url: width: 96 | image_tag: class: 'drawer__item-img', loading: 'lazy', alt: item.product.title }}
<span class="drawer__item-title">{{ item.product.title }}</span>
<span class="drawer__item-price">{{ item.final_line_price | money }}</span>
</li>
{%- endfor -%}
</ul>
</div>
<div class="drawer__foot">
<div class="drawer__subtotal">
<span>小计</span>
<span data-cart-subtotal>{{ cart.total_price | money }}</span>
</div>
<p class="drawer__ship">运费和税费将在结账时计算。</p>
<a href="{{ routes.cart_url }}" class="drawer__checkout">去结账</a>
</div>
</aside>
<div class="drawer__scrim" data-drawer-scrim></div>此处有两个值得注意的细节:行项目使用的是 item.final_line_price 而不是 item.line_price。虽然两者都存在于购物车中,但 final_line_price 会反映行级折扣,因此这是客户实际需要支付的金额。此外,每个 <li> 都带有 data-line-key 和 data-quantity 属性,后续的数量和移除控件会读取这些数据。
添加按钮位于您的产品卡片或产品页面上,它直接从 Liquid 中携带变体 ID,确保每次都能添加正确的变体:
{%- comment -%} 在产品卡片 / 产品详情页中 {%- endcomment -%}
<button
type="button"
class="pdp__add"
data-add
data-variant-id="{{ product.selected_or_first_available_variant.id }}"
>
加入购物车
</button>第二步:无需刷新页面添加到购物车
用一句话概括整个模式:先修改购物车,然后重新读取购物车,最后渲染并打开抽屉。具体来说,就是向 /cart/add.js 发送 POST 请求添加变体,通过 GET /cart.js 读取整个购物车,然后根据返回结果渲染抽屉。
// assets/cart-drawer.js
document.querySelectorAll("[data-add]").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = Number(btn.getAttribute("data-variant-id"));
fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: id, quantity: 1 }),
})
.then(function (r) { return r.json(); })
.then(function () { return refresh(); }) // 重新读取 /cart.js
.then(openDrawer); // 然后滑入抽屉
});
});为什么在 /cart/add.js 已经返回数据的情况下还要重新读取购物车?因为 add.js 的响应只描述了刚刚添加的行项目,而不是整个购物车,而抽屉显示的是完整的购物车内容。
更重要的是,Shopify 是最终决定购物车内容的唯一来源。它可能会将你新增的商品行合并到现有行中、自动应用折扣,或者拒绝售罄的变体。唯一能确保抽屉内容与现实一致的方式,就是直接向现实发起请求。这就是 refresh() 函数的作用,也是这个文件中唯一被允许操作购物车 UI 的函数:
function refresh() {
return fetch("/cart.js").then(function (r) { return r.json(); }).then(render);
}这里的两个端点(/cart/add.js 和 /cart.js)都属于 Shopify 的 Ajax API,该接口在所有商店前端都可用,无需任何配置。
修改数据后重新读取,然后渲染。这个循环逻辑同样适用于添加商品、修改数量和移除商品的操作。
第三步:从服务器的真实数据渲染抽屉
render(cart) 函数接收 /cart.js 的响应数据,并据此绘制抽屉内容。请注意它没有做的事情:它从不自行计算总价或递增计数器。而是直接从 Shopify 返回的对象中读取 item_count 和 total_price 属性。
function money(cents) {
return "$" + (cents / 100).toFixed(2);
}
function render(cart) {
document.querySelector("[data-cart-count]").textContent = cart.item_count;
document.querySelector("[data-cart-subtotal]").textContent = money(cart.total_price);
var itemsEl = document.querySelector("[data-drawer-items]");
var emptyEl = document.querySelector("[data-drawer-empty]");
itemsEl.innerHTML = "";
if (!cart.items.length) { emptyEl.style.display = "block"; return; }
emptyEl.style.display = "none";
cart.items.forEach(function (line) {
var li = document.createElement("li");
li.className = "drawer__item";
li.setAttribute("data-line", "");
li.setAttribute("data-line-key", line.key);
li.setAttribute("data-quantity", line.quantity);
li.innerHTML =
'<img class="drawer__item-img" src="' + (line.image || "") + '" alt="">' +
'<span class="drawer__item-title">' + line.title + "</span>" +
'<span class="drawer__item-price">' + money(line.final_line_price) + "</span>";
itemsEl.appendChild(li);
});
}最容易出错的地方是货币转换。Ajax API 返回的价格单位是分(cents)。价值 $18.99 的商品会返回 1899。如果忘记除以 100,就可能把价值 $18.99 的咖啡误发成 $1,899。money() 辅助函数的作用就是在一个统一的位置完成这个转换。
一个生产环境注意事项:这里使用的是 final_line_price 属性,它反映了行级折扣后的价格,因此是用户实际支付的金额(line_price 是折扣前的金额)。在多货币商店中,应将自定义的 money() 函数替换为 Shopify 的货币格式化接口,以确保货币符号和小数位数符合当前市场的规范。
抽屉内容完全基于 /cart.js 的响应数据渲染。数量、商品行和小计都来自服务器端。
第四步:使用事件委托实现数量增减功能
每个抽屉都允许用户调整商品数量。显然的实现方式是遍历所有加减按钮并为每个按钮附加点击事件处理程序。但这样做会导致在第一次修改后控件失效。
原因如下:每次购物车变更时都会重新渲染列表。这会替换原有的 <li> 元素,而你之前附加的事件处理程序会随着旧元素一同消失。新按钮没有监听器。
解决方法是使用事件委托。将一个监听器附加到永远不会被替换的父元素(<ul>),当点击事件冒泡上来时,检查实际被点击的元素。一个事件处理程序就能持续工作,因为它的绑定对象始终存在。
var itemsEl = document.querySelector("[data-drawer-items]");
itemsEl.addEventListener("click", function (e) {
var inc = e.target.closest("[data-qty-inc]");
var dec = e.target.closest("[data-qty-dec]");
if (!inc && !dec) return;
var line = e.target.closest("[data-line]");
var key = line.getAttribute("data-line-key");
var qty = Number(line.getAttribute("data-quantity"));
var nextQty = inc ? qty + 1 : qty - 1;
fetch("/cart/change.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: key, quantity: nextQty }),
})
.then(function (r) { return r.json(); })
.then(render);
});对于这个版本,render() 会在每行中渲染步进器控件:
li.innerHTML =
'<img class="drawer__item-img" src="' + (line.image || "") + '" alt="">' +
'<span class="drawer__item-title">' + line.title + "</span>" +
'<span class="drawer__item-qty">' +
'<button class="qty-btn" data-qty-dec aria-label="Decrease">-</button>' +
'<span class="qty-value">' + line.quantity + "</span>" +
'<button class="qty-btn" data-qty-inc aria-label="Increase">+</button>' +
"</span>" +
'<span class="drawer__item-price">' + money(line.final_line_price) + "</span>";一个容易出错的细节:修改请求发送的是 id: key(即行键),而不是变体 ID。当购物车中存在具有不同行项目属性(如刻字、礼物留言)的相同变体时,购物车可能包含两个独立的行。键是唯一标识单个行的字段,因此 /cart/change.js 接口需要的就是这个键。
现在以及每次重新渲染后,一个委托监听器会驱动所有行的步进器。
第5步:移除一行并清空购物车
新开发者可能会寻找 /cart/remove.js 接口,但该接口并不存在。在 Shopify 的购物车 API 中,移除一行的操作是通过将数量修改为零来实现的,这与之前用于步进器的 /cart/change.js 路由相同。清空整个购物车有独立的接口 /cart/clear.js,该接口不需要请求体。
// 移除:一个委托监听器,数量设为0会删除该行。
itemsEl.addEventListener("click", function (e) {
var remove = e.target.closest("[data-remove]");
if (!remove) return;
var key = e.target.closest("[data-line]").getAttribute("data-line-key");
fetch("/cart/change.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: key, quantity: 0 }),
})
.then(function (r) { return r.json(); })
.then(render);
});
// 清空:清空整个购物车,不需要请求体。
document.querySelector("[data-cart-clear]").addEventListener("click", function () {
fetch("/cart/clear.js", { method: "POST" })
.then(function (r) { return r.json(); })
.then(render);
});所有内容都从服务器响应重新渲染,与其他部分保持一致。这种严谨的处理方式可以防止移除的物品在数量中残留:你不会直接从 DOM 中移除 <li> 元素并寄希望于它消失。而是先告诉服务器,再根据服务器的反馈进行渲染。
第6步:使用 Section Rendering API 重新渲染(可发布的版本)
到目前为止的所有实现都是通过 JavaScript 重新构建抽屉的 HTML。虽然这种方法可行,但需要付出高昂的代价:抽屉的标记现在存在于两个地方,一次是第1步中的 Liquid 代码,另一次是 render() 函数中的模板字符串。如果需要更改设计,就必须同步修改这两个地方,这种维护成本会持续存在。
Shopify 的解决方案是采用捆绑部分渲染(bundled section rendering)技术。你通过向购物车接口发起请求,要求其在修改购物车的同一请求中返回重新渲染后的部分 HTML。这部分的标记语言仅存在于 Liquid 中,服务器会直接将最终生成的 HTML 返回给你,你只需将其插入到页面中即可。
你可以通过在购物车请求中添加 sections 参数来启用该功能:
fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: variantId,
quantity: 1,
sections: "cart-drawer", // 部分 ID(逗号分隔)
sections_url: window.location.pathname // 可选的渲染上下文
}),
})
.then(function (r) { return r.json(); })
.then(function (cart) {
// Shopify 在 JSON 响应的 sections 字段中返回渲染后的 HTML,按部分 ID 键值对应
var html = cart.sections["cart-drawer"];
if (html) {
document.querySelector("[data-drawer]").innerHTML =
new DOMParser().parseFromString(html, "text/html")
.querySelector("[data-drawer]").innerHTML;
}
openDrawer();
});需要了解的关键信息均来自 Ajax Cart API 参考文档和 Section Rendering API 文档:
- 捆绑部分渲染功能适用于
/cart/add、/cart/change、/cart/clear和/cart/update接口。 - sections 参数可以是逗号分隔的字符串或数组形式,最多支持五个部分 ID。
- sections_url 必须以
/开头。如果省略该参数,部分渲染将基于当前页面的上下文(通过 Referer 头确定)。 - 渲染后的 HTML 会包含在 JSON 响应的 sections 字段中,按部分 ID 键值对应。
- 渲染失败的部分(包括不存在的部分)会以 null 形式返回,HTTP 状态码仍为 200。务必始终检查 null 值。
- 部分 ID 在 Liquid 中对应 section.id,或包装器上的 id="shopify-section-[id]"。对于通过文件名渲染的部分(例如 theme.liquid 中的
{% section 'cart-drawer' %}),ID 为 cart-drawer。在 JSON 模板中,ID 会动态生成为 template--123__cart-drawer,因此在硬编码键值前应先检查包装器。
普通的 Section Rendering API(通过在任意页面 URL 后添加 ?sections= 或 ?section_id= 的 GET 请求)对于非购物车操作(如分页搜索或无限滚动)也采用相同原理。Shopify 官方建议:对于任何由购物车变更驱动的功能,优先使用捆绑部分渲染而非单独请求,因为这可以节省一次往返通信。
这不是一个简单的技术模式。Shopify 自家的 Horizon 主题正是通过这种方式驱动其购物车:其购物车组件在 /cart/change.js 请求中发送 sections 列表,并从 response.sections 中重新渲染,通过读取每个部分的 data 属性获取 ID,而非硬编码。这正是上述注意事项的生产环境实现:永远不要假设 ID 是 cart-drawer 这样的裸字符串。应从渲染后的包装器中读取 ID。
2026 年升级:标准商店前端事件和操作
以上内容属于 Shopify 的永恒特性。现在进入全新的部分。
2026 年 6 月 17 日,作为 Spring '26 版本的一部分,Shopify 推出了标准商店前端事件和操作(standard storefront events and actions),现已全面上线。
核心理念是:主题现在会触发一组标准化的 DOM 事件(所有事件名称均以 shopify: 为命名空间),并在 Shopify.actions 上暴露一组标准化的操作。现在,任何应用或 AI 购物助手都可以通过统一的接口与任意商店前端进行交互,而无需反向工程每个主题的私有 JavaScript。
要理解这为何重要,可以想象一下以往的推荐产品应用如何检测从未见过的主题上的添加到购物车操作。
它有四个糟糕的选项:通过猴子补丁修改 fetch 以嗅探对 /cart/add 的调用,监听特定主题的自定义事件(但事件名称会随着主题变化),定时轮询 /cart.js 并进行差异比较,或者通过选择器在 DOM 中抓取购物车计数节点。当商家重新设计主题或更换主题时,所有方法都会失效。
这也是当 AI 助手被要求"在添加到购物车后执行某操作"时,倾向于生成的代码。因为这种脆弱的模式在它的训练数据中占据主导地位。
问题从来不是开发者疏忽。只是根本不存在稳定的接口可供使用。
四种针对特定主题的脆弱策略,最终整合为一个能抵御主题更换的契约接口。
关键的是,这一层构建在你刚刚开发的抽屉组件之上。它不会取代 Ajax API。文档甚至展示了主题端事件封装了相同的 /cart/add.js 和 /cart.js 调用,Shopify 还提供了一个专用助手,其唯一职责是将 /cart.js 响应转换为事件的 payload 格式。因此你的所有工作都不会浪费。你即将为它打开一扇公共入口。
事件:主题向世界宣告发生了什么
每个事件都使用 shopify: 命名空间,遵循 category:action 的命名模式,从最具体的元素(产品卡片、购物车、集合容器)派发,并向 document 泡泡传播。payload 遵循 Storefront GraphQL API 的结构,使用 camelCase 字段。
事件 | 触发时机 --- | ---
shopify:page:view每次页面加载
shopify:product:view产品变得可见
shopify:product:select买家更改变体选择
shopify:cart:view购物车变得可见
shopify:cart:lines-update购物车行项目被添加、更新或移除
shopify:cart:note-update购物车备注变更
shopify:cart:discount-update折扣码被应用或移除
shopify:cart:error购物车变更失败
shopify:collection:view集合页面加载
shopify:collection:update集合筛选或排序变更
shopify:search:update搜索筛选或排序变更
应用通过普通 DOM API 订阅事件。无需 SDK:
document.addEventListener('shopify:cart:lines-update', (event) => {
console.log(event.action, event.lines);
event.promise?.then(({ cart }) => {
console.log(cart.cost.totalAmount.amount);
});
});购物车、备注、折扣、产品选择、集合更新和搜索更新事件都携带 promise 字段用于异步结果。这使监听器能立即显示加载状态或乐观状态,然后在操作完成时读取已解决的 { cart } 数据。
从你的主题中发出事件
事件库托管在 Shopify CDN 上。你可通过导入映射(适用于 Horizon 等模块化主题)或全局变量(适用于 Dawn 风格主题)加载它。
主题通过构造事件类并调用 dispatchEvent() 来触发事件。仔细阅读后你会发现,它封装了第 2 步中提到的相同 /cart/add.js 和 /cart.js 调用:
import { CartLinesUpdateEvent, CartErrorEvent } from '@theme/standard-events';
const deferred = CartLinesUpdateEvent.createPromise();
element.dispatchEvent(new CartLinesUpdateEvent({
action: 'add',
context: 'product',
lines: [{ merchandiseId: variantId, quantity: 1 }],
promise: deferred.promise,
}));try {
const response = await fetch(window.Shopify.routes.root + 'cart/add.js', { method: 'POST', body, headers });
if (!response.ok) throw new Error('Add to cart failed');
const ajaxCart = await fetch(window.Shopify.routes.root + 'cart.js').then(r => r.json());
deferred.resolve({
cart: CartLinesUpdateEvent.createCartFromAjaxResponse(ajaxCart),
});
} catch (e) {
element.dispatchEvent(new CartErrorEvent({ error: e.message, code: 'SERVICE_UNAVAILABLE' }));
deferred.reject(e);
}那个静态的 createCartFromAjaxResponse(ajaxCart) 方法是经典 Ajax 抽屉与新事件契约之间的桥梁。它会将你的 /cart.js 响应转换为事件期望的 Storefront-API 格式负载,因此你已有的抽屉可以直接接入。
动作:世界要求主题执行某些操作
动作是 Shopify.actions 上的异步函数,通过每个 Liquid 商店前端注入,无需你自己的脚本标签:
// 添加、更新或移除商品行。也支持备注和优惠码。
const { cart, userErrors, warnings } = await Shopify.actions.updateCart({
lines: [
{ merchandiseId: "gid://shopify/ProductVariant/123", quantity: 1 }, // 添加
{ id: "gid://shopify/CartLine/456", quantity: 5 }, // 更新
{ id: "gid://shopify/CartLine/789", quantity: 0 }, // 移除
],
});
// 返回: Promise<{ cart, userErrors?, warnings? }>
await Shopify.actions.openCart(); // Promise<void>
const { cart } = await Shopify.actions.getCart(); // 读取当前购物车默认行为在未修改的标准主题上有效:updateCart 会写入 Storefront API 并原地刷新,若失败则回退到整页刷新。openCart 会打开现有的 <cart-drawer-component> 或 <cart-drawer> 元素,否则重定向到 /cart。getCart 读取当前购物车。当配置的动作成功时,运行时会自动发出匹配的事件,因此调用 updateCart 的应用无需再手动触发 shopify:cart:lines-update 事件。
覆盖动作以控制你的抽屉
这才是真正的价值所在。由于主题可以覆盖动作的默认行为,你可以拦截 openCart 和 updateCart,使任何应用的调用都通过你已构建的抽屉进行处理,而不是触发页面刷新。应用不需要了解你的标记结构。它调用标准动作,而你的覆盖决定 UI 的行为。
在布局中 {{ content_for_header }} 之上放置一个 DOMContentLoaded 监听器内注册覆盖,确保它在任何应用代码之前运行:
document.addEventListener('DOMContentLoaded', () => {
Shopify.actions.updateCart.configure({
eventTarget: (meta) => {
if (meta.type === 'shopify:cart:note-update') return document.querySelector('cart-note');
if (meta.type === 'shopify:cart:discount-update') return document.querySelector('cart-discount');
if (meta.type === 'shopify:cart:lines-update' && meta.action === 'add') {
return document.querySelector('product-form');
}
return document.querySelector('cart-items');
},
async handler(defaultHandler, payload, options) {
const result = await defaultHandler();
customUpdateUI(result); // 你之前实现的 render() + openDrawer()
return result;
},
});
});openCart 的覆盖更简单:
Shopify.actions.openCart.configure({
handler() { document.querySelector('cart-drawer')?.open(); },
});一些能节省你时间的规则:
- updateCart 需要 eventTarget,它决定了自动发出的事件从哪个元素发出。
- getCart 故意设计为不可配置。对其调用 configure() 会触发 TypeScript 错误和运行时 TypeError。
- isDefault() 用于判断主题是否已覆盖某个操作。
- updateCart 的解析结果包含 { cart, userErrors?, warnings?, detail? },仅在完全无法执行时(网络故障或格式错误的负载)才会拒绝。userErrors 数组表示突变操作被拒绝(如 INVALID、MAXIMUM_EXCEEDED)。warnings 数组表示操作成功但存在注意事项(如 MERCHANDISE_OUT_OF_STOCK、DISCOUNT_NOT_FOUND)。在信任购物车数据前,请同时检查这两个字段。
应用会调用标准操作且完全不了解你的标记代码。你的覆盖决定了用户界面的表现。
验证方法
运行 shopify theme dev 命令时,CLI 会加载事件运行时的开发构建版本,该版本会验证负载数据并在字段格式错误或缺失时记录警告。
这些检查在生产环境会被移除。添加 --standard-events-inspector 标志后,它会在本地页面注入一个浮动调试面板,包含两个标签页:Events 标签页会实时显示所有发出的标准事件及其完整负载,Actions 标签页允许手动触发操作并检查结果。在连接负载数据时,请始终优先信任调试面板而非任何教程(包括本文)。
为什么这很重要
这个构建中包含的两个理念会超越具体代码本身,它们决定了抽屉组件是能正常工作还是可维护的。
事件委托
一个父元素上的监听器永远不会被替换,通过读取 e.target.closest(...) 处理所有子元素:当前页面上的元素以及尚未渲染的元素。如果改为每个按钮单独绑定处理程序,那么在列表重新渲染时(购物车抽屉会频繁发生),这些处理程序会立即失效。
巧合的是,事件委托也是 AI 工具最容易出错的模式,因为它们的训练数据中大量出现的是逐元素绑定。知道何时使用事件委托,正是语法无法提供的判断力。
区块渲染 API
无需在两个位置维护抽屉的标记代码并祈祷它们保持同步,而是让服务器渲染区块并返回 HTML。你的标记代码只存在于一个文件中,当商家在主题编辑器中修改区块时,标记代码会始终保持正确。
代价是响应体积略微增大且需要增加解析步骤,但换来的好处是永远不需要维护两份相同的标记代码。
在所有这些基础之上,有一条规则:每次突变操作后,都要重新读取购物车(或渲染后的区块),并根据服务器响应重新绘制。本地计数器就是 bug 的根源。本文所有其他内容都是对"信任服务器"这一原则的不同变体。
完整文件
对于直接跳转到这里的人,这就是全部内容:区块标记代码、JavaScript 和 CSS。JavaScript 集成了添加、数量修改、移除和清空功能,所有操作都基于服务器响应重新渲染。
sections/cart-drawer.liquid :
<button type="button" class="cart-toggle" data-cart-toggle>
Cart <span class="cart-count" data-cart-count>{{ cart.item_count }}</span>
</button>
<aside class="drawer" data-drawer aria-label="Cart" aria-hidden="true">
<div class="drawer__head">
<h2>Your cart</h2>
<button type="button" class="drawer__close" data-drawer-close aria-label="Close cart">×</button>
</div><div class="drawer__body">
<p class="drawer__empty" data-drawer-empty {% if cart.item_count > 0 %}style="display:none"{% endif %}>
您的购物车为空。
</p>
<ul class="drawer__items" data-drawer-items>
{%- for item in cart.items -%}
<li class="drawer__item" data-line data-line-key="{{ item.key }}" data-quantity="{{ item.quantity }}">
{{ item.image | image_url: width: 96 | image_tag: class: 'drawer__item-img', loading: 'lazy', alt: item.product.title }}
<span class="drawer__item-title">{{ item.product.title }}</span>
<span class="drawer__item-qty">
<button type="button" class="qty-btn" data-qty-dec aria-label="减少">-</button>
<span class="qty-value">{{ item.quantity }}</span>
<button type="button" class="qty-btn" data-qty-inc aria-label="增加">+</button>
</span>
<span class="drawer__item-price">{{ item.final_line_price | money }}</span>
<button type="button" class="drawer__remove" data-remove aria-label="移除">移除</button>
</li>
{%- endfor -%}
</ul>
</div>
<div class="drawer__foot">
<div class="drawer__subtotal">
<span>小计</span>
<span data-cart-subtotal>{{ cart.total_price | money }}</span>
</div>
<button type="button" class="drawer__clear" data-cart-clear>清空购物车</button>
<a href="{{ routes.cart_url }}" class="drawer__checkout">去结算</a>
</div>
</aside>
<div class="drawer__scrim" data-drawer-scrim></div>
{% schema %}
{ "name": "Cart drawer" }
{% endschema %}assets/cart-drawer.js :
(function () {
var countEl = document.querySelector("[data-cart-count]");
var itemsEl = document.querySelector("[data-drawer-items]");
var emptyEl = document.querySelector("[data-drawer-empty]");
var subtotalEl = document.querySelector("[data-cart-subtotal]");
var drawer = document.querySelector("[data-drawer]");
var scrim = document.querySelector("[data-drawer-scrim]");
var clearBtn = document.querySelector("[data-cart-clear]");
// --- 添加到购物车:修改,重新读取,渲染,打开 ---
document.querySelectorAll("[data-add]").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = Number(btn.getAttribute("data-variant-id"));
fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: id, quantity: 1 }),
})
.then(function (r) { return r.json(); })
.then(function () { return refresh(); })
.then(openDrawer);
});
});
// --- 数量增减:在稳定的列表上使用一个委托监听器 ---
itemsEl.addEventListener("click", function (e) {
var inc = e.target.closest("[data-qty-inc]");
var dec = e.target.closest("[data-qty-dec]");
if (!inc && !dec) return;
var line = e.target.closest("[data-line]");
var key = line.getAttribute("data-line-key");
var qty = Number(line.getAttribute("data-quantity"));
var nextQty = inc ? qty + 1 : qty - 1;
fetch("/cart/change.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: key, quantity: nextQty }),
}).then(function (r) { return r.json(); }).then(render);
});// --- 删除一行:数量设为 0(没有 /cart/remove.js)---
itemsEl.addEventListener("click", function (e) {
var remove = e.target.closest("[data-remove]");
if (!remove) return;
var key = e.target.closest("[data-line]").getAttribute("data-line-key");
fetch("/cart/change.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: key, quantity: 0 }),
}).then(function (r) { return r.json(); }).then(render);
});
// --- 清空整个购物车 ---
clearBtn.addEventListener("click", function () {
fetch("/cart/clear.js", { method: "POST" })
.then(function (r) { return r.json(); })
.then(render);
});
// --- 用于从购物车绘制抽屉界面的函数 ---
function money(cents) { return "$" + (cents / 100).toFixed(2); }
function render(cart) {
countEl.textContent = cart.item_count;
subtotalEl.textContent = money(cart.total_price);
itemsEl.innerHTML = "";
if (!cart.items.length) { emptyEl.style.display = "block"; return; }
emptyEl.style.display = "none";
cart.items.forEach(function (line) {
var li = document.createElement("li");
li.className = "drawer__item";
li.setAttribute("data-line", "");
li.setAttribute("data-line-key", line.key);
li.setAttribute("data-quantity", line.quantity);
li.innerHTML =
'<img class="drawer__item-img" src="' + (line.image || "") + '" alt="">' +
'<span class="drawer__item-title">' + line.title + "</span>" +
'<span class="drawer__item-qty">' +
'<button type="button" class="qty-btn" data-qty-dec aria-label="Decrease">-</button>' +
'<span class="qty-value">' + line.quantity + "</span>" +
'<button type="button" class="qty-btn" data-qty-inc aria-label="Increase">+</button>' +
"</span>" +
'<span class="drawer__item-price">' + money(line.final_line_price) + "</span>" +
'<button type="button" class="drawer__remove" data-remove aria-label="Remove">Remove</button>';
itemsEl.appendChild(li);
});
}
function refresh() {
return fetch("/cart.js").then(function (r) { return r.json(); }).then(render);
}
function openDrawer() { drawer.classList.add("is-open"); scrim.classList.add("is-open"); }
function closeDrawer() { drawer.classList.remove("is-open"); scrim.classList.remove("is-open"); }
document.querySelector("[data-cart-toggle]").addEventListener("click", function () { refresh().then(openDrawer); });
document.querySelector("[data-drawer-close]").addEventListener("click", closeDrawer);
scrim.addEventListener("click", closeDrawer);
refresh(); // 页面加载时绘制
})();assets/cart-drawer.css :
.drawer {
position: fixed;
inset: 0 0 0 auto;
width: min(420px, 100%);
background: #fff;
transform: translateX(100%);
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
}
.drawer.is-open { transform: translateX(0); }
.drawer__scrim {
position: fixed; inset: 0;
background: rgba(30, 18, 6, 0.45);
opacity: 0; pointer-events: none;
transition: opacity 0.3s ease;
}
.drawer__scrim.is-open { opacity: 1; pointer-events: auto; }总结
你现在拥有一个无需页面刷新即可添加、更新、删除和清空购物车的抽屉组件,其界面始终与真实购物车保持同步。通过操作覆盖,它还能为整个应用生态系统(包括人类和AI代理)提供一个干净的公共接口。
Ajax 的基础多年来一直保持不变。2026 层构建于其之上,因此你今天构建的组件明天就能应对任何调用它的场景。
如果你想交互式地构建这个组件,编写 JavaScript 并实时查看商店前端的反应,可以在 learnshopify.dev 免费体验。
阅读更多文章。
如果这篇文章对你有帮助,请分享它。
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始
ADVERTISEMENT