Skip to content

商品分类案例 ​

本页对应 1-13商品分类案例,共 2 段视频,约 21 分钟。

学习目标 ​

  1. 能使用 async/await 声明并调用异步函数,完成一级商品分类接口请求,并在控制台正确输出分类数组。
  2. 能基于一级分类数据中的 id,使用 map 生成由多个二级分类请求组成的 Promise 数组,且不误用 await 阻断映射过程。
  3. 能使用 Promise.all 等待所有二级分类接口完成,并通过 await 获取全部二级分类结果。
  4. 能使用 map、join 和 innerHTML 将一级标题、二级分类名称及图片渲染到页面,并正确区分外层与内层循环变量。
  5. 能按照“先获取一级分类、再获取所有二级分类、最后渲染数据”的步骤分阶段编码与调试,每完成一步验证一次结果。

知识点 ​

需求与效果确认 ​

本案例要完成“商品分类”效果:页面刷新后先没有数据,稍等片刻后同时渲染出多个区域的商品分类。每个区域都有顶部一级分类标题,下面是一排二级分类商品。

商品分类效果示意图

为什么需要:页面数据不是来自一个接口,而是“一级分类接口 + 多个二级分类接口”。如果拿到一个二级分类就渲染一个,页面会出现分批闪烁、顺序不稳定,也不利于统一处理。
怎么想:先把所有请求都发出去,等所有二级分类数据都回来,再一次性渲染。这正是 Promise.all 的典型场景。
怎么做:按笔记步骤:1)获取所有一级分类数据;2)遍历一级分类的 id,创建获取二级分类请求;3)合并所有二级分类 Promise 对象;4)等待同时成功,开始渲染页面。
易错点:不要误以为一级分类数据直接用于页面主体渲染;转写稿明确说页面上要渲染的二级分类,一级分类接口的数据主要用于拿 id 去请求二级分类。

先获取一级商品分类 ​

为什么需要:二级分类接口需要一级分类 id 作为查询参数,所以必须先拿一级分类数组。
怎么想:转写稿使用 async 函数配合 await,不再写链式调用。先声明函数,再调用函数。
怎么做:关键结构如下,接口地址按接口文档替换:

js
async function getData() {
  // 1. 获取一级商品分类
  const res = await axios.get('一级分类接口地址')
  console.log(res)
}

getData()

调试时先打印 res,再点开 res.data.data,确认拿到一级分类数组,每项里有标题和 id。
易错点:声明了 async 函数却忘记调用;把 await 写错位置;没有按接口文档确认一级接口无参数。

生成二级分类的 Promise 数组 ​

为什么需要:一级分类有几个,就要调用几次二级分类接口;而 Promise.all 管理的是 Promise 数组,所以要先基于一级分类数组生成 Promise 数组。
怎么想:基于一个数组生成新数组,用 map。每次循环里用当前项的 id 调用二级分类接口;注意这里不要 await,而是直接 return 请求返回的 Promise 对象。
怎么做:

js
const promiseArray = res.data.data.map((v) => {
  const { id } = v
  return axios.get('二级分类接口地址', {
    params: { id }
  })
})

console.log(promiseArray)

转写稿中的调试过程是:打印 promiseArray 应看到 Promise 数组;打开 Network 面板,应看到一级分类请求之后发出一排二级分类请求,且每个请求的 id 不一样。
易错点:map 回调里写了 await,导致数组里变成结果而不是 Promise;忘记 return;参数名、查询参数位置没按接口文档写;变量 v 与内层变量混用。

用 Promise.all 等待全部二级分类 ​

为什么需要:必须等所有二级分类请求都成功返回后再渲染,避免只拿到部分数据。
怎么想:Promise.all(promiseArray) 的返回值是一个新的 Promise,因此可以继续用 await 等待。
怎么做:

js
const result = await Promise.all(promiseArray)
console.log(result)

调试时点开 result,它应是数组,数组每一项对应一个二级分类接口结果;再点开每项的 data.data,里面能看到标题 name、图片 picture 和子级列表 children。
易错点:忘记 await Promise.all(...);把 Promise.all 写成其他方法;没有确认每个二级接口都成功返回。

渲染:外层 map 与内层 children.map ​

为什么需要:页面有多个一级区域,每个区域内部又有多项二级分类,属于“数组里套数组”的渲染。
怎么想:先对外层 result 做 map 拼模板;每个区域顶部标题用 v.data.data.name。内部列表基于 children 再 map 生成 li,内层 map 后要 join(''),外层整体也要 join('')。
怎么做:关键骨架如下,模板结构按页面审查元素复制:

js
const html = result.map((v) => {
  const { name, children } = v.data.data
  return `
    <h3>${name}</h3>
    <ul>
      ${children.map((c) => {
        return `<li>
          <img src="${c.picture}" alt="">
          <span>${c.name}</span>
        </li>`
      }).join('')}
    </ul>
  `
}).join('')

document.querySelector('列表容器选择器').innerHTML = html

转写稿的调试方式是:先渲染结构,用“测试用”占位,确认列表出现;发现逗号后补 join('');再替换 name、picture。
易错点:内层 map 后忘记 join 出现逗号;外层忘记 join;v 和 c 用混;innerHTML 拼错;选择器没对准列表容器。

课件图示 ​

课件第36页:需求 获取所有分类数据并同时渲染到页面上 获取一级商品分类 获取所有二级商品分类

代码示例 ​

示例一:获取一级分类 + 生成 promise 数组 + Promise.all 拿到全部二级分类 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>01 - 获取数据</title>
</head>
<body>
  <!-- 只用控制台验证数据,先不渲染 -->
  <script src="https://cdn.jsdelivr.net/npm/axios@1/dist/axios.min.js"></script>
  <script>
    // ⚠️ 换成接口文档里的一级分类 / 二级分类地址
    const API_TOP = 'http://your-host/api/category/top'   // 一级分类,无参数
    const API_SUB = 'http://your-host/api/category/sub'   // 二级分类,需要 id(query 查询参数)

    // 1. 声明 async 函数
    async function getCategoryData() {
      // 2. 获取一级商品分类
      const res = await axios({ url: API_TOP })
      console.log('一级分类:', res.data.data) // 数组,每项里有 id 和 name

      // 3. 基于每个 id 生成一个 promise,组成 promise 数组
      const pArray = res.data.data.map((v) => {
        const { id } = v
        // 注意:这里直接 return,不要写 await
        return axios({ url: API_SUB, params: { id } })
      })
      console.log('promise 数组:', pArray)

      // 4. Promise.all 等待所有二级分类同时成功
      const result = await Promise.all(pArray)
      console.log('所有二级分类:', result)

      return result
    }

    // 5. 声明完一定要调用
    getCategoryData()
  </script>
</body>
</html>

要点 / 易错点

  • async 只是修饰符,函数声明完必须手动调用 getCategoryData(),否则一行都不会执行。
  • 一级分类接口返回的是数组,res.data.data 才是真正的分类数组(axios 外面包了一层)。
  • map 的回调里直接 return axios(...):axios 调用的返回值本身就是 promise 对象,所以 pArray 才是「promise 数组」,才能交给 Promise.all。
  • 二级分类接口的参数是 query 查询参数,所以要写在 params: { id } 里,不是拼在 url 上。
  • 二级分类接口要调「一级分类的个数」那么多次,网络面板里能看到一串请求,每个 id 都不一样——这就是要 Promise.all 的原因。

示例二:渲染数据(map + join + innerHTML,内外双层循环) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>02 - 渲染数据</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    .sub-list { display: flex; flex-wrap: wrap; gap: 24px; padding: 20px; }
    .sub-list > li { list-style: none; width: 300px; }
    .sub-list h3 { font-size: 16px; margin-bottom: 10px; }
    .sub-list ul { display: flex; flex-wrap: wrap; gap: 10px; }
    .sub-list ul li { list-style: none; width: 84px; text-align: center; font-size: 12px; }
    .sub-list img { display: block; width: 60px; height: 60px; margin: 0 auto 4px; }
  </style>
</head>
<body>
  <!-- 大容器里只放一个列表盒子,其余全靠 JS 生成 -->
  <div class="sub-list"></div>

  <script>
    // 模拟 Promise.all 之后拿到的 result,结构与真实接口完全一致
    const result = [
      {
        data: {
          data: {
            name: '居家',
            children: [
              { name: '巧克力', picture: 'https://picsum.photos/seed/a/60/60' },
              { name: '拖鞋',   picture: 'https://picsum.photos/seed/b/60/60' }
            ]
          }
        }
      },
      {
        data: {
          data: {
            name: '服饰',
            children: [
              { name: '衬衫', picture: 'https://picsum.photos/seed/c/60/60' },
              { name: '裤子', picture: 'https://picsum.photos/seed/d/60/60' }
            ]
          }
        }
      }
    ]

    // 外层:一个一级分类 -> 一个 li
    const html = result.map((v) => {
      // 解构出顶级标题 name 和二级分类数组 children
      const { name, children } = v.data.data

      return `<li>
        <h3>${name}</h3>
        <ul>
          ${children.map((c) => `
            <li>
              <img src="${c.picture}" alt="" />
              <span>${c.name}</span>
            </li>`).join('')}
        </ul>
      </li>`
    }).join('')   // 别忘了外层也要 join

    // 一次性渲染
    document.querySelector('.sub-list').innerHTML = html
  </script>
</body>
</html>

要点 / 易错点

  • 数据是「数组套数组」:外层 map 生成一级分类的 li,内层 children.map 生成二级分类的 li。
  • 每个 map 后面都必须 .join(''),否则数组被隐式转成字符串,页面上会多出一堆逗号(视频里那个「逗号从哪来的」就是这个坑)。
  • 内层循环的变量用 c,外层用 v,两个变量名千万别写混,否则取不到 name / picture。
  • 内层 map 的结果直接嵌在外层模板字符串里,所以内层不需要再写 innerHTML;整个页面只在最后赋值一次。
  • 二级分类的字段是 name 和 picture,图片要放进 img 的 src,文本放进 span。
  • 建议写一步测一步:先 console.log(name) 确认标题通了,再替换图片。

示例三:完整整合版(获取 + 等待 + 渲染,带异常兜底) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>03 - 商品分类完整版</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    .sub-list { display: flex; flex-wrap: wrap; gap: 24px; padding: 20px; }
    .sub-list > li { list-style: none; width: 300px; }
    .sub-list h3 { font-size: 16px; margin-bottom: 10px; }
    .sub-list ul { display: flex; flex-wrap: wrap; gap: 10px; }
    .sub-list ul li { list-style: none; width: 84px; text-align: center; font-size: 12px; }
    .sub-list img { display: block; width: 60px; height: 60px; margin: 0 auto 4px; }
  </style>
</head>
<body>
  <div class="sub-list"></div>

  <script src="https://cdn.jsdelivr.net/npm/axios@1/dist/axios.min.js"></script>
  <script>
    // ⚠️ 换成接口文档里的真实地址
    const API_TOP = 'http://your-host/api/category/top'
    const API_SUB = 'http://your-host/api/category/sub'

    async function init() {
      try {
        // 第一步:获取一级商品分类
        const res = await axios({ url: API_TOP })

        // 第二步:获取所有的二级商品分类
        const pArray = res.data.data.map((v) => {
          const { id } = v
          return axios({ url: API_SUB, params: { id } }) // 直接 return,不写 await
        })
        const result = await Promise.all(pArray) // 全部成功后再往下走

        // 第三步:渲染数据
        const html = result.map((v) => {
          const { name, children } = v.data.data
          return `<li>
            <h3>${name}</h3>
            <ul>
              ${children.map((c) => `
                <li>
                  <img src="${c.picture}" alt="" />
                  <span>${c.name}</span>
                </li>`).join('')}
            </ul>
          </li>`
        }).join('')

        document.querySelector('.sub-list').innerHTML = html
      } catch (err) {
        console.log('数据获取失败:', err)
      }
    }

    init()
  </script>
</body>
</html>

要点 / 易错点

  • 三步顺序不能乱:先拿一级分类 → 才能基于 id 生成 promise 数组 → Promise.all 等待全部成功 → 最后才渲染。
  • Promise.all 的返回值是一个新的 promise,所以可以直接 await Promise.all(pArray),不需要再写 .then。
  • 只要有一个二级分类请求失败,Promise.all 就会直接进入 catch,页面不会渲染——这正是「同时渲染」的语义。
  • 只要「多个接口都成功才渲染」这种场景,第一反应就是 Promise.all。
  • 渲染只在最后做一次 innerHTML,中途不要反复赋值,避免页面闪烁和性能浪费。

常见错误 ​

  1. 现象:Network 面板里二级分类请求依次排队发出,总耗时等于各请求耗时相加,页面加载明显变慢。原因:在 map 回调里写了 async v => await axios(...),虽然返回值仍是 Promise、Promise.all 也能用,但循环体内部被 await 阻塞,请求变成串行。修法:回调里直接 return axios(...),把“等待”只留给最外层的 await Promise.all(...)。

  2. 现象:Promise.all 拿到的是 [undefined, undefined, ...],渲染时报 Cannot read properties of undefined。原因:map 回调用了带花括号的函数体却漏写 return(或者误用了 forEach,它本身不返回数组)。修法:补上 return,或改成 arr.map(v => axios(...)) 的简写形式;先 console.log(pArr) 确认数组里装的是 Promise 对象。

  3. 现象:页面完全没反应,Network 里一条请求都没有。原因:只声明了 async function getData(){},忘记在末尾调用。修法:声明后立即调用一次;建议在函数体第一行打个 log,验证执行链路。

  4. 现象:标题、图片位置显示 undefined,或者列表区域空白。原因:解构层级或字段名写错(data / data.data 混用、children、picture、name 拼错或大小写不一致)。修法:每写完一层解构就先 console.log 验证一次,再往下写;渲染前先打印一条完整数据结构作为对照。

  5. 现象:列表项之间多出一个逗号。原因:内层 children.map(...) 得到的是数组,直接塞进模板字符串时会走数组的隐式 toString,用逗号连接。修法:在 map 后补 .join(''),再替换进模板。

  6. 现象:内层列表渲染出的是外层的数据,或所有区域内容一模一样。原因:内外层 map 用了同名变量(都写 v),内层回调拿到的是外层项,或遮蔽后取值错误。修法:外层用 v、内层用 c(或语义化命名 category / sub),写完逐行核对变量来源。

  7. 现象:document.querySelector(...) 返回 null,报 Cannot set properties of null。原因:选择器类名拼写与 HTML 不一致(漏点、大小写、层级写错)。修法:在浏览器里复制该元素的 selector 再粘贴,赋值前先打印一次查询结果。

练习 ​

  1. 并行 vs 串行对比:把当前 map + Promise.all 的实现,另存一份改为 for...of + await 逐个请求二级分类。清空缓存后各跑一次,用 Network 面板记录两种方式的总耗时与请求瀑布图,写三行结论说明何时该选哪种。

  2. 抽离渲染函数:把渲染逻辑抽成 render(list),要求:(a) 主流程只剩“取数 → Promise.all → render”三步;(b) 数据为空数组时,页面显示“暂无分类”占位而不是空白;(c) 不改动原有 DOM 结构。

  3. 容错处理:人为把某一个二级分类的 id 改成不存在的值,让其中一条请求失败。要求页面仍然渲染其余成功的分类,失败的那一项显示兜底文案(提示:Promise.allSettled 或给每个请求加 .catch,二选一,并说明你为什么选它)。

  4. 换一种 DOM 写法:把 map + join + innerHTML 改写成 document.createElement + appendChild(或 insertAdjacentHTML)的方式,两种实现都跑通后,从性能、可读性、安全性(XSS)三个角度各写一句对比。

  5. 懒加载改造:把“一次性并发请求所有二级分类”改成“点击一级标题时才请求并渲染该分类下的二级分类”,要求同一分类重复点击不重复发请求。完成后说明:这个场景下为什么原来的 Promise.all 方案不再是最优解。

原视频对照 ​

#视频时长
1案例-商品分类0111:37
2案例-商品分类0209:37

小结 ​

  1. 这个案例的骨架是“一个前置请求 → 由前置结果派生 N 个请求 → 等全部回来再统一渲染”,Promise.all 只负责最后那一步“等全部”。

  2. map 在这里的唯一职责是把 id 数组转换成 Promise 数组,回调里绝不能加 await,一加就从并行退化为串行。

  3. Promise.all 的入参必须是 Promise 数组:forEach 返回 undefined、漏写 return 返回 undefined 元素,都会让它在真正开始执行前就失败。

  4. await 可以等待任何 Promise,包括 Promise.all 的返回值,所以不需要写 .then 链,用 async/await 读起来更线性。

  5. 两层嵌套渲染时,外层 map 的返回值里可以再套一层 map(...).join(''),但每个 map 都必须配一个 join(''),否则数组会隐式转成逗号串。

  6. 变量命名(v / c)和解构层级是这类嵌套渲染最容易出错的地方,写一步 console.log 验证一步,比写完再调试快得多。

  7. Promise.all 是“一失败全失败”,真实业务里若需要部分成功,应换 Promise.allSettled 或给单个请求加 catch 兜底。

  8. 当请求之间没有依赖、只是都要用结果时用并发;当后一个请求依赖前一个结果、或需要按需触发时,串行/懒加载才是正确选择。