Skip to content

商品列表的业务与购物车(Day5 1/2) ​

本页对应 day5,共 7 段视频,约 1 小时 47 分。

学习目标 ​

  • 能够用自己的话说明防抖与节流的区别,并指出在购物车数量修改等场景中应如何选择使用。
  • 能够说明商品列表页从首页“查看更多”和分类二级菜单进入时的参数差异,并正确实现商品列表请求与下拉加载更多逻辑。
  • 能够完成商品卡片点击跳转商品详情页、携带商品 ID、在 onLoad 中获取参数并调用接口渲染商品详情的完整流程。
  • 能够使用动作面板组件实现商品详情页底部弹层的显示与隐藏,并根据“加入购物车/立即购买”类型条件控制数量计数器是否渲染。
  • 能够收集商品数量与祝福语,完成登录判断、祝福语校验、加入购物车接口调用、成功提示、面板关闭及状态清空。
  • 能够搭建订单详情页静态结构,并说明其可从“立即购买”或“购物车结算”进入的两种业务来源。

知识点 ​

1.1 商品列表接口:先明确“拿哪一页、拿几条” ​

为什么需要:商品列表不能一次性把所有商品都查回来。商品数量可能很多,一次请求全部返回会增加接口压力、拖慢首屏渲染,也不利于后续“加载更多”。所以列表接口必须分页。

怎么想:本节笔记给出的接口是:

http
GET /mall-api/goods/list/{page}/{limit}

其中 page 是当前页码,limit 是一页展示几条数据。分类模块还要额外带 category2Id,用于只查某个二级分类下的商品。思路是:页面先请求第 1 页,每页 10 条;用户触底后再请求下一页;直到某次返回不足 10 条,说明没有更多。

怎么做:页面挂载时先请求第 1 页;请求成功后保存列表数据,并根据本次返回条数判断是否还有下一页。示意逻辑如下,变量名按转写稿中的“当前页码、是否有更多、列表数组”整理,不新增接口字段:

js
// 示意:分页加载的核心判断
const limit = 10;
let page = 1;
let hasMore = true;
let list = [];

async function loadList() {
  const res = await getGoodsList({ page, limit, category2Id });
  const rows = res.data || [];

  list = [...list, ...rows];          // 展开拼接,避免二维数组
  hasMore = rows.length === limit;    // 等于10可能有下一页,小于10则没有

  if (hasMore) page += 1;             // 成功后再加页码
}

易错点:页码更新要放在请求成功之后,不能在发请求前就加;拼接数组要用展开运算符,不能把新数组直接塞进旧数组,否则会变成二维数组;判断“还有没有更多”不能只看 hasMore 的初始值,要依据本次返回条数。转写稿里反复强调“当前这次请求得到了几个商品,小于10个就没有下一次,等于10个才有可能有下一次”,这是分页判断的关键。

1.2 分类参数:从首页进入和从分类进入不一样 ​

为什么需要:商品列表有两个入口:首页“查看更多”和分类右侧二级菜单。首页查看更多通常看全部商品,不需要分类条件;分类进入则必须带 category2Id,否则会把所有分类的商品都查出来,业务就不对了。

怎么想:转写稿里提到一个很典型的调试点:如果从首页进入时把 category2Id 当成空对象或 undefined 传给接口,接口可能拿不到正确的商品列表。因此不能“无脑带参数”,要先判断当前入口有没有二级分类 ID。分类进入时保存 category2Id,首页进入时不要传无效值。

怎么做:页面接收参数后先存起来;发请求时按业务决定是否携带 category2Id。转写稿的推导是:从分类二级菜单过来会带二级分类 ID,页面挂载时先存 ID,再拿这个 ID 发起商品列表请求。首页查看更多没有分类 ID,就按全部商品列表处理。

易错点:不要把空对象、undefined 直接拼到请求参数里;不要从首页进入时误带上次缓存的分类 ID;分类切换后要重置页码和列表,否则会把新分类的数据拼到旧分类后面。调试时先打印入口参数和请求参数,确认 category2Id 是否符合当前入口。

1.3 加载更多:10 → 10 → 10 → 10 → 4 ​

为什么需要:笔记里的 10->10->10->10->4 就是分页加载的典型过程:每页 10 条,前四页各 10 条,最后一页只剩 4 条。用户不需要一次看完,滚动到底再加载,体验更好。

怎么想:每次请求成功都要做两件事:第一,把本次数据追加到已有列表;第二,判断本次条数是否等于 limit。等于 10 说明可能还有下一页,页码加 1;小于 10 说明已经到底,把“是否有更多”置为 false。转写稿里说“第一页10个,处理时让当前页码加一,重新再来一次,第二页10个又来1个”,就是不断重复这个流程。

怎么做:维护 page、hasMore、list 三个状态。触底时先判断 hasMore,为 true 才继续请求,避免到底后还重复发请求。模板里用 list.length > 0 判断是否展示商品卡片;没有数据时展示空状态;“没有更多”的分割线只在 hasMore === false 时出现。

易错点:不要在 hasMore 为 false 时继续请求;不要忘记在分类切换时把 page 重置为 1、list 清空;不要用 list.length === 0 判断“没有更多”,空列表和加载到底不是一回事;调试时依次打印每次返回条数,确认是否按 10、10、10、10、4 走完,最后一条请求后 hasMore 应为 false。

1.4 列表渲染与空状态:数据有了再谈模板 ​

为什么需要:列表页不仅要有数据,还要处理“有商品”和“没有商品”两种展示。转写稿里强调“前端最重要就是数据,先看有没有数据”,没有数据时模板不能硬渲染卡片。

怎么想:商品列表数组长度大于 0 时,用卡片组件循环展示;长度等于 0 时,展示空状态或提示。分类请求可能返回空数组,这时如果还渲染商品卡片,就会出现空白或错误。

怎么做:模板中先判断 list.length > 0,再循环渲染商品项;每个商品项点击后进入详情页,这是后续业务,但列表项本身要保证有商品 ID 可用于跳转。转写稿中提到卡片组件里点击商品进入详情页,并传商品 ID;本节先保证列表数据和渲染条件正确。

易错点:不要在没有数据时渲染“没有更多”或商品卡片;循环项的 key 要稳定;点击跳转前确认商品 ID 存在;调试时先打印接口返回结构和列表长度,再改模板,避免“咔咔一顿写,报错找不到哪”。

先把购物车任务拆开 ​

本节笔记只给了四个任务:2.1 获取购物车数据并渲染、2.2 修改商品勾选状态、2.3 删除商品、2.4 修改商品数量,另外补充了防抖/节流的比喻和小程序插件限制。转写稿里大量内容在讲商品详情、动作面板和订单,因此这里只围绕购物车这一节能确认的内容扩写,不虚构接口名和字段名。笔记原参考链接保留:https://www.02405.com/archives/7466 。本节笔记未附图片,只有这个参考链接。

2.1 获取购物车数据并渲染 ​

为什么需要:购物车页面不是写死结构,数据来自接口;没有数据就无法判断空购物车、商品数量、勾选和合计。

怎么想:先拿数据,再存到 data,最后模板遍历渲染。转写稿复习商品列表时反复强调一个顺序:先打印看接口返回什么,确认有数据再写结构,“前端最重要就是数据,先看有没有数据”。购物车同理。

怎么做:页面挂载时发起请求,把返回的列表保存到 data 中;模板里用列表渲染;空数组时展示空状态,有数据时展示商品卡片。转写稿提到空数组不能直接“打点”取属性,赋值时要注意展开,避免把数据弄成二维数组。

易错点:不要一上来就写复杂模板;先 console.log 看返回结构。空数组要有兜底展示,否则页面空白。赋值后检查是“一层数组”还是“嵌套数组”。

2.2 修改商品勾选状态 ​

为什么需要:购物车结算依赖勾选状态,全选、合计金额、结算数量都跟着它走。

怎么想:勾选状态应该存在购物车列表的每一项数据里,而不是只改视图。点击某一项时,要定位到对应商品,再修改它的选中字段,最后 setData 更新。

怎么做:模板绑定点击事件,把当前商品 ID 或索引传出来;在事件里找到对应项,切换选中值;如果是全选,则遍历列表统一改。修改后重新计算合计。

易错点:直接改数组元素但不 setData,视图不更新;用索引定位时,删除后索引会变化,优先用商品 ID;全选和单项勾选要互相同步。

2.3 完成商品删除业务 ​

为什么需要:购物车要允许用户移除不买的商品,删除后还要保证合计、全选状态正确。

怎么想:删除前先确认删哪一项;删除时按商品 ID 过滤列表;删除后重新判断是否全选、重新算总价。

怎么做:事件里拿到商品 ID,调用删除接口;成功后在本地列表里过滤掉该项并 setData;如果列表为空,展示空购物车。

易错点:不要用“当前索引”直接删,因为列表变化后索引不可靠;删除后忘记重算合计;接口成功但本地没同步,页面看起来没变化。转写稿没有展开删除接口细节,这里只保留任务边界和检查点,不编造具体接口。

2.4 完成商品数量修改 ​

为什么需要:购物车数量会频繁加减,数量变化还要同步到后端,并影响合计。

怎么想:数量修改一般由计数器组件触发,事件里能拿到当前值。转写稿明确说通过 e.detail 拿当前值,再保存到 data:

js
this.setData({
  skuNumber: e.detail
})

加入购物车/更新购物车时,转写稿提到要带三类信息:商品 ID、数量、祝福语 remark。数量修改也属于“更新已有商品”的业务,所以要把商品 ID 和新数量传给更新接口。

易错点:数量是字符串还是数字要确认;数量边界要限制;接口参数顺序要对;未登录时不能直接改购物车,转写稿用 token 判断:

js
const token = wx.getStorageSync('token')
if (!token) {
  wx.navigateTo({ url: '/pages/login/login' })
  return
}

祝福语为空时,转写稿用 wx.showToast 提示“请填写祝福语”,并且提示要在发请求之前判断。

防抖与节流:回城与 CD 的比喻 ​

为什么需要:购物车数量连续点击、输入搜索等场景会频繁触发请求,需要控制频率。

怎么想:转写稿的比喻很准确:防抖像频繁点回城,前面很多次都取消,只保留最后一次,在规定时间后执行;节流像英雄技能 CD,规定间隔内不会重复触发,只有超过间隔才再次触发。

怎么做:防抖看“最后一次”,节流看“固定间隔”。转写稿还提醒:小程序里不要直接按 Web 端习惯用 lodash,转写稿说它底层依赖 window,小程序没有 window,用不了,需要用适配/改写后的依赖包。易错点是防抖节流概念混用,面试时要能说清“连续快速触发只执行一次”和“规定间隔内不重复触发”的区别。

小程序跳转与调试易错点 ​

购物车通常是 tabBar 页面,普通 navigator 默认类似 navigateTo,不能直接跳 tabBar。转写稿明确要加 open-type="switchTab":

html
<navigator url="/pages/cart/cart" open-type="switchTab">购物车</navigator>

页面取参用 onLoad(options),这是页面获取 query 参数的主要入口:

js
onLoad(options) {
  console.log(options)
}

调试时先打印商品 ID、数量、祝福语,确认参数有值再发请求;如果代码感觉没问题但效果不对,转写稿的做法是清缓存、重新构建再试。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. 商品列表 · 上拉加载更多(page / limit / hasMore) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品列表 - 加载更多</title>
<style>
  body { margin: 0; font-family: sans-serif; background: #f5f5f5; }
  .list { display: flex; flex-wrap: wrap; gap: 10px; padding: 10px; }
  .card { width: calc(50% - 5px); background: #fff; border-radius: 8px; overflow: hidden; }
  .card .img {
    height: 120px; display: flex; align-items: center; justify-content: center;
    color: #fff; font-size: 28px;
    background: linear-gradient(135deg, #ffb6c1, #ff7f9e);
  }
  .card .info { padding: 8px; font-size: 13px; }
  .card .price { color: #e93b3d; font-weight: bold; margin-top: 4px; }
  .more { text-align: center; padding: 14px; color: #999; font-size: 13px; }
  .divider { display: flex; align-items: center; color: #bbb; font-size: 12px; margin: 10px; }
  .divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: #ddd; }
  .divider::before { margin-right: 10px; }
  .divider::after { margin-left: 10px; }
</style>
</head>
<body>
<div class="list" id="list"></div>
<div class="divider" id="divider" style="display:none">没有更多了</div>
<div class="more" id="more"></div>

<script>
// 模拟接口:/mall-api/goods/list/{page}/{limit}  GET
const TOTAL = 44;   // 一共 44 条:10 -> 10 -> 10 -> 10 -> 4
function mockList(page, limit) {
  const start = (page - 1) * limit;
  const len = Math.max(0, Math.min(limit, TOTAL - start));
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(Array.from({ length: len }, (_, i) => ({
        id: start + i + 1,
        name: '商品 ' + (start + i + 1),
        price: (start + i + 1) * 10
      })));
    }, 400);
  });
}

const listEl = document.getElementById('list');
const moreEl = document.getElementById('more');
const dividerEl = document.getElementById('divider');

let page = 1;         // 当前页码
const limit = 10;     // 一页 10 条
let hasMore = true;   // 有没有下一页
let goodsList = [];   // 已加载的全部商品
let loading = false;

async function getGoodsList() {
  if (loading || !hasMore) return;
  loading = true;

  const res = await mockList(page, limit);

  // 关键一:本次请求拿到的条数 < limit,说明没有下一页
  if (res.length < limit) hasMore = false;

  // 关键二:展开拼接,不能 goodsList.push(res),否则变成二维数组
  goodsList = [...goodsList, ...res];
  render();

  // 关键三:还有更多,页码才 +1
  if (hasMore) page++;

  loading = false;
}

function render() {
  listEl.innerHTML = goodsList.map(item => `
    <div class="card">
      <div class="img">${item.id}</div>
      <div class="info">
        <div>${item.name}</div>
        <div class="price">¥${item.price}</div>
      </div>
    </div>
  `).join('');

  // 没有更多时才出分割线
  dividerEl.style.display = hasMore ? 'none' : 'flex';
  moreEl.textContent = hasMore ? '上拉加载更多' : '';
  moreEl.style.display = hasMore ? 'block' : 'none';
}

// 模拟上拉触底
window.addEventListener('scroll', () => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 50) {
    getGoodsList();
  }
});

// 页面挂载先拿第一页
getGoodsList();
</script>
</body>
</html>

关键行与易错点

  • if (res.length < limit) hasMore = false; —— 判断有没有下一页的唯一依据:本次请求拿到的条数是否小于 limit。等于 10 不代表一定还有,但必须认为可能有。
  • goodsList = [...goodsList, ...res] —— 必须展开。写成 goodsList.push(res) 会变成二维数组,模板里渲染不出来。
  • if (hasMore) page++; —— 页码加一要在判断之后,否则最后一页还会多请求一次。
  • 分割线“没有更多了”只在 hasMore === false 时出现,且只有 goodsList.length > 0 才渲染 card 列表。
  • 从“查看更多”进商品列表时没有 category2Id,不能带 undefined 拼进 URL,否则拿不到全部商品。

2. 手写防抖 + 购物车数量修改 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>购物车数量修改 - 防抖</title>
<style>
  body { margin: 0; font-family: sans-serif; background: #f5f5f5; padding: 10px; }
  .cart-item { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 8px; padding: 12px; }
  .thumb {
    width: 70px; height: 70px; border-radius: 8px; flex-shrink: 0;
    display: flex; align-items: center; justify-content: center;
    color: #fff; font-size: 20px;
    background: linear-gradient(135deg, #ff9a9e, #fad0c4);
  }
  .meta { flex: 1; }
  .name { font-size: 14px; }
  .price { color: #e93b3d; font-weight: bold; margin-top: 6px; }
  .stepper { display: flex; align-items: center; }
  .stepper button { width: 28px; height: 28px; border: 1px solid #ddd; background: #fafafa; font-size: 16px; line-height: 1; }
  .stepper span { width: 40px; text-align: center; font-size: 14px; }
  .log { margin-top: 12px; background: #fff; border-radius: 8px; padding: 10px; font-size: 12px; color: #666; min-height: 60px; }
</style>
</head>
<body>
<div class="cart-item">
  <div class="thumb">99</div>
  <div class="meta">
    <div class="name">99支红玫瑰 · 爱的表白</div>
    <div class="price">¥6999</div>
  </div>
  <div class="stepper">
    <button id="minus">-</button>
    <span id="num">1</span>
    <button id="plus">+</button>
  </div>
</div>
<div class="log" id="log">请求记录:</div>

<script>
// 防抖:连续快速触发时,前面的全部取消,只执行最后一次
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);            // 关键:每次进来先把上一次的定时器清掉
    timer = setTimeout(() => {
      fn.apply(this, args);         // 保留 this 和参数
    }, delay);
  };
}

let skuNumber = 1;
const numEl = document.getElementById('num');
const logEl = document.getElementById('log');

// 真正的"修改购物车数量"请求,被防抖包一层
const updateCart = debounce(function (num) {
  // 实际项目:req.post('/cart/update', { skuNumber: num, ... })
  logEl.innerHTML += `<div>发出请求:skuNumber = ${num}</div>`;
  console.log('发起修改数量请求', num);
}, 500);

function changeNum(delta) {
  skuNumber += delta;
  if (skuNumber < 1) skuNumber = 1;   // 数量不能小于 1
  numEl.textContent = skuNumber;
  updateCart(skuNumber);              // 快速点多次,只会发最后一次请求
}

document.getElementById('minus').onclick = () => changeNum(-1);
document.getElementById('plus').onclick = () => changeNum(1);
</script>
</body>
</html>

关键行与易错点

  • 防抖的本质:clearTimeout(timer) 把前面的触发全部作废,只有最后一次在 delay 之后执行 —— 就像疯狂点回城,前面 99 下全报废,只有最后一下等三四秒才回家。
  • 节流的本质:在规定间隔内不重复触发 —— 就像技能 CD,9 秒一个,只有大于这个间隔才触发。
  • fn.apply(this, args) 不能写成 fn(),否则丢失 this 和传参。
  • 易错点:微信小程序里不能直接用 lodash 的 debounce,因为它底层依赖 window,小程序没有 window。要么自己封装,要么换小程序可用的依赖包。
  • 数量 - 到 1 就停,防抖只能减少请求次数,不能代替边界判断。

3. 商品详情 · 动作面板显示隐藏 / 加入购物车 / 立即购买 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品详情 - 动作面板</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; font-family: sans-serif; background: #f5f5f5; padding-bottom: 70px; }
  .goods-img {
    height: 240px; display: flex; align-items: center; justify-content: center;
    color: #fff; font-size: 20px;
    background: linear-gradient(135deg, #ff9a9e, #fad0c4);
  }
  .goods-info { background: #fff; padding: 14px; }
  .price-row { display: flex; align-items: baseline; gap: 10px; }
  .price { color: #e93b3d; font-size: 22px; font-weight: bold; }
  .old-price { color: #999; text-decoration: line-through; font-size: 13px; }
  .name { margin-top: 8px; font-size: 16px; font-weight: bold; }
  .sub { color: #999; font-size: 13px; margin-top: 6px; }

  .bottom-bar {
    position: fixed; left: 0; right: 0; bottom: 0; height: 56px;
    background: #fff; border-top: 1px solid #eee;
    display: flex; align-items: center; padding: 0 10px;
  }
  .bottom-bar .icon { width: 50px; text-align: center; font-size: 12px; color: #666; }
  .bottom-bar .btns { flex: 1; display: flex; gap: 8px; margin-left: 8px; }
  .btn { flex: 1; height: 40px; border: none; border-radius: 20px; color: #fff; font-size: 14px; }
  .btn-cart { background: #ffa500; }
  .btn-buy { background: #e93b3d; }

  .mask { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; }
  .mask.show { display: block; }
  .sheet {
    position: fixed; left: 0; right: 0; bottom: -100%;
    background: #fff; border-radius: 14px 14px 0 0; padding: 16px;
    transition: bottom .3s;
  }
  .sheet.show { bottom: 0; }
  .sheet-head { display: flex; gap: 12px; }
  .sheet-head .pic {
    width: 80px; height: 80px; border-radius: 8px;
    display: flex; align-items: center; justify-content: center; color: #fff;
    background: linear-gradient(135deg, #ff9a9e, #fad0c4);
  }
  .stepper { display: flex; align-items: center; margin-top: 12px; }
  .stepper button { width: 30px; height: 30px; border: 1px solid #ddd; background: #fafafa; }
  .stepper span { width: 44px; text-align: center; }
  .remark { margin-top: 14px; }
  .remark input { width: 100%; height: 36px; border: 1px solid #eee; border-radius: 6px; padding: 0 10px; }
  .confirm { margin-top: 14px; width: 100%; height: 44px; border: none; border-radius: 22px; background: #e93b3d; color: #fff; font-size: 16px; }
  .toast {
    position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%);
    background: rgba(0,0,0,.75); color: #fff; padding: 10px 18px;
    border-radius: 6px; font-size: 14px; display: none;
  }
</style>
</head>
<body>
<div class="goods-img">99支红玫瑰</div>
<div class="goods-info">
  <div class="price-row">
    <span class="price">¥6999</span>
    <span class="old-price">¥8999</span>
  </div>
  <div class="name">99支红玫瑰 · 爱的表白</div>
  <div class="sub">广州 · 送给最爱的她</div>
</div>

<div class="bottom-bar">
  <div class="icon">🏠<br>首页</div>
  <div class="icon">🛒<br>购物车</div>
  <div class="btns">
    <button class="btn btn-cart" id="addCart">加入购物车</button>
    <button class="btn btn-buy" id="buyNow">立即购买</button>
  </div>
</div>

<div class="mask" id="mask"></div>
<div class="sheet" id="sheet">
  <div class="sheet-head">
    <div class="pic">🌹</div>
    <div>
      <div class="price">¥6999</div>
      <div class="sub">库存充足</div>
    </div>
  </div>
  <!-- 数量:只有"加入购物车"才展示 -->
  <div class="stepper" id="stepper">
    <button id="minus">-</button>
    <span id="num">1</span>
    <button id="plus">+</button>
  </div>
  <div class="remark">
    <input type="text" id="remark" placeholder="请填写祝福语">
  </div>
  <button class="confirm" id="confirm">确定</button>
</div>
<div class="toast" id="toast"></div>

<script>
// ---------- 状态 ----------
const state = {
  showSheet: false,
  type: '',        // 'cart' = 加入购物车   'buy' = 立即购买
  skuNumber: 1,
  remark: ''
};

const maskEl = document.getElementById('mask');
const sheetEl = document.getElementById('sheet');
const stepperEl = document.getElementById('stepper');
const numEl = document.getElementById('num');
const remarkEl = document.getElementById('remark');
const toastEl = document.getElementById('toast');

function toast(msg) {
  toastEl.textContent = msg;
  toastEl.style.display = 'block';
  setTimeout(() => (toastEl.style.display = 'none'), 1500);

常见错误 ​

  1. 现象:从首页“查看更多”进入商品列表,列表为空或请求参数异常。
    原因: 未带 category2Id 时直接取空对象字段,参数变成 undefined。
    修法: onLoad 中先做参数兜底,仅二级分类来源写入 category2Id,请求前统一校验参数。

  2. 现象:下拉加载更多只显示第一页、数组嵌套或“没有更多”不出现。
    原因: 新数据直接覆盖旧数组、未展开合并,或未根据本页条数更新 hasMore。
    修法: 使用 list = [...list, ...res.list];按 res.list.length < limit 置 hasMore=false;空数组立即停止。

  3. 现象:商品详情页 onLoad 拿不到 goodsId,详情接口 404 或请求空 ID。
    原因: 小程序路由传参写成 params/对象,或先发请求后存 ID。
    修法: URL 拼 ?goodsId=xxx,onLoad(options) 取参后先落库到 data,再用该值发请求。

  4. 现象:点击动作面板遮罩不关闭,或按钮能弹出但无法收起。
    原因: 直接调 API 控制显隐,未用 data 统一维护 showSheet,遮罩事件未绑定。
    修法: 用布尔状态控制面板;按钮置 true,遮罩点击和确定成功回调统一置 false。

  5. 现象:立即购买也显示数量选择器,确定逻辑串到加入购物车。
    原因: 加入购物车与立即购买共用面板,但没有来源标识。
    修法: 增加 type 区分 cart/buyNow;模板用 wx:if 控制数量显隐,确定时按 type 分支处理。

  6. 现象:未登录或未填祝福语就点确定,接口失败或流程中断。
    原因: 缺少前置校验,先发请求后补登录/必填判断。
    修法: 确定按钮先校验祝福语,再校验 token;未登录跳登录并保留回跳,校验通过后才发请求。

  7. 现象:加入购物车成功后数量、祝福语残留,连续添加数据异常。
    原因: 成功回调只关闭面板,未重置表单状态。
    修法: 成功后关闭面板,重置 skuNumber=1、remark='',必要时刷新购物车数据。

  8. 现象:购物车数量连点导致接口狂发、结果覆盖。
    原因: 高频修改未做防抖,或未处理请求竞态/失败回滚。
    修法: 对数量修改做防抖,合并请求;失败时回滚显示值,必要时加 loading 或禁用。

  9. 现象:小程序里直接使用 lodash 防抖报错或无效。
    原因: 小程序无 window/DOM,部分包依赖浏览器环境。
    修法: 自行封装 setTimeout/clearTimeout 防抖,或使用适配小程序的实现,避免浏览器 API。

  10. 现象:底部图标跳首页/购物车失败。
    原因: 声明式 navigator 默认 navigateTo,不能跳 tabBar 页面。
    修法: 使用 open-type="switchTab" 或 wx.switchTab。

练习 ​

  1. 实现商品列表页同时支持“首页查看更多”和“二级分类进入”:写出参数兜底、分页合并、hasMore 停止条件的关键状态与代码片段。

  2. 封装一个不依赖 window 的小程序防抖函数,用于购物车数量修改;说明如何避免最后一次请求丢失,以及失败时如何回滚。

  3. 实现商品详情页“加入购物车/立即购买”共用动作面板:要求区分 type、控制数量显隐、校验祝福语、未登录跳转、成功后重置状态。

  4. 设计“立即购买”和“购物车结算”进入订单页的参数方案,保证订单页能区分来源并渲染对应商品。

  5. 排查“详情页 onLoad 拿不到 goodsId,接口 404”的问题,列出排查顺序和修复步骤。

原视频对照 ​

#视频时长
1防抖与节流04:53
2复习06:54
3商品详情的静态查看18:10
4获取商品详情的数据并且渲染出来15:23
5商品详情动作面板显示与隐藏17:28
6商品详情的加入购物车与立即购买的业务25:22
7订单详情页静态查看19:00

小结 ​

  1. 商品列表的核心是参数来源、分页合并、停止条件三件事闭环,缺一处就会出现空列表或加载异常。
  2. 小程序路由只认 query 字符串,详情页请求必须建立在 onLoad 参数落库之后。
  3. 动作面板要由 data 驱动,显示、遮罩关闭、确定关闭必须走同一状态链路。
  4. 加入购物车与立即购买共用面板时,type 是分支开关,数量、校验、成功回调都要按 type 分开。
  5. 涉及用户资产的操作先做登录和必填校验,再发请求;成功后的关闭与重置必须和失败提示一样纳入流程。
  6. 购物车数量修改属于高频触发,防抖要选型并处理竞态与失败回滚,不能只包一层函数。
  7. 订单页要从入口设计参数,立即购买和购物车结算不是同一数据模型,来源标识必须显式。
  8. 静态页复制后必须检查组件注册、样式引入和条件渲染,否则“有结构无效果”是必然结果。