Skip to content

底部品牌广告与楼层(Lesson15 2/2) ​

本页对应 Lesson15,共 5 段视频,约 1 小时 57 分。

学习目标 ​

  • 能够基于 mock 配置与 API 封装规则,完成今日推荐、楼层、商品排行接口的定义与前端调用,并通过控制台验证返回数据结构正确。
  • 能够将首页中的今日推荐、楼层、商品排行拆分为独立 Vue 组件,在父页面中完成引入、注册与渲染,并保证样式和交互正常。
  • 能够分别实现楼层组件两种数据获取方案:父组件获取数组后通过 v-for 传参渲染,以及子组件内自行 dispatch 获取数据并渲染,并能说明两者适用场景。
  • 能够实现商品排行两种数据策略:一次性获取全部排行数据并切换显示,以及切换分类时按类型重新发送请求,并通过选中态和加载效果验证切换结果。
  • 能够使用 Vuex 的 state/mutations/actions 完成接口数据存储与更新,并能按接口、action、mutation、组件取值的顺序定位并修复数据未渲染问题。
  • 能够完成 Swiper 轮播、动态 class、点击事件、watch 配合 immediate 等交互,并通过样式调整使图片、楼层和排行布局符合页面效果。

知识点 ​

底部品牌广告:先把“接口—封装—状态—组件”串起来 ​

为什么需要:首页内容多,底部品牌广告、今日推荐这类模块如果都堆在 home/index.vue 里,阅读性差、维护困难。转写稿里明确说:首页内容多,要按展现内容划分模块,每个模块就是一个组件。

怎么想:先不管页面,先把数据链路打通:mock 提供接口 → API 统一封装 → Vuex 保存数据 → 组件渲染。转写稿反复强调“先完成接口,再封装,再进 store,再渲染”,并且“写一点测一点,不要全写完再调”。

怎么做:在 mock 的 index.js 里增加今日推荐接口,请求方式必须是 GET,地址要和前端最终请求地址一致。图片资源放到 public/images/home 下,页面里用 /images/... 访问。示意代码:

js
// mock/index.js
{
  url: '/adv/today/list',
  type: 'get',
  response: () => ({
    code: 200,
    data: [
      { id: 1, imgUrl: '/images/home/today01.png' },
      { id: 2, imgUrl: '/images/home/today02.png' }
    ]
  })
}
js
// api/adv.js
export const reqTodayList = () =>
  mockRequest({ url: '/adv/today/list', method: 'get' })
js
// store/adv.js
state: { topTodayList: [] },
mutations: { SET_TODAY_LIST(state, list) { state.topTodayList = list } },
actions: {
  async getTodayList({ commit }) {
    const res = await reqTodayList()
    commit('SET_TODAY_LIST', res.data)
  }
}

易错点:mock 请求在 Network 里可能看不到,因为被 mock 拦下来了;不要因此以为没发请求。接口地址、请求方式、baseURL 拼接要一致。图片不要写 src 目录相对路径,放 public 后用绝对路径。

今日推荐组件拆分与渲染 ​

为什么需要:今日推荐只是首页一块,拆成 TodayRecommend/index.vue 后,home 只负责组合,结构更清晰。

怎么想:先把静态结构和样式搬进组件,再在组件里 mounted 调 Vuex action,最后用 v-for 渲染列表。转写稿里说“只保留一个 li”,因为列表项结构相同。

怎么做:

vue
<!-- TodayRecommend/index.vue -->
<template>
  <div class="today-recommend">
    <ul>
      <li v-for="item in topTodayList" :key="item.id">
        <img :src="item.imgUrl" alt="今日推荐商品图" />
      </li>
    </ul>
  </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
  name: 'TodayRecommend',
  mounted() {
    this.$store.dispatch('getTodayList')
  },
  computed: {
    ...mapState(['topTodayList'])
  }
}
</script>

父组件中引入即可。易错点:样式没搬全导致错位;图片路径少写 /;v-for 没写 key;数据没出来时先看 Vuex 里有没有,再查 action、mutation 是否写对。

楼层数据:父组件取数,子组件传参 ​

为什么需要:家用电器、手机通讯等楼层结构一样,不能复制多份。

怎么想:接口返回数组,每个元素描述一个楼层;父组件 v-for 渲染 Floor 组件,把 item 传给子组件。转写稿也给了第二种方案:在 Floor 组件内部取数并自身遍历。两种都能做,建议先用父组件取数传参,职责更清楚。

怎么做:

vue
<!-- home/index.vue -->
<Floor v-for="item in floorList" :key="item.id" :item="item" />
js
// Floor/index.vue
props: ['item']
js
// store/product.js
async getFloorList({ commit }) {
  const res = await reqFloorList()
  commit('SET_FLOOR_LIST', res.data)
}

易错点:楼层图片要放 public,路径用 /images/...;key 用唯一 id;数据没出来先看 Vuex 的 floorList,再沿 action、mutation、组件遍历一层层查。转写稿里说“沿着模块 action 看解对了没有”,这就是排查顺序。

商品排行:两种数据策略 ​

为什么需要:热卖、特价、新品要切换。数据一次性拿还是点一次拿一次,体验不同。

怎么想:方案一,接口一次返回全部排行,前端用 activeIndex 控制显示,切换快但首屏数据多;方案二,每次切换发请求,首屏快但有请求等待。转写稿明确说“看接口怎么给你返回”。

怎么做:方案一保存 rankList,用 v-for 渲染三个标题和内容区,activeIndex 控制选中和显示。方案二用 type 区分 0/1/2,watch type 重新 dispatch,初始 mounted 先取 0。

js
watch: {
  type() {
    this.getRankList()
  }
},
mounted() {
  this.getRankList()
}

易错点:结构赋值和已定义变量冲突时要加括号,如 ({ data } = res);点击后只改 type 不重新请求,页面不会变;方案二初始值要立即调用一次,否则首屏没数据。

轮播图与图片调试 ​

为什么需要:底部品牌广告常用轮播图展示。

怎么想:复用已有轮播组件和 mock 的 banner 数据,不要重复造。转写稿里说“之前已经做过轮播图,把数据拿过来,循环 carouselList”。

怎么做:

vue
<swiper :options="swiperOption">
  <swiper-slide v-for="item in carouselList" :key="item.id">
    <img :src="item.imgUrl" alt="底部品牌广告轮播图" />
  </swiper-slide>
</swiper>

易错点:public 图片路径不能写成 src 相对路径;图片宽高样式要控制,否则撑破布局;需要循环时设置 loop: true;不需要小圆点可删掉分页器,但左右箭头可保留。转写稿里还提醒:图片尺寸不合适时,调 img 的宽高或外层样式,不要只改数据。

先明确目标:一个楼层就是数组里的一个对象 ​

为什么需要:首页里“家用电器”“手机通讯”等楼层结构相同,如果每个楼层都复制一遍模板,代码会非常臃肿,后续维护困难。
怎么想:先看数据。楼层接口返回的是数组,数组里每个元素描述一个楼层;每个楼层的结构一致,所以应该“封装一个楼层组件”,再在 Home 中循环渲染,把每个楼层对象通过 props 传给组件。
怎么做:按“完成 mock 接口 → 封装 API → store 获取并保存 → 封装楼层组件 → Home 调用数据并循环渲染”的顺序做。
易错点:不要把楼层数组当成单个对象;不要每个楼层写一份模板;循环时记得加 :key。

完成 mock 接口:地址和请求方式必须匹配 ​

为什么需要:前端要先有数据才能渲染,mock 用来模拟服务端接口。
怎么想:转写稿里强调,mock 能生效要满足两个条件:请求方式一致、请求地址一致。前端经过 mockRequest 的 baseURL 拼接后,最终地址要和 mock 里写的地址对应。
怎么做:在 src/mock/index.js 中增加楼层数据接口:

js
// 楼层数据
Mock.mock("http://mock.com/product/floorList","get",{
	ok:200,
	data:floorList
})

易错点:地址写成 /product/floorList 还是完整地址,要看 mockRequest 的 baseURL;请求方式写成 post 也匹配不到。转写稿提到 mock 请求在 Network 里可能看不到,因为请求已被 mock 拦截,不要误以为没发请求。

封装 API:统一管理请求 ​

为什么需要:组件里不直接写请求地址,便于集中修改和复用。
怎么想:楼层属于商品相关,放到 src/api/product.js。
怎么做:

js
// 获取楼层信息
export const getFloorList = ()=>mockRequest("/product/floorList");

易错点:函数名和 store 中引入的名字要一致;不要漏掉 mockRequest 的引入。

store 获取并保存数据:state、mutations、actions 三步走 ​

为什么需要:楼层数据要被 Home 页面使用,放进 Vuex 便于统一管理。
怎么想:先定义 state.floorList,再写 mutation 保存,最后在 action 中发请求并 commit。转写稿里反复说“先看有没有数据,再看有没有 commit”,调试要一层一层找。
怎么做:

js
import {getBaseCategoryList, getFloorList} from "@/api/product";

const state = {
	floorList:[],// 楼层数据信息
}
const mutations = {
	SAVE_FLOOR_LIST(state,floorList){
		state.floorList = floorList;
	}
}
const actions = {
	async getFloorListAsync({commit}){
		const {data} = await getFloorList();
		commit("SAVE_FLOOR_LIST",data);
	}
}
export default {
	namespaced:true,
	state,
	mutations,
	actions
}

易错点:忘记 commit 时 state 不会更新;data 解构错层级会拿到 undefined;命名空间开启后调用要写 product/getFloorListAsync。

封装楼层组件:props 接收一个楼层对象 ​

为什么需要:楼层结构重复,抽成组件后 Home 只负责循环。
怎么想:组件只关心“一个楼层对象”怎么渲染,因此用 props:["floorInfo"] 接收。标题、导航、关键词、轮播、推荐位都从 floorInfo 中取。
怎么做:在 src/pages/Home/Floor/index.vue 中写模板,核心代码如下:

vue
<template>
    <div class="floor">
        <div class="py-container">
            <div class="title clearfix">
                <h3 class="fl">{{floorInfo.name}}</h3>
                <div class="fr">
                    <ul class="nav-tabs clearfix">
                        <li :class="{active:index===0}" v-for="(item,index) in floorInfo.navList">
                            <a :href="item.url" data-toggle="tab">{{item.text}}</a>
                        </li>
                    </ul>
                </div>
            </div>
            <div class="tab-content">
                <div class="tab-pane">
                    <div class="floor-1">
                        <div class="blockgary">
                            <ul class="jd-list">
                                <li v-for="(item,index) in floorInfo.keywords" :key="index">{{item}}</li>
                            </ul>
                            <img :src="floorInfo.imgUrl" />
                        </div>
                        <div class="floorBanner">
                            <swiper class="swiper" :options="swiperOption">
                                <swiper-slide v-for="item in floorInfo.carouselList" :key="item.id">
                                    <img :src="item.imgUrl" />
                                </swiper-slide>
                                <div class="swiper-button-prev" slot="button-prev"></div>
                                <div class="swiper-button-next" slot="button-next"></div>
                            </swiper>
                        </div>
                        <div class="split">
                            <span class="floor-x-line"></span>
                            <div class="floor-conver-pit">
                                <img :src="floorInfo.recommendList[0]" />
                            </div>
                            <div class="floor-conver-pit">
                                <img :src="floorInfo.recommendList[1]" />
                            </div>
                        </div>
                        <div class="split center">
                            <img :src="floorInfo.bigImg" />
                        </div>
                        <div class="split">
                            <span class="floor-x-line"></span>
                            <div class="floor-conver-pit">
                                <img :src="floorInfo.recommendList[2]" />
                            </div>
                            <div class="floor-conver-pit">
                                <img :src="floorInfo.recommendList[3]" />
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
import { Swiper, SwiperSlide } from 'vue-awesome-swiper'
import 'swiper/css/swiper.css'
export default {
    name: "Floor",
    props:["floorInfo"],
    components: {
        Swiper,
        SwiperSlide
    },
    data() {
        return {
            swiperOption: {
                slidesPerView: 1,
                spaceBetween: 1,
                loop: true,
                pagination: {
                    el: '.swiper-pagination',
                    clickable: true
                },
                autoplay: {
                    delay: 3000,
                    stopOnLastSlide: false,
                    disableOnInteraction: true,
                },
                navigation: {
                    nextEl: '.swiper-button-next',
                    prevEl: '.swiper-button-prev'
                }
            }
        }
    },
}
</script>

易错点:忘记注册 Swiper、SwiperSlide;忘记引入 swiper 样式;recommendList 下标写错;导航 active 直接写成 index===0 只会第一项高亮,若要做切换需另加当前索引;图片路径要注意 public 目录与相对路径。

Home 调用数据并循环渲染 ​

为什么需要:Home 是页面入口,负责取楼层列表并决定渲染几个楼层组件。
怎么想:在 Home 中派发 action,拿到 $store.state.product.floorList,用 v-for 循环。转写稿也提到另一种方案:把请求放进楼层组件内部,由组件自己循环;笔记采用的是 Home 获取、组件接收 props 的方案。
怎么做:

vue
<Floor v-for="item in $store.state.product.floorList" :floorInfo="item" :key="item.id"></Floor>

易错点:忘记在 Home 注册/引入 Floor;循环变量名和 :floorInfo 写错;key 用 index 虽能跑但不推荐。调试时先看 Vuex 里有没有 floorList,再看组件 props 是否收到,最后看模板字段是否写对。

本节目标:把楼层请求放进 Floor 组件 ​

本节是“楼层方案二”。方案一是在 Home/index.vue 里先 dispatch 获取楼层数据,再用 v-for 遍历 floorList,把每个 floorInfo 传给 Floor 子组件。方案二则反过来:Home 只负责放置 <Floor></Floor>,楼层数据的请求和遍历都放进 Floor/index.vue 内部完成。

为什么需要:首页内容多,如果所有请求、循环、结构都堆在 Home 里,可读性差,楼层组件也不够独立。
怎么想:楼层结构一致,只是数据不同,因此让 Floor 自己取 store 中的 product.floorList,自己循环渲染多个楼层。
怎么做:Home 中只保留组件入口;Floor 的 mounted 中派发 product/getFloorListAsync,模板中循环 $store.state.product.floorList。
易错点:方案二不要在 Home 里重复 dispatch,否则会重复请求;Floor 内循环时外层要有包裹元素,不能把 v-for 直接乱挂在根节点上。

Home 页面只保留组件入口 ​

为什么需要:方案二把数据请求下沉到楼层组件,父页面不再关心楼层数据怎么来。
怎么想:Home 只负责组合页面模块,楼层模块交给 Floor。
怎么做:在 src/pages/Home/index.vue 中写入:

vue
<Floor></Floor>

易错点:如果这里还保留方案一的 v-for 和 dispatch,就会和 Floor 内部请求重复;同时要确认 Floor 已正确注册或引入。

Floor 组件内请求并遍历楼层 ​

为什么需要:楼层数据是一个数组,每个元素描述一个楼层,结构相同,适合在组件内部统一循环。
怎么想:先把 floorList 请求回来,再在模板里用 v-for="floorInfo in $store.state.product.floorList" 渲染。转写稿里强调:如果直接在最外层写 v-for 不合适,就在外面加一个 div 包裹,再在内部循环。
怎么做:保留笔记中的完整组件代码:

vue
<template>
    <div>
        <div class="floor" v-for="floorInfo in $store.state.product.floorList">
            <div class="py-container">
                <div class="title clearfix">
                    <h3 class="fl">{{floorInfo.name}}</h3>
                    <div class="fr">
                        <ul class="nav-tabs clearfix">
                            <li :class="{active:index===0}" v-for="(item,index) in floorInfo.navList">
                                <a :href="item.url" data-toggle="tab">{{item.text}}</a>
                            </li>
                        </ul>
                    </div>
                </div>
                <div class="tab-content">
                    <div class="tab-pane">
                        <div class="floor-1">
                            <div class="blockgary">
                                <ul class="jd-list">
                                    <li v-for="(item,index) in floorInfo.keywords" :key="index">{{item}}</li>
                                </ul>
                                <img :src="floorInfo.imgUrl" />
                            </div>
                            <div class="floorBanner">
                                <swiper class="swiper" :options="swiperOption">
                                    <swiper-slide v-for="item in floorInfo.carouselList" :key="item.id">
                                        <img :src="item.imgUrl" />
                                    </swiper-slide>
                                    <div class="swiper-button-prev" slot="button-prev"></div>
                                    <div class="swiper-button-next" slot="button-next"></div>
                                </swiper>
                            </div>
                            <div class="split">
                                <span class="floor-x-line"></span>
                                <div class="floor-conver-pit">
                                    <img :src="floorInfo.recommendList[0]" />
                                </div>
                                <div class="floor-conver-pit">
                                    <img :src="floorInfo.recommendList[1]" />
                                </div>
                            </div>
                            <div class="split center">
                                <img :src="floorInfo.bigImg" />
                            </div>
                            <div class="split">
                                <span class="floor-x-line"></span>
                                <div class="floor-conver-pit">
                                    <img :src="floorInfo.recommendList[2]" />
                                </div>
                                <div class="floor-conver-pit">
                                    <img :src="floorInfo.recommendList[3]" />
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
import { Swiper, SwiperSlide } from 'vue-awesome-swiper'
import 'swiper/css/swiper.css'
export default {
    name: "Floor",
    props:["floorInfo"],
    components: {
        Swiper,
        SwiperSlide
    },
    data() {
        return {
            swiperOption: {
                slidesPerView: 1,
                spaceBetween: 1,
                loop: true,
                pagination: {
                    el: '.swiper-pagination',
                    clickable: true
                },
                autoplay: {
                    delay: 3000,
                    stopOnLastSlide: false,
                    disableOnInteraction: true,
                },
                navigation: {
                    nextEl: '.swiper-button-next',
                    prevEl: '.swiper-button-prev'
                }
            }
        }
    },
    mounted(){
        this.$store.dispatch("product/getFloorListAsync");
    }
}
</script>

易错点:mounted 中只派发一次请求;路径必须是 product/getFloorListAsync;模板中取的是 $store.state.product.floorList,不要写成别的模块。方案二不再依赖父组件传 floorInfo,props 保留也不影响,但不要误以为数据来自父组件。

调试过程:逐层排查数据 ​

转写稿里强调“写一点测一点,不要全写完再看”。方案二调试顺序是:先在 Vue Devtools 中看 product.floorList 是否有数据;没有数据就检查 mounted 是否执行、dispatch 的 action 名是否写对;再检查 API 封装地址和 mock 接口地址是否一致;最后检查模板中 v-for 的路径和字段名。若页面空白,优先看控制台报错和 store 数据,不要一上来乱改样式。

小结 ​

方案二把楼层请求放进 Floor,Home 只保留 <Floor></Floor>。这样楼层组件更独立,首页结构更清晰。核心是:mounted 派发 product/getFloorListAsync,模板循环 $store.state.product.floorList,每个 floorInfo 渲染一个楼层。注意避免重复请求、路径写错和 v-for 外层缺少包裹元素。

一、先定方案:接口一次性返回所有排行数据 ​

为什么需要:商品排行有“热卖排行、特价排行、新品排行”等多组数据。如果接口一次性全部返回,用户拿到数据后切换 tab 只改前端状态,不再发请求,体验更流畅;缺点是第一次加载数据较多,首屏可能慢。本节先实现这种“一次性返回”的方案。

怎么想:把排行数据设计成数组,每个元素描述一个排行类型,里面再放 productList。组件只负责渲染和切换,不关心数据从哪来。

怎么做:在 src/mock/rank.json 中准备排行数据,保留笔记代码如下:

json
[
    {
        "id": 1,
        "typeName": "热卖排行",
        "productList": [
            {
                "id": 11,
                "name": "农心(NONGSHIM)方便面经典多口味8连包 辛拉面辣白菜拉面多连包 8口味 经典八连包",
                "imgUrl": "https://img12.360buyimg.com/seckillcms/s140x140_jfs/t1/112674/1/39503/144543/64af92b0Fdd48decc/b471ad6197a21ff1.jpg.avif",
                "price": 28.8
            },
            {
                "id": 12,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            },
            {
                "id": 13,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            },
            {
                "id": 14,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            }
        ]
    },
    {
        "id": 2,
        "typeName": "特价排行",
        "productList": [
            {
                "id": 21,
                "name": "千柏山 云南正宗黑糖500g大姨妈经期产妇月子手工老红糖块纯甘蔗熬制 云南黑糖500g",
                "imgUrl": "https://img11.360buyimg.com/seckillcms/s140x140_jfs/t1/117890/25/37901/164674/64aeabddF5db4d94d/949501067d0c385b.jpg.avif",
                "price": 11.8
            },
            {
                "id": 22,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            },
            {
                "id": 23,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            },
            {
                "id": 24,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            }
        ]
    },
    {
        "id": 3,
        "typeName": "新品排行",
        "productList": [
            {
                "id": 31,
                "name": "千丝板栗味馅饼整箱320g办公室点心休闲早餐糕点小零食品年货 板栗味馅饼320g【约8个】",
                "imgUrl": "https://img12.360buyimg.com/seckillcms/s140x140_jfs/t1/108157/15/41592/119490/64c6303eF0bebfe72/10fed046bebf8946.jpg.avif",
                "price": 6
            },
            {
                "id": 32,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            },
            {
                "id": 33,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            },
            {
                "id": 34,
                "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB 银色 移动联通电信4G手机",
                "imgUrl": "/images/1.jpg",
                "price": 100.98
            }
        ]
    }
]

易错点:不要把“一次性返回”误写成“每次切换都请求”;首屏慢是这种方案的代价,切换流畅是它的收益。

二、在 Mock 中注册排行接口 ​

为什么需要:后端接口还没好时,前端要先用 Mock 造数据,保证组件能跑通。

怎么想:Mock 的匹配条件是“请求方式 + 请求地址”。地址要和 axios 拼接后的地址一致,否则匹配不到。

怎么做:在 src/mock/index.js 中注册:

js
// 排行数据
Mock.mock("http://mock.com/product/rankList","get",{
	ok:200,
	data:rankList
});

易错点:转写稿里强调,Mock 拦截后 Network 里可能看不到真实请求,不要以为请求没发;地址不一致时,要检查 baseURL 与接口路径拼接后的结果。请求方式必须是 get。

三、封装 API 与 Vuex 状态 ​

为什么需要:组件不直接写请求,接口统一放 api,数据统一放 store,页面只取状态,后续维护更方便。

怎么想:API 只负责发请求;Vuex 按“state 存数据、mutation 改数据、action 发请求”三步走。

怎么做:src/api/product.js:

js
// 获取商品排行数据
export const getRankList = ()=>mockRequest("/product/rankList");

src/store/product.js:

js
import {getBaseCategoryList, getFloorList, getRankList} from "@/api/product";

const state = {
	rankList:[]
}
const mutations = {
	SAVE_RANK_LIST(state,rankList){
		state.rankList = rankList;
	}
}
const actions = {
	async getRankListAsync({commit}){
		const {data} = await getRankList();
		commit("SAVE_RANK_LIST",data);
	}
}
export default {
	namespaced:true,
	state,
	mutations,
	actions
}

易错点:action 里要解构出 data 再提交;mutation 名要一致;namespaced:true 后组件派发要写 product/getRankListAsync。转写稿中的调试思路是:先在 Vue Devtools 看 product/rankList 有没有值,再沿 action → api → mock 地址逐层排查。

四、组件渲染与 tab 切换 ​

为什么需要:首页内容多,把排行拆成 RankList 组件,首页可读性更好。

怎么想:rankList 渲染 tab,activeIndex 记录当前选中项;内容区用 v-show 控制显示;mounted 时派发 action 取数据。

怎么做:src/pages/Home/RankList/index.vue:

vue
<template>
    <div class="rank">
        <div class="tab">
            <div class="tab-tit clearfix">
                <a @click="activeIndex=index" v-for="(item,index) in rankList" :key="item.id"
                   href="javascript:;"
                   :class="{on:activeIndex===index}">
                    <p class="img">
                        <i></i>
                    </p>
                    <p class="text">{{item.typeName}}</p>
                </a>
            </div>

        </div>
        <div v-show="activeIndex===index" class="content" v-for="(item,index) in rankList">
            <ul>
                <li>
                    <div v-for="info in item.productList" :key="info.id" class="img-item">
                        <p class="tab-pic">
                            <a href="#">
                                <img :src="info.imgUrl" />
                            </a>
                        </p>
                        <div class="tab-info">
                            <div class="info-title">
                                <a href="#">{{info.name}}</a>
                            </div>
                            <p class="info-price">定金:{{info.price}}</p>
                        </div>
                    </div>
                </li>
            </ul>
        </div>
    </div>
</template>

<script>
import {mapState} from "vuex";

export default {
    name: "RankList",
    computed:mapState("product",["rankList"]),
    data(){
        return {
            activeIndex:0
        }
    },
    mounted() {
        this.$store.dispatch("product/getRankListAsync");
    }
}
</script>

易错点:activeIndex 初始要为 0,否则首屏没有选中项;v-for 要写 :key;选中样式用 :class="{on:activeIndex===index}",不要写成普通 class;本节方案切换 tab 不再发请求,这是正常的。转写稿里还提醒:写一点测一点,先确认 Vuex 有数据,再看模板渲染,最后调样式和图片路径。

方案二的核心:切一次,取一次 ​

为什么需要:商品排行有热卖、特价、新品三栏。方案一是进入页面就把三份数据全部取回,再用 v-if/v-show 切换;优点是切换快,缺点是初次请求数据多、渲染慢。方案二改为:默认只取热卖,用户点特价再取特价,点新品再取新品。这样初次渲染速度快,但每次切换都会发请求。

怎么想:把“当前选中类型”作为唯一状态 type,0 热卖、1 特价、2 新品;type 一变就 dispatch 对应 action,拿到最新数据后提交 mutation 更新 rankList2。

怎么做:先准备三份 Mock 数据,再注册三个 GET 接口,然后封装 API、写 Vuex,最后在 RankList.vue 中用 watch 监听 type 并加 immediate: true,保证首次进入也请求热卖。

先造 Mock 数据与接口 ​

三个 JSON 结构一致,字段为 id/name/imgUrl/price。下面保留本节数据文件的关键内容(tejia.json、xinpin.json、remai.json 分别对应特价、新品、热卖):

json
// src/mock/tejia.json
[
  { "id": 21, "name": "千柏山 云南正宗黑糖500g...", "imgUrl": "https://img11.360buyimg.com/...jpg.avif", "price": 11.8 },
  { "id": 22, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 },
  { "id": 23, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 },
  { "id": 24, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 }
]
json
// src/mock/xinpin.json
[
  { "id": 31, "name": "千丝板栗味馅饼整箱320g...", "imgUrl": "https://img12.360buyimg.com/...jpg.avif", "price": 6 },
  { "id": 32, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 },
  { "id": 33, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 },
  { "id": 34, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 }
]
json
// src/mock/remai.json
[
  { "id": 11, "name": "农心(NONGSHIM)方便面经典多口味8连包...", "imgUrl": "https://img12.360buyimg.com/...jpg.avif", "price": 28.8 },
  { "id": 12, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 },
  { "id": 13, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 },
  { "id": 14, "name": "【官网价直降1100】Apple iPhone 8 Plus 256GB...", "imgUrl": "/images/1.jpg", "price": 100.98 }
]
js
// src/mock/index.js
// 热卖排行
Mock.mock("http://mock.com/product/remai", "get", { ok: 200, data: remai });
// 新品排行
Mock.mock("http://mock.com/product/xinpin", "get", { ok: 200, data: xinpin });
// 特价排行
Mock.mock("http://mock.com/product/tejia", "get", { ok: 200, data: tejia });

易错点:Mock 注册的地址必须和 mockRequest 拼接后的最终地址一致;请求方式必须是 get。转写稿里反复说“茂哥/Mock”“接头发/接口”,本质就是核对 Mock 拦截地址与 baseURL 拼接结果。

API 与 Vuex 的职责 ​

为什么需要:组件不直接写请求地址,API 层集中封装;Vuex 统一保存排行数据,组件只负责展示和触发。

js
// src/api/product.js
export const getRemaiList = () => mockRequest("/product/remai");
export const getTeJiaList = () => mockRequest("/product/tejia");
export const getXinPinList = () => mockRequest("/product/xinpin");
js
// src/store/product.js
import { getRemaiList, getTeJiaList, getXinPinList } from "@/api/product";

const state = {
  rankList2: [] // 第二种方案
};
const mutations = {
  SAVE_RANK_LIST2(state, rankList) {
    state.rankList2 = rankList;
  }
};
const actions = {
  async getRankList2Async({ commit }, type) {
    // type:0-remai 1-tejia 2-xinpin
    let data;
    if (type === 0) ({ data } = await getRemaiList());
    else if (type === 1) ({ data } = await getTeJiaList());
    else ({ data } = await getXinPinList());
    commit("SAVE_RANK_LIST2", data);
  }
};
export default { namespaced: true, state, mutations, actions };

易错点:await 返回的是 { ok, data },要解构 data;mutation 名和提交名要一致;转写稿中曾出现“数据有了但页面没有”,最后发现是 rankList2 赋值写错,调试时要先看 Vuex 里 product.rankList2 是否真的有值。

RankList 的切换与监听 ​

vue
<template>
  <div class="rank">
    <div class="tab">
      <div class="tab-tit clearfix">
        <a @click="type = 0" href="javascript:;" :class="{ on: type === 0 }">
          <p class="img"><i></i></p><p class="text">热卖排行</p>
        </a>
        <a @click="type = 1" href="javascript:;" :class="{ on: type === 1 }">
          <p class="img"><i></i></p><p class="text">特价排行</p>
        </a>
        <a @click="type = 2" href="javascript:;" :class="{ on: type === 2 }">
          <p class="img"><i></i></p><p class="text">新品排行</p>
        </a>
      </div>
    </div>
    <div class="content">
      <ul>
        <li>
          <div v-for="item in $store.state.product.rankList2" :key="item.id" class="img-item">
            <p class="tab-pic">
              <a href="#"><img :src="item.imgUrl" alt="商品图片" /></a>
            </p>
            <div class="tab-info">
              <div class="info-title"><a href="#">{{ item.name }}</a></div>
              <p class="info-price">定金:{{ item.price }}</p>
            </div>
          </div>
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  name: "RankList",
  data() {
    return { type: 0 };
  },
  watch: {
    type: {
      handler() {
        this.$store.dispatch("product/getRankList2Async", this.type);
      },
      immediate: true
    }
  }
};
</script>

易错点:immediate: true 不能漏,否则首次没有数据;v-for 要加 :key;on 类名用 type 判断;图片路径要能访问到。调试顺序是:看 type 是否变化 → 看 action 是否执行 → 看接口地址是否被 Mock 拦截 → 看 Vuex 是否有数据 → 最后看模板字段。这样写,就实现了“每次切换排行,均会发送请求获取最新的数据,初次渲染速度快”的方案二。

今日推荐:先让 mock 接口和请求地址对上 ​

为什么需要:首页“今日推荐”的图片不能写死在页面里,否则后面换数据、换图片都要改模板。怎么想:先让 mock 提供一个“获取今日推荐”的接口,再让前端按接口请求;请求能通,再谈渲染。怎么做:在 mock/index.js 里加接口,请求方式必须是 get,地址要和前端最终发出的地址一致。转写中前端通过 axios 的 baseURL 与请求路径拼接,所以 mock 里看到的地址和代码里写的短路径可能“不一样”,但拼接后要相同。可整理为:

js
// mock/index.js
Mock.mock('/adv/today/list', 'get', {
  code: 200,
  data: [
    { id: 1, imgUrl: '/images/today01.png' },
    { id: 2, imgUrl: '/images/today02.png' },
    { id: 3, imgUrl: '/images/today03.png' },
    { id: 4, imgUrl: '/images/today04.png' }
  ]
})

易错点:mock 地址与真实请求地址不一致;baseURL 拼接后路径多了或少了斜线;打开 Network 看不到 mock 请求,是因为 mock 已经把请求拦下来了,不是没发请求。

从 mock 到 API 封装:请求地址要能被追踪 ​

为什么需要:组件里直接写请求地址,后期接口一改就要满项目搜。怎么想:所有广告相关请求集中到 src/api/adv.js,商品相关放到 src/api/product.js。怎么做:按转写整理:

js
// src/api/adv.js
export const reqTodayList = () => request.get('/adv/today/list')

然后在 Vuex 的 adv 模块里写 state、mutations、actions,action 里 dispatch 到 API,拿到数据后 commit 更新状态。易错点:action 名、mutation 名、API 名三者写混;commit 时忘了传数据;把请求直接写在模板里,导致难调试。

组件拆分:今日推荐为什么单独成组件 ​

为什么需要:首页内容越来越多,全塞在 home/index.vue 里可读性差。怎么想:按页面区块拆,今日推荐是一个区块,就建 today-recommend/index.vue。怎么做:把今日推荐的结构和样式剪到组件里,在首页引入;组件内 dispatch('getTodayList'),模板里 v-for 渲染图片:

vue
<div class="item" v-for="item in todayList" :key="item.id">
  <img :src="item.imgUrl" alt="今日推荐商品">
</div>

易错点:样式没跟着搬导致布局错;图片路径仍写旧目录;v-for 忘写 :key;组件拆分后还去父组件取数据。

楼层渲染:两种方案与数据流 ​

为什么需要:家用电器、手机通讯等楼层结构一样,不能复制两遍模板。怎么想:数据是数组,每个元素描述一个楼层,就做一个 Floor 组件,父级遍历后传 item。怎么做:方案一在 home 里请求 floorList,再:

vue
<Floor v-for="item in floorList" :key="item.id" :item="item" />

方案二把请求放进 Floor 组件内部,组件自己 dispatch 并遍历。转写强调两种都能用,前者数据流清晰,后者组件更独立。易错点:楼层图片放在 src 下但模板用绝对路径找不到,应放 public/images;v-for 的 key 用下标;子组件接收属性名与父组件传参名不一致。

商品排行:一次性返回与按需请求 ​

为什么需要:热卖、特价、新品三个排行如果都写死,切换时没法更新。怎么想:方案一接口一次性返回全部排行,前端用 activeIndex/type 控制显示隐藏;方案二每次切换都发请求。怎么做:方案一在模板里遍历标题和商品,点击时改 type,内容区按 type 显示;方案二用 watch 监听 type,变化后 dispatch 对应接口:

js
watch: {
  type() {
    this.getRankList()
  }
}

易错点:一次性返回时忘了用 type 控制显示;按需请求时初始值没触发请求;结构赋值时变量重名,转写中通过加括号解决;mutation 名写错,导致 action 执行了但数据没进 state。

调试过程:写一点测一点 ​

转写里反复强调:不要一口气写完再看。先看 action 有没有执行,再看 API 地址对不对,再看 mutation 有没有提交,最后看组件有没有拿到数据。图片不显示时,先确认资源是否在 public/images,再确认 imgUrl 拼接;排行不切换时,先看点击事件有没有改 type,再看 watch 是否触发。这样一层层找,比“全部写完再猜”快得多。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例一:楼层(方案一)——Home 里取数据,给 Floor 传 props ​

js
// src/mock/index.js
import Mock from "mockjs";
import floorList from "./floorList.json";

// 楼层数据
Mock.mock("http://mock.com/product/floorList", "get", {
  ok: 200,
  data: floorList
});
js
// src/api/product.js
import mockRequest from "@/utils/mockRequest";

// 获取楼层信息
export const getFloorList = () => mockRequest("/product/floorList");
js
// src/store/product.js
import { getBaseCategoryList, getFloorList } from "@/api/product";

const state = {
  floorList: [] // 楼层数据信息
};
const mutations = {
  SAVE_FLOOR_LIST(state, floorList) {
    state.floorList = floorList;
  }
};
const actions = {
  async getFloorListAsync({ commit }) {
    const { data } = await getFloorList();
    commit("SAVE_FLOOR_LIST", data);
  }
};
export default {
  namespaced: true,
  state,
  mutations,
  actions
};
vue
<!-- src/pages/Home/Floor/index.vue -->
<template>
  <div class="floor">
    <div class="py-container">
      <div class="title clearfix">
        <h3 class="fl">{{ floorInfo.name }}</h3>
        <div class="fr">
          <ul class="nav-tabs clearfix">
            <li :class="{ active: index === 0 }" v-for="(item, index) in floorInfo.navList" :key="index">
              <a :href="item.url" data-toggle="tab">{{ item.text }}</a>
            </li>
          </ul>
        </div>
      </div>
      <div class="tab-content">
        <div class="tab-pane">
          <div class="floor-1">
            <div class="blockgary">
              <ul class="jd-list">
                <li v-for="(item, index) in floorInfo.keywords" :key="index">{{ item }}</li>
              </ul>
              <img :src="floorInfo.imgUrl" />
            </div>
            <div class="floorBanner">
              <swiper class="swiper" :options="swiperOption">
                <swiper-slide v-for="item in floorInfo.carouselList" :key="item.id">
                  <img :src="item.imgUrl" />
                </swiper-slide>
                <div class="swiper-button-prev" slot="button-prev"></div>
                <div class="swiper-button-next" slot="button-next"></div>
              </swiper>
            </div>
            <div class="split">
              <span class="floor-x-line"></span>
              <div class="floor-conver-pit">
                <img :src="floorInfo.recommendList[0]" />
              </div>
              <div class="floor-conver-pit">
                <img :src="floorInfo.recommendList[1]" />
              </div>
            </div>
            <div class="split center">
              <img :src="floorInfo.bigImg" />
            </div>
            <div class="split">
              <span class="floor-x-line"></span>
              <div class="floor-conver-pit">
                <img :src="floorInfo.recommendList[2]" />
              </div>
              <div class="floor-conver-pit">
                <img :src="floorInfo.recommendList[3]" />
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { Swiper, SwiperSlide } from "vue-awesome-swiper";
import "swiper/css/swiper.css";
export default {
  name: "Floor",
  props: ["floorInfo"],
  components: {
    // 将 vue-awesome-swiper 提供的组件注册为当前组件
    Swiper,
    SwiperSlide
  },
  data() {
    return {
      // 配置项
      swiperOption: {
        // 显示多少屏
        slidesPerView: 1,
        // 屏之间的间隔
        spaceBetween: 1,
        // 是否循环
        loop: true,
        pagination: {
          // 分页
          el: ".swiper-pagination",
          // 点击小圆点是否进行切换
          clickable: true
        },
        autoplay: {
          // 切换的间隔时间为3秒
          delay: 3000,
          // 如果设置为 true,当切换到最后一个 slide 时停止自动切换
          stopOnLastSlide: false,
          // 用户操作 swiper 之后,是否禁止 autoplay。默认为 true:停止。
          disableOnInteraction: true
        },
        navigation: {
          nextEl: ".swiper-button-next",
          prevEl: ".swiper-button-prev"
        }
      }
    };
  }
};
</script>
vue
<!-- src/pages/Home/index.vue -->
<template>
  <div>
    <Floor v-for="item in $store.state.product.floorList" :floorInfo="item" :key="item.id"></Floor>
  </div>
</template>

<script>
import Floor from "./Floor/index.vue";
export default {
  name: "Home",
  components: { Floor },
  mounted() {
    this.$store.dispatch("product/getFloorListAsync");
  }
};
</script>

关键行与易错点

  • Mock.mock("http://mock.com/product/floorList","get",...) 的地址必须和请求拦截器里 baseURL + 路径 拼出来的最终地址一致,转写稿里特别强调过这点:mockRequest("/product/floorList") 经过 baseURL 拼接后才是完整地址,两边对不上 mock 就拦不到,而且 mock 是拦在请求层的,Network 面板里看不到。
  • props: ["floorInfo"] 与父级 :floorInfo="item" 必须同名;楼层是数组,v-for 一定要写 :key="item.id",笔记里的 mock 数据每个楼层都有唯一 id。
  • 模板里用到的字段名要和 JSON 完全一致:name、navList[{url,text}]、keywords、imgUrl、carouselList[{id,imgUrl}]、recommendList[0..3]、bigImg,少一个就是空白或报错。
  • Swiper、SwiperSlide 必须注册到 components,并且 import "swiper/css/swiper.css" 不能漏,否则轮播图不动、箭头不显示。
  • 楼层有两个,结构完全一样,所以只写一个组件、循环渲染;数据在 Home 里 dispatch 一次即可。

示例二:商品排行方案一——接口一次性返回全部排行 ​

js
// src/mock/index.js
import Mock from "mockjs";
import rankList from "./rank.json";

// 排行数据
Mock.mock("http://mock.com/product/rankList", "get", {
  ok: 200,
  data: rankList
});
js
// src/api/product.js
// 获取商品排行数据
export const getRankList = () => mockRequest("/product/rankList");
js
// src/store/product.js
import { getBaseCategoryList, getFloorList, getRankList } from "@/api/product";

const state = {
  rankList: []
};
const mutations = {
  SAVE_RANK_LIST(state, rankList) {
    state.rankList = rankList;
  }
};
const actions = {
  async getRankListAsync({ commit }) {
    const { data } = await getRankList();
    commit("SAVE_RANK_LIST", data);
  }
};
export default {
  namespaced: true,
  state,
  mutations,
  actions
};
vue
<!-- src/pages/Home/RankList/index.vue -->
<template>
  <div class="rank">
    <div class="tab">
      <div class="tab-tit clearfix">
        <a
          @click="activeIndex = index"
          v-for="(item, index) in rankList"
          :key="item.id"
          href="javascript:;"
          :class="{ on: activeIndex === index }"
        >
          <p class="img"><i></i></p>
          <p class="text">{{ item.typeName }}</p>
        </a>
      </div>
    </div>
    <div v-show="activeIndex === index" class="content" v-for="(item, index) in rankList" :key="item.id">
      <ul>
        <li>
          <div v-for="info in item.productList" :key="info.id" class="img-item">
            <p class="tab-pic">
              <a href="#"><img :src="info.imgUrl" /></a>
            </p>
            <div class="tab-info">
              <div class="info-title">
                <a href="#">{{ info.name }}</a>
              </div>
              <p class="info-price">定金:{{ info.price }}</p>
            </div>
          </div>
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
import { mapState } from "vuex";

export default {
  name: "RankList",
  computed: mapState("product", ["rankList"]),
  data() {
    return {
      activeIndex: 0
    };
  },
  mounted() {
    this.$store.dispatch("product/getRankListAsync");
  }
};
</script>

关键行与易错点

  • computed: mapState("product", ["rankList"]) 里第一个参数是模块名(store 里 namespaced:true 起的名字),写成 mapState(["rankList"]) 就拿不到数据。
  • 切换用 v-show 而不是 v-if:方案一的意义就是数据已全部拿到、只做显示隐藏,用 v-if 反复销毁重建反而浪费。
  • :class="{ on: activeIndex === index }" 是选中态,activeIndex 初始 0,所以默认选中“热卖排行”;模板里的 index 来自 v-for="(item,index)",两处 v-for 都要写 :key。
  • href="javascript:;" 防止 a 标签跳转导致页面刷新丢状态。
  • 缺点写在方案里:首次加载数据量大,渲染慢;优点是一旦拿到,切换无请求、体验流畅。

示例三:商品排行方案二——每次切换发一次请求 ​

js
// src/mock/index.js
import Mock from "mockjs";
import remai from "./remai.json";
import xinpin from "./xinpin.json";
import tejia from "./tejia.json";

// 热卖排行
Mock.mock("http://mock.com/product/remai", "get", { ok: 200, data: remai });
// 新品排行
Mock.mock("http://mock.com/product/xinpin", "get", { ok: 200, data: xinpin });
// 特价排行
Mock.mock("http://mock.com/product/tejia", "get", { ok: 200, data: tejia });
js
// src/api/product.js
export const getRemaiList = () => mockRequest("/product/remai");
export const getTeJiaList = () => mockRequest("/product/tejia");
export const getXinPinList = () => mockRequest("/product/xinpin");
js
// src/store/product.js
import {
  getBaseCategoryList,
  getFloorList,
  getRankList,
  getRemaiList,
  getTeJiaList,
  getXinPinList
} from "@/api/product";

const state = {
  rankList2: [] // 第二种方案
};
const mutations = {
  SAVE_RANK_LIST2(state, rankList) {
    state.rankList2 = rankList;
  }
};
const actions = {
  async getRankList2Async({ commit }, type) {
    // type:0-remai  1- tejia  2-xinpin
    let data;
    if (type === 0) ({ data } = await getRemaiList());
    else if (type === 1) ({ data } = await getTeJiaList());
    else ({ data } = await getXinPinList());
    commit("SAVE_RANK_LIST2", data);
  }
};
export default {
  namespaced: true,
  state,
  mutations,
  actions
};
vue
<!-- src/pages/Home/RankList/index.vue -->
<template>
  <div class="rank">
    <div class="tab">
      <div class="tab-tit clearfix">
        <a @click="type = 0" href="javascript:;" :class="{ on: type === 0 }">
          <p class="img"><i></i></p>
          <p class="text">热卖排行</p>
        </a>
        <a @click="type = 1" href="javascript:;" :class="{ on: type === 1 }">
          <p class="img"><i></i></p>
          <p class="text">特价排行</p>
        </a>
        <a @click="type = 2" href="javascript:;" :class="{ on: type === 2 }">
          <p class="img"><i></i></p>
          <p class="text">新品排行</p>
        </a>
      </div>
    </div>
    <div class="content">
      <ul>
        <li>
          <div v-for="item in $store.state.product.rankList2" :key="item.id" class="img-item">
            <p class="tab-pic">
              <a href="#"><img :src="item.imgUrl" /></a>
            </p>
            <div class="tab-info">
              <div class="info-title">
                <a href="#">{{ item.name }}</a>
              </div>
              <p class="info-price">定金:{{ item.price }}</p>
            </div>
          </div>
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  name: "RankList",
  data() {
    return {
      type: 0
    };
  },
  watch: {
    type: {
      handler() {
        this.$store.dispatch("product/getRankList2Async", this.type);
      },
      immediate: true
    }
  }
};
</script>

关键行与易错点

  • ({ data } = await getRemaiList()); 外面这对括号不能省:前面已经 let data,直接写 { data } = ... 会被解析成语句块而报错,转写稿里专门演示了这个“已声明变量再解构赋值”的场景。
  • 用 watch 监听 type 而不是在 mounted 里 dispatch:immediate: true 让首次进入就用 type=0 拉一次热卖数据,之后点哪个 tab 就按哪个 type 发请求,逻辑只有一份。
  • 三个分支都要把结果赋给同一个 data 变量再 commit("SAVE_RANK_LIST2", data),这样模板里只需要渲染 rankList2 一个数组,不用再按 tab 分开放。
  • 每次切换都会重新发请求,所以会有短暂空白/加载感;这就是方案二的取舍——初次渲染快,但切换有延迟。要保留切换动画可以再套 vue-awesome-swiper 或加 loading 态。
  • 模板里 :key="item.id" 用的是商品 id(11/12/13/14、21...),mock 数据里每个商品都有唯一 id,别用 index 当 key 造成复用错乱。

常见错误 ​

  1. 请求 404,mock 不生效 现象:控制台报 404,或者返回的数据一直是 undefined。 原因:mockRequest 内部会拼接 baseURL,而 mock 文件里注册的是完整地址 http://mock.com/product/floorList,两处地址对不上,于是拦截规则匹配不到。 修法:mock 只认完整地址,api 层只传 /product/floorList;改完地址后,先用 console.log(data) 验证一次,再往下写。

  2. 打开 Network 面板却看不到请求 现象:以为请求没发出去,其实是 mock 已经拦掉了。 原因:Mock.js 重写了 XMLHttpRequest,请求不会走真实网络。 修法:判断数据是否到位,要看 Vuex Devtools 里的 state 或 console.log,不要依赖 Network 面板;要排查地址问题,就临时注释掉 mock 再请求一次。

  3. 楼层里有两个轮播,箭头只会控制第一个 现象:点第二个楼层的左右箭头,第一个楼层跟着切换;或者箭头全挤在第一个楼层。 原因:navigation.nextEl: '.swiper-button-next' 是全局选择器,页面上有多个 swiper 时,每个实例都会匹配到同一个 DOM。 修法:给每个 swiper 配唯一选择器(或用 ref 拿到实例),或者用 <template v-slot:button-next> 作用域插槽把按钮写进组件内部。

  4. 引入 swiper 样式直接编译报错 现象:Can't resolve 'swiper/css/swiper.css'。 原因:swiper/css/swiper.css 是 swiper 5.x 的路径,装了 swiper 3/4 就对不上;vue-awesome-swiper 的主版本和 swiper 主版本是绑定的。 修法:锁定 vue-awesome-swiper@4 + swiper@5;如果使用老版本,样式路径是 swiper/dist/css/swiper.css。

  5. 楼层图片全是裂图 现象:楼层渲染出来了,但图片全是 404。 原因:数据里给的是 /images/xxx.jpg 这种以斜杠开头的路径,它指向 public 目录,而不是 src/assets;或者服务端返回的图片本地根本没有。 修法:把图片统一放进 public/images,路径保持 /images/xxx.jpg;数据里缺图,就自己补图或复用已有图,不要在组件里写 require 去猜路径。

  6. 改成方案二后 Cannot read property 'name' of undefined 现象:模板里一访问 floorInfo.name 就报错。 原因:方案二在 Floor 组件内部自己 dispatch 取数据,父组件已经不传 floorInfo 了,但 props: ["floorInfo"] 和模板里的 floorInfo 还留着。 修法:两种方案不要混用;方案二要删掉 props,模板改成 v-for="floorInfo in $store.state.product.floorList" 并包一层根 div。

  7. 排行区域进页面空白,点一下 tab 才出数据 现象:首屏是空的,手动点“特价”再点回“热卖”才显示。 原因:方案二靠 watch: { type() {...} } 触发请求,而 type 初始值没有变化,watch 不会触发。 修法:watch 加上 immediate: true,或者把 type 写成对象形式 { handler, immediate: true };也可以在 mounted 里先手动 dispatch 一次。

  8. 切换 tab 数据不更新,state 一直是空数组 现象:dispatch 没报错,但页面就是没数据。 原因:action 里 commit 的 mutation 名和 mutations 里定义的拼写不一致(比如 SAVE_RANK_LIST / SAVE_RANK_LIST2 混用),或者 dispatch 漏了模块名前缀 product/。 修法:开启 namespaced: true 后,dispatch 必须带模块名;沿着 action → mutation → state 的顺序,一层层用 Devtools 打印排查,别一次写完再运行。

  9. 解构赋值报语法错误 现象:{data} = await getFloorList() 报 Unexpected token。 原因:变量已声明过,再解构赋值必须整体加括号,否则会被当成代码块。 修法:写成 ({ data } = await getFloorList());更稳妥的做法是 const res = await ...; const data = res.data。

练习 ​

  1. 把“今日推荐”按楼层方案二改造成组件自取数据:父组件只写 <TodayRecommend />,组件内 mounted 里 dispatch。改完后说明:删掉了哪几个 props、模板根节点需要怎么处理。

  2. 让页面上的两个楼层轮播互不干扰:不用全局 class,给每个 swiper 配独立的选择器或 ref,验证点击任意一个楼层的箭头只切换自身。

  3. 把排行方案二的 type 参数从数字改成字符串 'remai' | 'tejia' | 'xinpin',用一张映射表替代 if-else 分支,并补上请求失败时的兜底(接口返回异常时不清空上一次数据)。

  4. 写一个价格格式化方法(28.8 → ¥28.80),在排行和楼层组件里复用,要求不重复定义、不污染全局。

  5. 把 mock 里的楼层/排行数据从“本地图片路径”改成“完整网络图片地址”,改完检查:哪些文件必须动、哪些文件完全不用动?用一句话说明接口层和组件层的边界在哪。

原视频对照 ​

#视频时长
1今日推荐22:28
2完成楼层38:21
3楼层方案二04:12
4商品排行一27:23
5商品排行二24:42

小结 ​

  • mock 里写的是完整地址,api 层写的是相对路径,baseURL 负责拼起来;只要这两处有一处写错,就是 404,先对地址再怀疑组件。
  • mock 拦掉的请求在 Network 里看不到,调试入口应该是 Vuex Devtools 和 console.log,不是浏览器网络面板。
  • 楼层组件有两种做法:父组件传 props,或者子组件自取数据。选一种贯彻到底,混用就会出现 undefined 和重复请求。
  • 多实例组件(swiper)的配置里,凡是“选择器”都是全局的,页面上有第二个实例时必然冲突,要么唯一化,要么走作用域插槽。
  • 数据驱动的页面排查顺序固定:接口 → api → action → mutation → state → 组件,逐层打印,写几行验一次,别写完一整块再跑。
  • 切换类需求(排行 tab)本质是两种权衡:一次全量取,切换流畅但首屏重;按需取,首屏轻但每次切换都要请求,选哪种取决于接口怎么给。
  • 图片这类静态资源,接口返回的是路径不是文件,路径落在 public 还是 assets 决定了能不能访问到,改数据前先确认文件在不在。
  • 组件拆分的标准是“结构重复且数据独立”,不是“代码太长”;拆完父页面只负责编排,不应该再关心楼层内部长什么样。