欢迎回来
登录你的知识库账户
忘记密码?
还没有账户?立即注册
创建账户
注册你的专属知识库
已有账户?去登录
找回密码
输入注册邮箱获取验证码
返回登录
请输入图片中的验证码以继续注册
加载中...
取消
新建收藏
手动添加你喜欢的内容
取消
编辑头像与昵称
上传新头像或修改你的显示昵称
支持 JPG/PNG,最大 2MB
取消

问题反馈

notebasewww.notebase.cn
控制台
内容库
动态
管理
账户
U
用户
--
在线
v0.8.7 · 知识库
笔记
KnowledgeBase
网络无边,知识有迹。
0笔记
0工具
30推荐

分类导航

按主题直达

编辑精选

站内用户贡献 · 真实笔记

最新收录

每日更新
继续浏览全部内容 →
>
笔记
0
加载中...
工具
0
此页用于记录用户反馈问题后的每一次改进
笔记用法

“写笔记”支持四种格式——Word 文档、Excel 表格、Markdown、纯文本,起稿或二次编辑时都能随时切换,同一篇笔记想用哪种形态来记,都由你说了算。

md、txt、csv、json 这类纯文本则原样载入,不做多余加工。拿一张现成的表倒进来、改几笔、再导出去,等于白用一台免费的格式转换器。

要带走就在右上角点“下载”,可导出 PDF、Word、Markdown、Excel、TXT 等格式;列表卡片“⋯”菜单里,也有同样的下载入口。

工具用法

在“工具”页点“+ 上传工具”即可发布:填好名称与链接,再用 Markdown 把使用方法写清楚——能解决什么问题、怎么装、怎么用,比堆介绍实在。

要分发安装包就一并上传压缩包(ZIP、RAR、7Z、TAR.GZ,最大 35MB),别人在详情页一键下载;只放链接不带附件也可以。

工具按大家的收藏热度排序,好用的自然会被顶上来。发布后可在详情页或卡片菜单里编辑、下架。

隐藏笔记

写笔记时勾上“隐藏”,这篇就只存在于你自己的账号里:不进列表、不进搜索、不上首页精选,也不会出现在任何公开的页面,链接发给别人同样打不开。

适合放密码、草稿、日记这类只给自己看的内容;想公开,去“发布”打开它,把“隐藏”的勾去掉再保存,之后编辑会默认保持原状态,不会悄悄变回公开。

数据安全

你的内容会同时保存在多个副本上,系统定期做备份与完整性校验,再配合异地容灾机制:就算某台机器出问题,数据也不会丢,可以长期放心存放;特别重要的资料,仍建议你另外再留一份备份。

技术

全站跑在容器化、模块化的现代架构上,更新、部署、回滚都很快,扩展性和稳定性都按长期运营的标准来设计(Built for reliability, designed to scale)。

理念

这个网站最早只是一个人的笔记仓库,后来慢慢长成现在的知识中枢。设计上很克制——没有广告、没有追踪、没有推荐算法,只是干干净净地存放一些东西;既然做好了,就公开出来,万一有人用得上呢。

原则

不做大而全,不做平台梦,保持简单、保持克制、保持好奇。所有内容都由用户贡献、由用户维护:不会突然冒出付费墙,不会在角落塞广告位,也不会把你的数据卖给第三方。

更多

产品会持续迭代,站内日志页记录着每一次改动,改了什么都有迹可循;想了解这个站是怎么一步步走到今天的,翻翻日志就能看到来龙去脉。

举报

如果在这里看到涉嫌违规的内容,点对应卡片右侧的“举报”按钮就能提交,我们会尽快核实处理;也谢谢你花一点时间,一起把这里维护干净。

趋势
// 点击导航加载发现
归档
// 归档为空
最近浏览
// 暂无浏览记录
发布
// 加载中...
用户发布
// 加载中...
用户管理
// 加载中...
访问统计
// 加载中...
内容审核
// 加载中...
个人信息
// 加载中...
返回首页

如何用AJAX构建Shopify购物车抽屉(2026版)

2026/7/3编程开发

最近在给一个Shopify店铺做优化,发现大多数教程和AI工具生成的购物车抽屉都太脆弱了——它们用JavaScript变量维护一个计数器,然后手动更新DOM。这在demo里看着挺好,但一旦遇到订单合并、折扣生效或者变体售罄,立马就崩了。今天我就分享一个更靠谱的做法,服务器永远是唯一的数据源,外加2026年的新玩法,让你的抽屉能和App以及AI购物代理“说同一种语言”。

先说说为什么要做这个。默认情况下,在Shopify店铺里点击“加入购物车”,整个页面会重新加载。想象一下,顾客正盯着你的产品,点一下“加入购物车”,然后浏览器把当前页面扔掉,重新构建——在慢速网络下,这就意味着两三秒的空白屏幕。有时候甚至直接跳转到 /cart 页面,顾客完全离开了他们正在浏览的商品,购买冲动瞬间就没了。

购物车抽屉就是解决这个问题的。它是一个滑出的面板,顾客添加商品后自动出现:购物车实时更新,“结算”按钮就在眼前,顾客永远不会离开当前页面。几乎所有高转化率的Shopify店铺都有这个功能,而且你完全不需要安装App就能实现——只需要两个Ajax端点,加上不到一百行JavaScript代码。

如果你想要跟着做,先打开一个可以编辑的开发主题,我们一步步搭建。所有代码都基于标准的Online Store 2.0主题(Horizon或Dawn),不安装任何App。

目录

  • 你要构建什么
  • 第一步:抽屉的标记结构
  • 第二步:无刷新添加购物车
  • 第三步:从服务器数据源渲染抽屉
  • 第四步:数量加减与事件委托
  • 第五步:删除商品行与清空购物车
  • 第六步:用Section Rendering API重新渲染(可上生产环境的版本)
  • 2026升级:标准店铺前端事件与操作
  • 为什么这很重要
  • 完整文件
  • 总结

你要构建什么

最终,你会得到一个具备以下功能的抽屉:

  • 通过Ajax添加购物车,无需页面刷新
  • 每次变更后重新读取购物车,把服务器返回的数据当作唯一真实来源
  • 自行渲染内容并滑出
  • 用单个委托监听器处理数量加减
  • 支持删除商品行和清空购物车
  • 最终升级版:通过Shopify新的标准店铺前端操作暴露自身,让App和AI代理能够驱动它

前提条件

  • 一个可编辑的Shopify主题(示例基于Online Store 2.0,如Horizon或Dawn)
  • 熟悉 fetch 和 Promise
  • 基本的Liquid知识
  • 不需要App、不需要框架、不需要构建步骤

第一步:抽屉的标记结构

我们要把抽屉做成一个section,这样它会在每个页面都渲染,并且在JavaScript运行之前就用Liquid在服务器端渲染出当前购物车的内容。这一点很重要:如果顾客到达时购物车里已经有商品,抽屉在首次渲染时就是正确的,JavaScript只需要在变更后更新它。这就是渐进增强,也是大多数自己动手做的方案所忽略的。

下面这些 data-* 属性是标记和脚本之间的约定。JavaScript要操作的所有元素,都通过这些属性来查找,从不依赖类名或标签位置。

{%- comment -%}
sections/cart-drawer.liquid
{%- endcomment -%}

<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">&times;</button>
  </div>
  
  <div class="drawer__body">
    <p class="drawer__empty" data-drawer-empty {% if cart.item_count > 0 %}style="display:none"{% endif %}>
      Your cart is empty.
    </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>Subtotal</span>
      <span data-cart-subtotal>{{ cart.total_price | money }}</span>
    </div>
    <p class="drawer__ship">Shipping &amp; taxes calculated at checkout.</p>
    <a href="{{ routes.cart_url }}" class="drawer__checkout">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 }}">
  Add to cart
</button>

第二步:无刷新添加购物车

整个模式可以用一句话概括:变更购物车,然后重新读取,再渲染并打开。具体来说,就是先 POST /cart/add.js 添加变体,再 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";
  
  // 遍历每个商品行并创建DOM元素
  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);
    
    // 商品图片
    var img = document.createElement("img");
    img.src = line.image;
    img.width = 96;
    img.alt = line.product_title;
    img.className = "drawer__item-img";
    li.appendChild(img);
    
    // 商品标题
    var title = document.createElement("span");
    title.className = "drawer__item-title";
    title.textContent = line.product_title;
    li.appendChild(title);
    
    // 商品价格
    var price = document.createElement("span");
    price.className = "drawer__item-price";
    price.textContent = money(line.line_price);
    li.appendChild(price);
    
    itemsEl.appendChild(li);
  });
}

这里有个关键点:money() 函数只是简单的美元格式化。如果你的店铺使用其他货币,需要相应调整。另外注意我用了 line.line_price 而不是 line.final_line_price 来显示——这里可以根据需求选择,但如果你在行级别有折扣,应该用 final_line_price。

第四步:数量加减与事件委托

传统的做法是为每个加减按钮单独绑定事件监听器,但这样在新增商品行后需要重新绑定。更好的做法是使用事件委托:在父容器上监听点击事件,然后根据点击的目标元素来判断是增加还是减少数量。

document.querySelector("[data-drawer-items]").addEventListener("click", function (e) {
  var target = e.target;
  var lineItem = target.closest("[data-line]");
  
  if (!lineItem) return;
  
  var key = lineItem.getAttribute("data-line-key");
  var currentQty = parseInt(lineItem.getAttribute("data-quantity"), 10);
  var newQty;
  
  if (target.hasAttribute("data-qty-minus")) {
    newQty = Math.max(0, currentQty - 1);
  } else if (target.hasAttribute("data-qty-plus")) {
    newQty = currentQty + 1;
  } else {
    return;
  }
  
  // 如果数量变为0,删除该商品行
  if (newQty === 0) {
    removeLineItem(key);
  } else {
    updateLineItem(key, newQty);
  }
});

这里用到了 closest() 方法,它会向上查找最近的匹配选择器的祖先元素。这样即使加减按钮嵌套在多层结构中也能正常工作。

对应的 updateLineItem 和 removeLineItem 函数如下:

function updateLineItem(key, quantity) {
  fetch("/cart/change.js", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      id: key,
      quantity: quantity
    }),
  })
  .then(function (r) {
    return r.json();
  })
  .then(render)
  .then(openDrawer);
}

function removeLineItem(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)
  .then(openDrawer);
}

注意这里我用了 /cart/change.js 端点,它接受一个 id(商品行的key)和 quantity。当 quantity 设为0时,相当于删除该商品行。这个端点比 /cart/update.js 更精确,因为它只修改指定的商品行,不会影响其他行。

第五步:删除商品行与清空购物车

除了通过数量减到0来删除商品行外,我们还应该提供一个显式的删除按钮。在商品行中添加一个删除按钮:

<li class="drawer__item" data-line data-line-key="{{ item.key }}" data-quantity="{{ item.quantity }}">
  <!-- 其他内容 -->
  <button type="button" data-remove aria-label="Remove item">&times;</button>
</li>

然后在事件委托中处理:

document.querySelector("[data-drawer-items]").addEventListener("click", function (e) {
  var target = e.target;
  var lineItem = target.closest("[data-line]");
  
  if (!lineItem) return;
  
  var key = lineItem.getAttribute("data-line-key");
  
  if (target.hasAttribute("data-remove")) {
    removeLineItem(key);
    return;
  }
  
  // 数量加减逻辑...
});

清空购物车则更简单,直接调用 /cart/clear.js:

function clearCart() {
  fetch("/cart/clear.js", {
    method: "POST"
  })
  .then(function (r) {
    return r.json();
  })
  .then(render)
  .then(closeDrawer);
}

注意清空购物车后我调用了 closeDrawer(),因为此时抽屉里没有商品了,显示空购物车没有意义。当然,你也可以选择不关闭,让顾客看到“您的购物车是空的”提示。

第六步:用Section Rendering API重新渲染(可上生产环境的版本)

到目前为止,我们一直在用JavaScript手动构建DOM元素。这在小规模场景下没问题,但如果你的商品行包含复杂的内容(比如自定义字段、捆绑销售信息、个性化选项),手动构建DOM会变得非常繁琐且容易出错。

Shopify的Section Rendering API提供了一个更好的方案:让服务器渲染HTML片段,然后直接插入到DOM中。这样你就不需要在JavaScript中处理HTML字符串拼接了。

首先,在 sections/cart-drawer.liquid 中添加一个用于渲染单个商品行的代码块:

{%- comment -%}
这个部分会被Section Rendering API调用
{%- endcomment -%}
{%- if request.design_mode == false -%}
  {%- if request.page_type == 'cart-drawer-item' -%}
    {%- assign item = cart.items | first -%}
    <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>
      <button type="button" data-remove aria-label="Remove item">&times;</button>
    </li>
  {%- endif -%}
{%- endif -%}

然后修改JavaScript中的 render 函数,使用Section Rendering API来获取渲染好的HTML:

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]");
  
  if (!cart.items.length) {
    itemsEl.innerHTML = "";
    emptyEl.style.display = "block";
    return;
  }
  
  emptyEl.style.display = "none";
  
  // 使用Section Rendering API获取渲染好的HTML
  var url = "/?sections=cart-drawer";
  fetch(url)
    .then(function (r) {
      return r.json();
    })
    .then(function (sections) {
      var html = sections["cart-drawer"];
      var parser = new DOMParser();
      var doc = parser.parseFromString(html, "text/html");
      var newItems = doc.querySelector("[data-drawer-items]");
      if (newItems) {
        itemsEl.innerHTML = newItems.innerHTML;
      }
    });
}

这个方法的优势在于:你可以在Liquid模板中使用所有Shopify的过滤器、自定义字段和逻辑,JavaScript只需要负责插入最终的HTML。而且Section Rendering API返回的是JSON对象,其中包含了每个section的渲染结果,你可以只提取需要的部分。

不过要注意,Section Rendering API会触发完整的页面渲染,对于高频操作(比如数量加减)来说可能有点慢。在实际项目中,你可以混合使用两种方法:初始渲染和添加商品时用Section Rendering API,数量加减时用JavaScript手动更新。

2026升级:标准店铺前端事件与操作

现在到了最酷的部分。2026年,Shopify引入了标准店铺前端事件(Standard Storefront Events),这是一个统一的事件系统,让不同组件之间可以互相通信,而不需要直接耦合。

想象一下这样的场景:你安装了一个AI购物助手App,它想在你的购物车抽屉中添加商品。如果没有标准事件,这个App需要知道你的JavaScript函数叫什么名字,或者需要直接操作DOM——这既脆弱又不安全。

有了标准事件,一切变得简单:

// 监听标准 add-to-cart 事件
document.addEventListener("shopify:add-to-cart", function (e) {
  var detail = e.detail;
  
  // detail.variantId 是变体ID
  // detail.quantity 是数量
  // detail.properties 是自定义属性
  
  fetch("/cart/add.js", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      id: detail.variantId,
      quantity: detail.quantity || 1,
      properties: detail.properties || {}
    }),
  })
  .then(function (r) {
    return r.json();
  })
  .then(function () {
    return refresh();
  })
  .then(openDrawer);
});

// 监听标准 cart-open 事件
document.addEventListener("shopify:cart-open", function () {
  refresh().then(openDrawer);
});

// 监听标准 cart-close 事件
document.addEventListener("shopify:cart-close", function () {
  closeDrawer();
});

同样,你的抽屉也可以触发标准事件,让其他组件知道发生了什么:

function openDrawer() {
  document.querySelector("[data-drawer]").setAttribute("aria-hidden", "false");
  document.querySelector("[data-drawer-scrim]").style.display = "block";
  document.body.style.overflow = "hidden";
  
  // 触发标准事件
  document.dispatchEvent(new CustomEvent("shopify:cart-opened", {
    detail: {
      timestamp: Date.now()
    }
  }));
}

function closeDrawer() {
  document.querySelector("[data-drawer]").setAttribute("aria-hidden", "true");
  document.querySelector("[data-drawer-scrim]").style.display = "none";
  document.body.style.overflow = "";
  
  // 触发标准事件
  document.dispatchEvent(new CustomEvent("shopify:cart-closed", {
    detail: {
      timestamp: Date.now()
    }
  }));
}

这里的关键点是 e.detail 属性。CustomEvent 的 detail 属性可以携带任意数据,这样事件的发送方和接收方之间就有了一个清晰的契约。比如,AI购物代理可以这样触发添加购物车:

// AI购物代理的代码
document.dispatchEvent(new CustomEvent("shopify:add-to-cart", {
  detail: {
    variantId: 123456789,
    quantity: 2,
    properties: {
      "Gift wrap": "Yes"
    }
  }
}));

你的抽屉代码不需要知道这个事件是从哪里来的——按钮点击、AI代理、还是其他App——它只需要监听并响应即可。

为什么这很重要

你可能会想,我为什么要费这么大劲用标准事件?直接用函数调用不是更简单吗?

短期来看确实如此。但考虑到Shopify生态系统的未来,标准事件有几个关键优势:

  1. 解耦:你的抽屉不需要知道谁在调用它。按钮、App、AI代理都可以通过同一套事件接口与你的抽屉交互。

  2. 可组合性:你可以把不同的功能模块组合在一起,而不需要修改彼此的代码。比如,你可以用一个“一键补货”App、一个“购物车推荐”App和一个AI购物助手,它们都通过标准事件与你的抽屉通信。

  3. 未来兼容:Shopify正在推动标准事件成为行业规范。如果你的代码遵循这个规范,未来新的App和工具就能直接兼容,而不需要你修改代码。

  4. 调试友好:标准事件可以在浏览器开发者工具中监听和调试。你可以打开控制台,输入 monitorEvents(document, 'shopify:') 来查看所有触发的事件。

完整文件

以下是完整的 cart-drawer.js 文件,包含了所有功能:

// assets/cart-drawer.js

(function () {
  'use strict';
  
  // ===== 工具函数 =====
  
  function money(cents) {
    return "$" + (cents / 100).toFixed(2);
  }
  
  // ===== 核心函数 =====
  
  function refresh() {
    return fetch("/cart.js")
      .then(function (r) {
        return r.json();
      })
      .then(render);
  }
  
  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);
      
      var img = document.createElement("img");
      img.src = line.image;
      img.width = 96;
      img.alt = line.product_title;
      img.className = "drawer__item-img";
      li.appendChild(img);
      
      var title = document.createElement("span");
      title.className = "drawer__item-title";
      title.textContent = line.product_title;
      li.appendChild(title);
      
      var price = document.createElement("span");
      price.className = "drawer__item-price";
      price.textContent = money(line.line_price);
      li.appendChild(price);
      
      var removeBtn = document.createElement("button");
      removeBtn.type = "button";
      removeBtn.setAttribute("data-remove", "");
      removeBtn.setAttribute("aria-label", "Remove item");
      removeBtn.innerHTML = "&times;";
      li.appendChild(removeBtn);
      
      itemsEl.appendChild(li);
    });
  }
  
  function updateLineItem(key, quantity) {
    fetch("/cart/change.js", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        id: key,
        quantity: quantity
      }),
    })
    .then(function (r) {
      return r.json();
    })
    .then(render)
    .then(openDrawer);
  }
  
  function removeLineItem(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)
    .then(openDrawer);
  }
  
  function clearCart() {
    fetch("/cart/clear.js", {
      method: "POST"
    })
    .then(function (r) {
      return r.json();
    })
    .then(render)
    .then(closeDrawer);
  }
  
  // ===== UI 控制 =====
  
  function openDrawer() {
    document.querySelector("[data-drawer]").setAttribute("aria-hidden", "false");
    document.querySelector("[data-drawer-scrim]").style.display = "block";
    document.body.style.overflow = "hidden";
    
    document.dispatchEvent(new CustomEvent("shopify:cart-opened", {
      detail: { timestamp: Date.now() }
    }));
  }
  
  function closeDrawer() {
    document.querySelector("[data-drawer]").setAttribute("aria-hidden", "true");
    document.querySelector("[data-drawer-scrim]").style.display = "none";
    document.body.style.overflow = "";
    
    document.dispatchEvent(new CustomEvent("shopify:cart-closed", {
      detail: { timestamp: Date.now() }
    }));
  }
  
  // ===== 事件绑定 =====
  
  // 添加购物车按钮
  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);
    });
  });
  
  // 打开/关闭抽屉
  document.querySelector("[data-cart-toggle]").addEventListener("click", function () {
    var drawer = document.querySelector("[data-drawer]");
    if (drawer.getAttribute("aria-hidden") === "true") {
编写使用方法
Markdown 格式 · Ctrl+Enter 确定
新建笔记
预览
数据表格
点击单元格编辑 · Tab 移动
A1fx
Sheet1
BIH1H2≡🔗</>
隐私提醒

取消
编辑工具
取消