购物车代码实战:保存购物车数据并在页面刷新后恢复

技术教程5小时前发布 jdkjadmin
0 00
购物车代码实战:保存购物车数据并在页面刷新后恢复

购物车代码实战:保存购物车数据并在页面刷新后恢复

购物车通常需要跨越页面刷新继续存在:用户浏览商品、切换页面或暂时关闭标签页后,回来仍能看到已选商品。实现这项功能并不复杂,但“把数据存起来”只是其中一部分。还需要考虑存什么、何时保存、数据损坏怎么办,以及怎样避免把浏览器中的数据误当成可信的订单信息。

本文用原生 HTML 和 JavaScript 实现一个可直接运行的购物车。示例把商品编号和数量保存在 localStorage 中;页面加载时恢复购物车,并根据商品目录重新计算显示内容。代码不依赖框架或第三方库,适合用来理解基础流程,也可作为小型项目的起点。

一、适用场景:先选对存储方式

如果需求是“同一个浏览器中的用户刷新页面后,购物车仍然保留”,localStorage 通常是简单而合适的选择。它按来源(origin)隔离,来源由协议、域名和端口共同决定;用户清除网站数据后,相关内容也可能被删除。它不会自动随 HTTP 请求发送到服务器。

不同存储方式适用于不同需求:

  • 组件状态或内存变量:适合只需在当前页面交互期间保留的临时状态。刷新后会丢失。
  • sessionStorage:适合需要在当前标签页的页面会话中保留、但不需要长期留存的数据。它与标签页会话相关,不能简单视为跨设备或可靠的长期存储。
  • localStorage:适合保存少量、非敏感的浏览器端偏好或购物车草稿。刷新后通常仍可读取,但用户可以清除或修改它。
  • 服务端购物车:适合登录用户跨设备同步、长期保存、库存与订单流程需要统一管理的场景。客户端通常只保存购物车标识或展示所需状态,最终数据以服务端为准。

localStorage 是同步 API,读取和写入会在当前脚本执行过程中完成;不宜把大量数据频繁写入其中。浏览器对可用空间、隐私模式行为和存储策略可能有差异,具体上限也不应当作为业务保证。写入可能失败,因此代码应处理异常,而不是假定每次保存都成功。

二、购物车应该保存什么

购物车记录尽量只保存恢复购物车所必需的信息,例如商品 ID 和数量:

[
  { "id": "tea-01", "qty": 2 },
  { "id": "mug-02", "qty": 1 }
]

示例不把商品名称、图片地址、价格或折扣作为可信数据保存。页面通过 ID 查询当前商品目录,再生成展示信息。这种做法能避免购物车中的旧名称或旧价格被误认为最新商品信息,也减少重复数据。

实际项目可以把商品目录从 API 获取,而不是像本文示例一样写在页面中。无论目录来源是什么,浏览器存储的购物车内容都可能被用户通过开发者工具修改;客户端的校验只能帮助页面保持可用,不能代替服务端对商品、库存、价格、优惠和订单金额的校验。

三、可运行示例

将下面代码保存为 cart-demo.html,用浏览器打开即可测试。示例中有两个商品,支持添加、增加数量、减少数量和移除商品;刷新页面后会尝试恢复已保存的数据。

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>购物车持久化示例</title>
  <style>
    body {
      max-width: 760px;
      margin: 2rem auto;
      padding: 0 1rem;
      font-family: sans-serif;
      line-height: 1.6;
    }
    .product, .cart-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 1rem;
      padding: .75rem 0;
      border-bottom: 1px solid #ddd;
    }
    button { padding: .35rem .65rem; cursor: pointer; }
    .controls { display: flex; align-items: center; gap: .5rem; }
    #message { min-height: 1.5em; color: #8a2d16; }
  </style>
</head>
<body>
  <h1>商品</h1>
  <section id="products" aria-label="商品列表"></section>

  <h2>购物车</h2>
  <p id="message" role="status" aria-live="polite"></p>
  <ul id="cart" aria-label="购物车商品"></ul>
  <p>合计:<strong id="total">¥0.00</strong></p>

  <script>
    "use strict";

    // 演示用目录。真实项目通常由服务端 API 提供。
    // 价格用“分”表示,避免用浮点数直接累计金额。
    const products = [
      { id: "tea-01", name: "乌龙茶", priceCents: 2580 },
      { id: "mug-02", name: "陶瓷杯", priceCents: 4990 }
    ];

    const productById = new Map(products.map(product => [product.id, product]));
    const STORAGE_KEY = "cart-demo:v1";
    const MAX_QTY = 99; // 本示例的业务限制,不是浏览器存储上限。

    let cart = [];
    const productList = document.querySelector("#products");
    const cartList = document.querySelector("#cart");
    const totalElement = document.querySelector("#total");
    const messageElement = document.querySelector("#message");

    function formatMoney(cents) {
      return "¥" + (cents / 100).toFixed(2);
    }

    // 只接受本示例支持的数据形状,并剔除目录中不存在的商品。
    function normalizeCart(value) {
      if (!Array.isArray(value)) return [];

      const quantities = new Map();

      for (const item of value) {
        if (!item || typeof item.id !== "string") continue;

        const product = productById.get(item.id);
        if (!product) continue;

        if (!Number.isSafeInteger(item.qty) || item.qty < 1) continue;

        const nextQty = Math.min(
          MAX_QTY,
          (quantities.get(item.id) || 0) + item.qty
        );
        quantities.set(item.id, nextQty);
      }

      return Array.from(quantities, ([id, qty]) => ({ id, qty }));
    }

    function loadCart() {
      try {
        const raw = localStorage.getItem(STORAGE_KEY);
        if (raw === null) return [];
        return normalizeCart(JSON.parse(raw));
      } catch (error) {
        // 读取可能因数据损坏、权限或浏览器策略而失败。
        messageElement.textContent =
          "无法读取已保存的购物车,本次先显示空购物车。";
        return [];
      }
    }

    function saveCart() {
      try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(cart));
        messageElement.textContent = "";
        return true;
      } catch (error) {
        // 存储失败时,当前页面中的购物车仍可继续操作,但刷新后可能无法恢复。
        messageElement.textContent =
          "购物车未能保存到浏览器;刷新页面后可能会丢失。";
        return false;
      }
    }

    function renderProducts() {
      productList.replaceChildren();

      for (const product of products) {
        const row = document.createElement("div");
        row.className = "product";

        const description = document.createElement("span");
        description.textContent =
          product.name + " — " + formatMoney(product.priceCents);

        const button = document.createElement("button");
        button.type = "button";
        button.textContent = "加入购物车";
        button.dataset.action = "add";
        button.dataset.id = product.id;

        row.append(description, button);
        productList.append(row);
      }
    }

    function renderCart() {
      cartList.replaceChildren();
      let totalCents = 0;

      for (const item of cart) {
        const product = productById.get(item.id);
        if (!product) continue;

        totalCents += product.priceCents * item.qty;

        const row = document.createElement("li");
        row.className = "cart-row";

        const description = document.createElement("span");
        description.textContent =
          product.name + " × " + item.qty + " — " +
          formatMoney(product.priceCents * item.qty);

        const controls = document.createElement("span");
        controls.className = "controls";

        const decrease = document.createElement("button");
        decrease.type = "button";
        decrease.textContent = "−";
        decrease.setAttribute("aria-label", "减少" + product.name + "数量");
        decrease.dataset.action = "decrease";
        decrease.dataset.id = product.id;

        const increase = document.createElement("button");
        increase.type = "button";
        increase.textContent = "+";
        increase.setAttribute("aria-label", "增加" + product.name + "数量");
        increase.dataset.action = "increase";
        increase.dataset.id = product.id;

        const remove = document.createElement("button");
        remove.type = "button";
        remove.textContent = "移除";
        remove.dataset.action = "remove";
        remove.dataset.id = product.id;

        controls.append(decrease, increase, remove);
        row.append(description, controls);
        cartList.append(row);
      }

      totalElement.textContent = formatMoney(totalCents);
    }

    function updateQuantity(id, action) {
      const item = cart.find(entry => entry.id === id);
      if (action === "add" && !item) {
        cart.push({ id, qty: 1 });
      } else if (item && action === "add") {
        item.qty = Math.min(MAX_QTY, item.qty + 1);
      } else if (item && action === "increase") {
        item.qty = Math.min(MAX_QTY, item.qty + 1);
      } else if (item && action === "decrease") {
        item.qty -= 1;
        if (item.qty < 1) {
          cart = cart.filter(entry => entry.id !== id);
        }
      } else if (item && action === "remove") {
        cart = cart.filter(entry => entry.id !== id);
      }

      cart = normalizeCart(cart);
      saveCart();
      renderCart();
    }

    // 事件委托:按钮由 renderProducts/renderCart 动态创建,监听器只需绑定一次。
    document.addEventListener("click", event => {
      const button = event.target.closest("button[data-action]");
      if (!button) return;

      const id = button.dataset.id;
      const action = button.dataset.action;

      // 不接受目录之外的 ID,也不把 DOM 中的任意值当作商品信息。
      if (!productById.has(id)) return;
      updateQuantity(id, action);
    });

    cart = loadCart();
    renderProducts();
    renderCart();

    // 同一来源的其他标签页修改该键时,同步更新本页显示。
    window.addEventListener("storage", event => {
      if (event.key !== STORAGE_KEY && event.key !== null) return;
      cart = loadCart();
      renderCart();
    });
  </script>
</body>
</html>

四、代码的关键流程

1. 使用稳定的存储键和数据格式

STORAGE_KEY 是应用给这份数据起的名字。示例把版本写进键名:cart-demo:v1。当数据结构将来发生不兼容变化时,可以采用新版本键,并实现迁移或明确丢弃旧格式。不要假定浏览器中读出的 JSON 永远符合当前代码预期:它可能是旧版本写入的,也可能被手动修改,甚至已经损坏。

这里保存的是数组,数组中的每项只包含 id 和 qty。JSON.stringify 将 JavaScript 数据转换成字符串,JSON.parse 则尝试反向解析。解析成功并不代表内容可信,因此 normalizeCart 还会检查数据形状、ID 是否存在、数量是否为安全整数,并合并重复 ID。

2. 恢复数据时校验,而不是直接使用

loadCart 把读取和解析放在 try...catch 中。存储不可用、内容不是合法 JSON 或浏览器拒绝访问时,读取都可能失败。示例在失败后使用空购物车并显示提示,让页面仍然可交互。

normalizeCart 只负责客户端的基本数据整理。它不能证明用户有权购买,也不能确认商品仍在售或有库存。真实应用还应考虑商品下架、地区限制、起订量、库存上限和活动规则,并在服务端获取最新商品状态。

数量上限 MAX_QTY = 99 是示例定义的业务规则,用来限制演示中的单品数量。它不是 HTML、JavaScript 或浏览器规定的参数上限,也不能防止用户绕过限制。上线前应按业务规则确定数量边界,并在服务端再次校验。

3. 修改后立即保存

添加、加减或移除商品后,示例会先整理购物车,再调用 saveCart 保存。购物车数据量很小,因此每次操作后保存通常足够简单。若应用有高频更新场景,可以考虑合并写入,但需要权衡页面意外关闭时丢失最近变更的风险。

保存异常不会让当前页面的操作立即失效,但会提示用户:当前状态可能无法在刷新后恢复。不要悄悄忽略写入失败,否则用户可能误以为购物车已可靠保存。

4. 从可信目录渲染,并避免把数据拼进 HTML

示例根据商品 ID 查找目录数据,再用 textContent 设置商品名称和价格。它没有把存储内容拼接到 innerHTML 中。假如被篡改的购物车数据直接进入 HTML 字符串,存在引入脚本或破坏页面结构的风险;使用文本节点能避免把这些字符串当作 HTML 解析。

这并不意味着 textContent 能替代所有安全措施。若应用需要展示富文本、处理 URL 或把数据写入其他敏感位置,仍要根据具体上下文进行相应的校验和安全处理。更重要的是,购物车数据不应包含可执行内容,也不应被当作授权凭证。

5. 金额用整数分累计

示例的价格字段是整数分,例如 2580 代表 25.80 元。金额相乘和累计时使用整数,展示时再除以 100 格式化。这能避免常见的二进制浮点数精度现象,例如某些小数无法用二进制浮点数精确表示。

这一处理只解决前端演示中的表示问题,不是支付金额校验。实际下单时,客户端可以传商品 ID 和数量;服务端应从可信的商品与促销数据重新计算金额,并按照支付系统要求处理货币精度、舍入规则和币种。

五、页面刷新恢复,不等于跨页面或跨设备同步

页面加载时,代码先调用 loadCart(),再渲染商品和购物车。只要浏览器允许访问存储、对应来源相同且数据仍存在,刷新后就能恢复。这种机制只覆盖浏览器端的本地状态,不会自动同步到其他设备、其他浏览器或另一台电脑。

同一来源的多个标签页可能同时打开。一个标签页写入 localStorage 后,其他标签页可收到 storage 事件。事件不会在执行写入的当前标签页上触发,所以当前页仍要自行更新界面。示例收到事件后重新读取并渲染;实际应用可根据交互需求进一步处理冲突,例如提醒用户购物车已在另一个标签页更新。

简单的“最后一次写入覆盖此前内容”未必满足所有业务。如果多个标签页同时修改数量,可能出现用户操作被覆盖的情况;如果需要强一致性或多端协作,应把购物车放到服务端,由服务端管理更新顺序和业务规则。

六、重要安全边界:客户端购物车不是订单

localStorage 中的内容由用户控制。用户可以用开发者工具改商品 ID、数量,甚至把数据清空。对站点脚本而言,能够读取该来源存储的代码也可能访问其中的数据。因此:

  • 不要在购物车中保存密码、支付卡号、会话密钥或其他敏感凭据。
  • 不要相信浏览器保存的价格、折扣、运费、库存状态或应付金额。
  • 下单时由服务端验证商品是否有效、数量是否合法、库存是否足够,并重新计算价格、优惠、税费和运费。
  • 不能仅凭客户端传来的商品 ID 和数量推断用户权限;需要登录、限购或其他授权时,应由服务端检查。
  • 对用户输入和服务端返回的内容仍要按输出上下文安全处理;存储位置本身不会自动消除注入风险。

如果业务允许匿名用户先加入购物车,服务端仍可以在结算时创建或关联服务端购物车。登录后合并匿名购物车时,要定义重复商品的合并规则、数量上限以及失效商品的处理方式,不能只把客户端数据原样提交并直接生成订单。

七、常见错误与排查方法

错误一:刷新后购物车为空

先检查写入和读取是否使用完全相同的键名,再查看浏览器开发者工具的存储面板中是否存在对应数据。确认页面是否切换了协议、域名或端口,因为来源变化会导致存储隔离;也要检查浏览器是否清除了网站数据,以及代码是否因异常提前停止执行。

错误二:只做 JSON 解析,没有校验结构

JSON.parse 只负责解析语法,不会检查数据是否符合业务格式。解析结果可能是字符串、对象或包含错误字段的数组。应验证整体类型、每项字段、商品是否有效和数量范围,并明确不合法数据是剔除、迁移还是重置。

错误三:把完整商品对象长期保存

保存名称和价格会产生过期信息:商品改名或调价后,购物车可能继续展示旧值。可以为离线体验保存展示快照,但必须明确它只是缓存;恢复时应与最新目录核对,结算时仍以服务端数据为准。对基础购物车,保存 ID 和数量通常更容易维护。

错误四:使用 innerHTML 直接渲染存储数据

存储内容可被修改,不应直接当成可信 HTML。本文用 createElement 和 textContent 构造界面。如果项目使用模板框架,也要了解框架的转义规则,不要为了方便绕过安全转义。

错误五:认为存储一定可写

存储空间耗尽、浏览器策略或环境限制等情况都可能导致写入失败。将访问放进异常处理,并向用户提供清楚反馈。对于必须可靠保留的数据,应使用有服务端确认的方案,而不能只依赖本地写入成功。

错误六:把客户端限制当作防作弊措施

隐藏按钮、设置最大数量或校验总价,都只能改善界面体验。用户可以跳过页面逻辑直接改存储或构造请求。任何影响订单、库存或支付结果的规则都需要在服务端执行。

八、接入真实项目时的改造清单

  1. 明确购物车的生命周期:只保留到标签页关闭、保留到用户清除,还是需要登录后跨设备同步。
  2. 为数据定义稳定的结构和版本,并为旧数据安排迁移或安全降级策略。
  3. 商品目录由可信 API 提供;恢复时处理下架、价格变化和库存不足等情况。
  4. 保存和读取都做异常处理,向用户说明本地保存失败的影响。
  5. 渲染时避免把购物车数据作为 HTML 解析,并确保不把敏感信息写入浏览器存储。
  6. 结算接口只把客户端购物车当作请求输入;服务端重新验证并计算订单。
  7. 测试空购物车、损坏 JSON、未知商品、重复 ID、超大数量、存储写入失败和多标签页修改等边界。

总结

用 localStorage 实现刷新后恢复,核心步骤是:保存最小化的购物车数据、加载时解析并校验、修改后及时写入、渲染时从商品目录取值,并对读写失败作出处理。它适合保存浏览器端的购物车草稿,不适合充当订单数据库、价格依据或安全边界。只要把本地状态与服务端权威数据明确区分,这种实现就能既简单又不容易在关键边界上出错。

© 版权声明

相关文章