主题
axios二次封装与api封装(Lesson14 3/3)
本页对应 Lesson14,共 6 段视频,约 1 小时 2 分。
学习目标
- 能够基于 axios 完成二次封装,并按业务模块集中管理 API 请求方法。
- 能够调用封装后的商品分类接口,渲染首页一、二、三级商品分类结构。
- 能够说明大量
router-link带来的渲染开销,并使用事件委托优化分类跳转与内存占用。 - 能够根据当前路由控制商品分类在首页与搜索页的显示和隐藏,并实现搜索页鼠标移入显示、移出隐藏。
- 能够安装并配置 Vuex,划分
product模块,使用 state、mutations、actions、getters 及辅助函数保存并渲染商品分类数据。 - 能够独立完成电影列表切换、取前四条数据展示及底部组件按路由二选一显示的作业功能。
知识点
本节目标与目录
本节要解决的问题是:组件里不要直接写 axios.get(...),而是先做一层“请求器”二次封装,再通过统一出口给组件使用。笔记涉及三个文件:
src/request/sphRequest.js:创建 axios 实例,配置baseURL、超时、请求/响应拦截器。src/request/index.js:统一导出请求器,组件从@/request引入。src/components/Header/TypeNav/index.vue:在mounted里调用接口并打印响应体。
转写稿里反复强调:封装后代码会“越来越少”,接口调用集中、可复用、后期维护方便。本节先把底层请求器搭好,后续再把具体接口集中管理。
为什么需要二次封装
为什么需要:如果每个组件都直接 import axios 并写完整地址,接口地址、超时、进度条、错误处理会散落在各处;改配置时要到处找。
怎么想:转写稿说封装有两个好处:一是对所有接口集中式管理,二是接口可重复使用、方便维护。不要按“页面”拆接口,而应按业务/接口性质拆,避免首页接口只能首页用。
怎么做:在 src/request 下创建 sphRequest.js,用 axios.create 生成实例。
易错点:不要每个请求都重新 axios.create;实例只创建一次,组件引入同一个实例。
创建 sphRequest 实例
为什么需要:统一基础地址和超时时间,避免每个请求重复写 /api 和 timeout。
怎么想:baseURL:"/api" 表示请求会拼到 /api 下;timeout:5000 表示 5 秒超时。转写稿中先写底层请求器,再考虑接口封装,顺序不要反。
怎么做:
js
import axios from "axios";
import nprogress from "nprogress";
import "nprogress/nprogress.css";
const sphRequest = axios.create({
baseURL:"/api",
timeout:5000
});易错点:nprogress.css 必须引入,否则进度条没有样式;baseURL 要和代理配置匹配,不能随意改。
请求拦截器
为什么需要:请求发出前统一做一件事,比如开启顶部进度条。
怎么想:拦截器拿到 config,处理完必须 return config,否则请求配置丢失。
怎么做:
js
// 请求拦截
sphRequest.interceptors.request.use(config=>{
nprogress.start();// 开启进度条
return config;
});易错点:忘记 return config 是最常见错误;不要在请求拦截器里随意改业务数据。
响应拦截器与错误处理
为什么需要:成功时统一结束进度条并直接返回响应体;失败时也要结束进度条并统一提示。
怎么想:转写稿里组件打印的是 body,说明拦截器应把 response.data 返回出去,组件就不用每次 .data。错误分支返回一个 pending 的 Promise,避免后续 then 继续执行。
怎么做:
js
// 响应拦截
sphRequest.interceptors.response.use(response=>{
nprogress.done();// 结束进度条
return response.data;// 返回响应体
},(err)=>{
nprogress.done();// 结束进度条
alert(err);
return new Promise(()=>{});// 返回一个状态为pending的Promise
});
export default sphRequest;易错点:成功和失败都要 nprogress.done();不要返回 undefined 或 Promise.reject 后还在组件里写复杂兜底,本节按笔记返回 pending Promise。
统一出口 index.js
为什么需要:组件不要到处写 @/request/sphRequest,通过统一出口引入,后续扩展其他请求器更方便。
怎么想:sphRequest.js 默认导出实例,index.js 再按需命名导出。
怎么做:
js
import sphRequest from "@/request/sphRequest";
export {
// 调用尚品汇相关接口
sphRequest
}易错点:sphRequest.js 是 export default,index.js 引入时不要加大括号;index.js 导出时才有大括号。
在 TypeNav 中调用并调试
为什么需要:验证封装后的请求器能否正常拿到数据。
怎么想:转写稿里先在 mounted 调用接口并 console.log(body),确认有数据后再渲染。调试时如果报“未定义/找不到”,先检查是否忘记 import。
怎么做:
vue
<script>
import {sphRequest} from "@/request";
export default {
name: "TypeNav",
mounted(){
sphRequest.get("/product/getBaseCategoryList").then(body=>{
console.log(body)
})
}
}
</script>易错点:组件里引入的是 {sphRequest},不是默认导入;接口路径不要再重复写 /api,否则会变成 /api/api/...。刷新后能在控制台看到响应体,说明二次封装链路打通。
先看问题:组件里直接发请求为什么不够好
为什么需要:转写稿里讲师先确认“我现在可以获取到数据了”,也就是在 TypeNav 组件里直接调接口能跑通。但这样做有两个明显问题:接口地址散落在各个组件里,后期不好找;同一个接口如果多个页面都要用,就得重复写地址和请求逻辑。项目一旦改版或接口调整,维护成本会变高。
怎么想:不要把“首页用到的接口”简单塞进 home 文件,而要按业务模块管理。转写稿里讲师明确说“极其不建议”按页面分,因为首页三级分类在其他页面也可能用;更合理的是按接口文档的路径前缀分,比如 product、order、user、购物车等。本节只处理商品相关,所以建 product.js。
怎么做:在 src 下建 api 目录,把商品相关接口集中到 src/api/product.js。组件只负责调用导出的方法,不关心请求地址怎么拼。
易错点:不要因为当前只在首页用,就把文件命名成 home.js;不要在每个组件里重复写 /product/getBaseCategoryList;不要一上来就改 request 配置,先按项目已有 sphRequest 使用。
编写 product.js:按接口文档取路径和请求方式
为什么需要:封装文件要成为“接口字典”。转写稿里反复强调“一切以文档为核心”,先看请求方式,再看接口路径。这样函数名、路径、导出名才能统一。
怎么想:请求方式是 get,函数名就以 get 开头,后面跟接口语义,例如 getBaseCategoryList。路径取文档中商品分类接口的地址;笔记代码里写的是 /product/getBaseCategoryList,说明项目里的 sphRequest 已经处理了公共基地址,封装处不要重复写 /api。
怎么做:保留笔记代码如下,文件实际放在 src/api/product.js:
js
import {sphRequest} from "@/request";
// 首页三级分类 get /api/product/getBaseCategoryList
export const getBaseCategoryList = ()=>sphRequest("/product/getBaseCategoryList");易错点:函数名和导出名要一致;如果 sphRequest 基地址已经包含 /api,这里不要再写成 /api/product/...,否则会重复;不要漏掉 /product 这一级路径;不要在 api 文件里写 slice(0,15) 这类页面展示逻辑,封装只负责请求。
在 TypeNav 中调用:组件只关心“调哪个方法”
为什么需要:封装完成后,组件代码应该变少。转写稿里讲师说“我们这个 src 里面的代码是不是越来越少”,意思就是把请求细节下沉到 api 层。
怎么想:TypeNav 只需要知道“首页三级分类”对应 getBaseCategoryList,在 mounted 里调用,拿到 body 后先打印验证。不要在这里再写请求地址。
怎么做:保留笔记代码:
vue
<script>
import {getBaseCategoryList} from "@/api/product";
export default {
name: "TypeNav",
mounted(){
getBaseCategoryList().then(body=>{
console.log(body);
})
}
}
</script>易错点:导入路径是 @/api/product,不是 @/product;解构名必须是 getBaseCategoryList;mounted 里调用后要 .then 接收结果;不要忘记 import,否则会报“getBaseCategoryList 没有定义”。
调试过程:从“没有定义”到打印出 body
为什么需要:转写稿里的调试很典型:封装后刷新页面,先报错,讲师判断“是不是我没有引入啊”,补上引入后再输出 body。这说明封装不是写完就完,要验证导入、调用、返回结构。
怎么想:先看控制台报错是“方法未定义”还是“请求失败”。如果是方法未定义,优先查 import;如果能请求但没数据,再查路径和 request 配置。
怎么做:按笔记代码在 mounted 中 console.log(body),确认能拿到接口返回。转写稿里还提到接口返回 17 条、页面想展示 15 条,这属于后续渲染阶段的数据截取,不应塞回 product.js。
易错点:不要把 then(body=>{}) 写成 then(body=>{}) 里再解构错层级;不要看到有数据就直接在 api 文件里改返回条数;如果路径重复 /api,请求会 404 或返回异常,先对照接口文档和 sphRequest 配置。
小结:本节要记住的封装边界
为什么需要:api 封装的目标是集中管理、复用和后期维护,不是把所有逻辑都堆进一个文件。
怎么想:按业务模块分文件;函数名体现请求方式和语义;组件只导入并调用方法。
怎么做:src/api/product.js 导出 getBaseCategoryList,TypeNav 导入后在 mounted 调用并打印 body 验证。
易错点:路径别名、导出名、是否重复 /api、是否忘记引入,是这一节最容易出错的地方。
先封装 API:让请求集中管理
为什么需要:首页商品分类的数据要请求接口,如果直接在组件里写请求地址,后续搜索页、详情页也要用同类接口时就会重复;项目改版时还要到处改地址,维护成本高。转写稿里强调“集中式管理”和“重复使用”,所以先把接口收口到 src/api。
怎么想:不要按页面建 home.js,而按业务域建文件。商品分类、商品详情、搜索商品列表都属于商品相关,可放在 product.js。请求方式以 get 开头命名,便于一眼看出方法类型。
怎么做:在 src/api 下新建 product.js,引入封装好的请求对象(转写稿里是 requests 一类),导出函数。示例思路:
js
import requests from './request'
// 以项目实际接口名为准
export const getBaseCategoryList = () => {
return requests({
url: '/api/product/getBaseCategoryList',
method: 'get'
})
}组件里不再直接写地址,而是引入方法调用。转写稿中调试时曾出现“方法没有引入”的报错,补上 import 后即可拿到数据。
易错点:不要把 get 重复拼进 url;接口地址以文档为准;导出名和引入名要一致;请求封装后仍要在组件里 import,否则会报未定义。
渲染三级分类:先一级,再二级,再三级
为什么需要:分类数据是树形结构,一级分类下面有 categoryChild,二级下面还有 categoryChild。如果不先看清结构,直接一把梭渲染,很容易把层级套错。
怎么想:转写稿的推导是“先渲染第一级,再把里面这一级渲染出来,再把里面这一级渲染出来”。数据拿到后不要直接改原数组,先用 slice(0, 15) 截取需要展示的条数,再赋给 categoryList。
怎么做:在 TypeNav/index.vue 中定义 categoryList: [],请求成功后赋值。模板保留本节笔记代码:
vue
<template>
<div class="type-nav">
<div class="container">
<div class="nav-left">
<h2 class="all">全部商品分类</h2>
<div class="sort">
<div class="all-sort-list2">
<div v-for="c1 in categoryList" :key="c1.categoryId" class="item">
<h3>
<router-link to="/search">{{c1.categoryName}}</router-link>
</h3>
<div class="item-list clearfix">
<div v-for="c2 in c1.categoryChild" :key="c2.categoryId" class="subitem">
<dl class="fore">
<dt>
<router-link to="/search">{{c2.categoryName}}</router-link>
</dt>
<dd>
<em v-for="c3 in c2.categoryChild" :key="c3.categoryId">
<router-link to="/search">{{c3.categoryName}}</router-link>
</em>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<nav class="nav">
<a>服装城</a>
<a>美妆馆</a>
<a>尚品汇超市</a>
<a>全球购</a>
<a>闪购</a>
<a>团购</a>
<a>有趣</a>
<a>秒杀</a>
</nav>
</div>
</div>
</template>易错点:slice 会返回新数组,不要误以为会修改原数组;转写稿专门提醒“从两条里再截 15 条最终还是两条”,所以截取顺序要对。key 用 categoryId,不要用索引。三级循环的变量名不要混。
优化大量 router-link:用事件委托
为什么需要:转写稿用堆快照对比,发现分类多、每级都要跳转,大量 router-link 会增加解析和内存开销。分类条目很多时,页面切换也可能出现短暂白屏。
怎么想:所有分类项都要跳搜索,可以把点击行为交给共同父级处理,即事件委托。父级监听点击,通过 data-* 或事件源拿到目标,再 $router.push('/search')。
怎么做:把内层 router-link 改成 a,在父级加 @click="goSearch"。函数里判断点击目标并跳转。转写稿先直接写 $router.push 验证功能,再建议封装函数,因为后续还要带参数、写逻辑。
易错点:事件委托要确认父级范围;点击三级分类时不要被二级父级重复处理;跳转后参数如何传给搜索页是后续内容,不能在本节凭空补全。
Search 页隐藏分类,首页显示
为什么需要:分类导航在首页显示,进入搜索页应默认隐藏;但鼠标移入“全部商品分类”时又要能展开,离开后按当前路由决定是否隐藏。
怎么想:用 isShow 控制显示隐藏。初始值由当前路由判断:首页显示,非首页隐藏。路由切换时组件可能不销毁,所以要 watch $route 重置。鼠标移入设 true,移出时判断“当前是否首页”,首页不隐藏,非首页隐藏。
怎么做:在 TypeNav 中定义 isShow,用 $route.path 或路由 name 判断;给外层容器加 mouseenter、mouseleave。转写稿调试时发现“进入首页不显示”,原因是组件未销毁、data 还是旧值,增加路由监听后解决。
易错点:mouseleave 不要绑在太小区域,否则点二级时容易触发离开;判断首页可用 path 也可用 name,但要和路由配置一致。
用 Vuex 保存分类数据并渲染
为什么需要:分类数据多个组件可能共用,放 Vuex 可避免重复请求。转写稿还提到分类变化不频繁,可做缓存优化。
怎么想:Vue2 项目使用 Vuex 3。建 src/store/index.js 创建仓库,建 product 模块并开启 namespaced。state 存 categoryList,mutations 更新,actions 发请求并 commit。
怎么做:安装 Vuex 3 后创建仓库并注册模块;在 TypeNav 中 dispatch('product/getBaseCategoryList', 15),用 mapState 取 product.categoryList 渲染。转写稿还演示了 mapActions 简写,调用时传保留条数。
易错点:开启命名空间后要写 product/xxx;action 里 commit 的 mutation 名要对;mapState 取模块状态要加模块名;不要把 slice 结果直接覆盖原数据导致后续无法再截取。
为什么需要:分类跳转不能全用 router-link
本节要解决的是 TypeNav 首页商品分类的渲染内存问题。分类数据有三级:一级 15 个,每个一级下面又有多个二级,每个二级下面又有多个三级。转写稿里反复提到“分类较多,每个分类均需要跳转”,如果每个可点击项都用 router-link,那么页面上会创建大量组件实例。router-link 不是普通 a 标签,它需要解析、响应式更新和路由相关处理,数量一多就会增加内存与渲染开销。转写稿中先改成 router-link 后拍照看堆内存,约在 19/20 左右,刷新时还有短暂白屏;这正是需要优化的信号。
怎么想:把点击行为交给共同父级
思路是“事件委托”:不给每个分类单独绑定跳转组件或点击事件,而是把点击监听放在它们共同的父级容器上,利用事件冒泡统一处理。转写稿里说“交给他的父级”,并给父级 div 增加 @click,内部仍用普通 a 标签。这样只保留一个监听器,减少大量 router-link 实例。注意转写稿中的“如斯钢链/root杠link”应写作 router-link,“委托”指事件委托。
怎么做:保留本节组件代码
下面是本节笔记中的 src/components/Header/TypeNav/index.vue,保留原代码。可以看到内部三级分类使用普通 a 标签,父级 div.all-sort-list2 上统一绑定 @click="$router.push('/search')",这就是委托式跳转的落点。
vue
<template>
<div class="type-nav">
<div class="container">
<div class="nav-left">
<h2 class="all">全部商品分类</h2>
<div class="sort">
<div @click="$router.push('/search')" class="all-sort-list2">
<div v-for="c1 in categoryList" :key="c1.categoryId" class="item">
<h3>
<a>{{c1.categoryName}}</a>
</h3>
<div class="item-list clearfix">
<div v-for="c2 in c1.categoryChild" :key="c2.categoryId" class="subitem">
<dl class="fore">
<dt>
<a>{{c2.categoryName}}</a>
</dt>
<dd>
<em v-for="c3 in c2.categoryChild" :key="c3.categoryId">
<a>{{c3.categoryName}}</a>
</em>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<nav class="nav">
<a>服装城</a>
<a>美妆馆</a>
<a>尚品汇超市</a>
<a>全球购</a>
<a>闪购</a>
<a>团购</a>
<a>有趣</a>
<a>秒杀</a>
</nav>
</div>
</div>
</template>
<script>
import {getBaseCategoryList} from "@/api/product";
export default {
name: "TypeNav",
data(){
return {
categoryList:[]
}
},
mounted(){
getBaseCategoryList().then(body=>{
this.categoryList = body.data.splice(0,15);
})
}
}
</script>数据获取处用 splice(0,15) 只取前 15 条。转写稿提醒过:splice 会修改原数组,body.data 原本 17 条,截取后原数组只剩 2 条;如果再次从剩余数据里截 15 条,最终仍只有 2 条。这是调试时容易误判的地方。模板中三级遍历分别用 c1/c2/c3,key 用 categoryId,避免用索引造成更新问题。
调试与验证:点击能跳,内存下降
转写稿的验证过程是:先给分类项加 router-link,点击能跳搜索,但内存占用偏高;再把内部改回 a,在共同父级加 @click,点击一级、二级、三级都能通过冒泡触发父级跳转,内存占用下降。实现时可以先只写 $router.push('/search') 验证跳转,后续需要带分类参数时再在方法里取事件对象或 data-*。不要在每个 a 上重复写 @click,否则又退回到大量监听器。
易错点
第一,splice 会改原数组,不要误以为它只是“复制前 15 条”。第二,事件委托要绑在共同父级,点击子元素会冒泡;如果父级范围过大,可能误触。第三,a 标签默认行为可能导致地址变化,必要时加 .prevent。第四,本节先统一跳 /search,若后续要区分一级/二级/三级,不能只写死无参数跳转,应在委托处理函数里判断目标或读取 data-*。第五,内存拍照只是辅助验证,不要把它当成精确指标;核心是减少大量 router-link 组件实例。
本节要解决的问题
在 Header 的 TypeNav 中,首页要展示“全部商品分类”,进入 /search 后默认隐藏;但当鼠标移入“全部商品分类”时,仍要能展开分类,移出后再隐藏。点击任意分类还要跳到搜索页。
为什么需要:搜索页如果一直展开分类会遮挡内容,但用户又可能需要重新选分类,所以要做“默认隐藏、移入显示、移出隐藏”的联动。
怎么想:用 isShow 控制显示隐藏,初始值由当前路由决定;用 watch 监听 $route.path,解决组件复用不销毁导致 data 不重置的问题。
怎么做:见文末完整代码。
易错点:mouseleave 要放在包含标题和菜单的父级 nav-left 上,不能只放在 h2 上,否则鼠标移到子菜单就会误隐藏。
数据从哪来:先封装 API 再请求
为什么需要:分类接口要集中管理、方便复用,不能每个页面各写一套请求。
怎么想:商品相关接口放到 api/product.js,导出 getBaseCategoryList;组件只负责调用和渲染。
怎么做:组件里 import {getBaseCategoryList} from "@/api/product",在 mounted 中请求,拿到 body.data 后截取前 15 条赋给 categoryList。
易错点:转写稿里调试时先打印 body.data,发现接口返回 17 条,页面只要 15 条,于是写了 body.data.splice(0,15)。但 splice 会修改原数组,截取后原数组只剩 2 条;如果再次对同一个 body.data 做 splice(0,15),最终只剩 2 条。这里要理解 splice 与 slice 的区别,后续更灵活的做法是让 action 接收条数参数,或用 slice 不改变原数组。
三级分类怎么渲染
为什么需要:分类是三级结构,必须按层级嵌套渲染。
怎么想:一级 c1 遍历 categoryList,二级 c2 遍历 c1.categoryChild,三级 c3 遍历 c2.categoryChild。
怎么做:三层 v-for,key 用各自的 categoryId,名称用 categoryName。
易错点:层级不要写错;key 要唯一;不要把所有层级都写成同一层数组。
显示隐藏与路由联动
为什么需要:首页默认展示,搜索页默认隐藏,但搜索页鼠标移入还要能展开。
怎么想:isShow 初始值写成 this.$route.path === "/";watch 监听 $route.path,路径变化时重新赋值;categoryHide 里判断“不是首页才允许隐藏”。
怎么做:h2 上 @mouseenter="isShow=true",父级 nav-left 上 @mouseleave="categoryHide"。
易错点:转写稿中进入 /search 后 isShow 仍是 true,因为 TypeNav 组件没有被销毁,data 不会重新初始化;加 watch "$route.path" 后才恢复。另外,离开事件要放在父级,否则从标题移到菜单时会触发隐藏,导致菜单点不到。
点击跳转 search 与优化
为什么需要:每个分类都要能跳到搜索页。
怎么想:如果每个 a 都写 router-link,数量很大,会占用较多内存;转写稿里拍内存快照后改为事件委托,把点击交给共同父级。
怎么做:在 all-sort-list2 上写 @click.prevent="$router.push('/search')",内部 a 不写 href,靠冒泡到父级统一跳转。
易错点:.prevent 要加,避免 a 默认行为;点击子元素会冒泡到父级,所以父级绑定一次即可,不要在每层重复绑定。
完整代码
vue
<template>
<div class="type-nav">
<div class="container">
<div class="nav-left" @mouseleave="categoryHide">
<h2 class="all" @mouseenter="isShow=true">全部商品分类</h2>
<div class="sort" v-show="isShow">
<div @click.prevent="$router.push('/search')" class="all-sort-list2">
<div v-for="c1 in categoryList" :key="c1.categoryId" class="item">
<h3>
<a href="">{{c1.categoryName}}</a>
</h3>
<div class="item-list clearfix">
<div v-for="c2 in c1.categoryChild" :key="c2.categoryId" class="subitem">
<dl class="fore">
<dt>
<a href="">{{c2.categoryName}}</a>
</dt>
<dd>
<em v-for="c3 in c2.categoryChild" :key="c3.categoryId">
<a href="">{{c3.categoryName}}</a>
</em>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<nav class="nav">
<a>服装城</a>
<a>美妆馆</a>
<a>尚品汇超市</a>
<a>全球购</a>
<a>闪购</a>
<a>团购</a>
<a>有趣</a>
<a>秒杀</a>
</nav>
</div>
</div>
</template>
<script>
import {getBaseCategoryList} from "@/api/product";
export default {
name: "TypeNav",
data(){
return {
categoryList:[],
isShow:this.$route.path === "/"
}
},
methods:{
categoryHide(){
// 判断当前是否为首页,如果为首页不允许隐藏
if(this.$route.path !== "/") this.isShow = false;
}
},
watch:{
"$route.path":{
handler(){
this.isShow = this.$route.path === "/";
}
}
},
mounted(){
getBaseCategoryList().then(body=>{
this.categoryList = body.data.splice(0,15);
})
}
}
</script>一、为什么用 Vuex 保存分类数据
为什么需要:首页商品分类在 TypeNav 里请求并渲染,但搜索页等组件也可能要用;如果每个组件都发请求,接口分散、重复请求、后期难维护。转写稿里先做 API 封装,再把数据放进 Vuex,就是为了“集中管理接口 + 跨组件共享状态”。
怎么想:接口数据属于商品模块,放到 store/product.js;组件只负责派发 action、映射 state,不再直接写请求逻辑。Vue2 项目配 Vuex3。
怎么做:先安装:
shell
cnpm i vuex@3易错点:Vue2 不要装 Vuex4;忘记 Vue.use(Vuex) 或忘记在 new Vue 里注入 store,组件里 this.$store 会拿不到。
二、创建仓库与商品模块
为什么需要:仓库要提供统一入口,模块要划分商品、用户、购物车等状态。转写稿说“除了商品还有用户、支付、购物车”,所以用 modules 划分。
怎么做:src/store/index.js 提供 store:
js
import Vue from "vue";
import Vuex from "vuex";
import product from "@/store/product";
Vue.use(Vuex);
const store = new Vuex.Store({
modules:{
product
}
});
export default store;src/store/product.js 中定义 state、mutations、actions:
js
import {getBaseCategoryList} from "@/api/product";
const state = {
// 首页分类列表
categoryList:[],
}
const mutations = {
// 更新首页分类列表
UP_CATEGORY_LIST(state,categoryList){
state.categoryList = categoryList;
}
}
const actions = {
async getBaseCategoryListAsync({commit},num=1){
const {data} = await getBaseCategoryList();
commit("UP_CATEGORY_LIST",data.splice(0,num));
}
}
export default {
namespaced:true,
state,
mutations,
actions
}怎么想:action 负责异步请求,mutation 负责同步改 state;namespaced:true 后派发要带模块名。
易错点:转写稿专门调试了 splice:data.splice(0,15) 会修改原数组,17 条被删到只剩 2 条,再截 15 还是 2 条。若只想截取不改原数组,应使用 slice(0,num);笔记原代码是 splice,要理解其副作用。mutation 名 UP_CATEGORY_LIST 与 commit 名必须一致。
三、把 store 注入 Vue 实例
为什么需要:只有注入根实例,所有组件才能通过 this.$store 访问仓库。
js
import Vue from "vue";
import App from "@/App";
import router from "@/router";
import store from "@/store";
import "@/assets/css/reset.css";
// 设置为 false 以阻止 vue 在启动时生成生产提示
Vue.config.productionTip = false;
new Vue({
el:"#app",
router,
store,
render:h=>h(App)
})易错点:store 要小写注入;路径 @/store 默认找 index.js。
四、TypeNav 中派发 action 与映射状态
为什么需要:组件挂载后要拿分类数据;用 mapActions、mapState 简化代码。转写稿先写非简写 this.$store.dispatch("product/getBaseCategoryListAsync",15),再改成简写。
怎么做:在 TypeNav 中:
vue
<script>
import {mapActions,mapState} from "vuex";
export default {
name: "TypeNav",
data() {
return {
isShow: this.$route.path === "/"
}
},
methods: {
...mapActions("product", ["getBaseCategoryListAsync"]),
categoryHide() {
if (this.$route.path !== "/") this.isShow = false;
}
},
watch: {
"$route.path": {
handler() {
this.isShow = this.$route.path === "/";
}
}
},
computed:mapState("product",["categoryList"]),
mounted() {
// 非简写:
// this.$store.dispatch("product/getBaseCategoryListAsync", 15)
// 简写:
this.getBaseCategoryListAsync(15);
}
}
</script>易错点:mapActions("product", [...]) 第一参数是模块名;mapState 同理。转写稿调试 isShow 时发现:从首页跳到搜索页,组件未销毁,data 不会重置,所以加 watch "$route.path" 重新计算;鼠标移入显示,移出时若在首页不能隐藏,否则首页菜单会误关。
五、模板渲染三级分类
为什么需要:接口是三级结构:categoryList → categoryChild → categoryChild,要逐层 v-for。
vue
<template>
<div class="type-nav">
<div class="container">
<div class="nav-left" @mouseleave="categoryHide">
<h2 class="all" @mouseenter="isShow=true">全部商品分类</h2>
<div class="sort" v-show="isShow">
<div @click.prevent="$router.push('/search')" class="all-sort-list2">
<div v-for="c1 in categoryList" :key="c1.categoryId" class="item">
<h3>
<a href="">{{ c1.categoryName }}</a>
</h3>
<div class="item-list clearfix">
<div v-for="c2 in c1.categoryChild" :key="c2.categoryId" class="subitem">
<dl class="fore">
<dt>
<a href="">{{ c2.categoryName }}</a>
</dt>
<dd>
<em v-for="c3 in c2.categoryChild" :key="c3.categoryId">
<a href="">{{ c3.categoryName }}</a>
</em>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<nav class="nav">
<a>服装城</a>
<a>美妆馆</a>
<a>尚品汇超市</a>
<a>全球购</a>
<a>闪购</a>
<a>团购</a>
<a>有趣</a>
<a>秒杀</a>
</nav>
</div>
</div>
</template>易错点:v-for 必须加 :key;v-show="isShow" 控制显隐;点击分类用 @click.prevent="$router.push('/search')" 阻止默认跳转。转写稿还提到大量 router-link 会占用内存,后续可用事件委托优化,但本节先完成 Vuex 保存与渲染。
本节目标与整体思路
本节要完成“首页商品分类”的接口封装、三级分类渲染、跳转优化、搜索页显隐控制,以及用 Vuex 保存分类数据并渲染。转写稿里“渲认/渲选”应理解为“渲染”,“如斯钢链”是 router-link,“未有场/吴有叉”是 Vuex,“猫迹”是 mutation,“阿甲格斯”是 actions,“名部件/秘密空间”是命名空间。
每个知识点都按“为什么需要 → 怎么想 → 怎么做 → 易错点”推进。本节笔记未附图片,以下不虚构配图,只保留并整理转写稿中的关键代码。
一、封装 API:集中管理接口
为什么需要:首页组件里直接发请求,会让接口地址散落在各个组件中;后期改版或复用接口时维护成本高。
怎么想:在 src/api 下按业务拆分,而不是按页面拆分。转写稿明确不建议把首页接口都塞进 home,因为商品分类在其他页面也可能用,按 product 这类业务模块管理更稳。
怎么做:引入封装好的 request,请求方式是什么就用什么开头命名,例如 getBaseCategoryList。
js
// src/api/product.js
import request from '@/utils/request'
export const getBaseCategoryList = () => {
return request({
url: '按接口文档复制的地址',
method: 'get'
})
}易错点:接口地址、请求方式要以文档为准;不要把接口按“页面”硬绑定;方法名建议体现请求方式和业务含义,便于复用。
二、渲染三级分类:先一级,再二级、三级
为什么需要:接口返回的是树形分类,页面需要一级标题、悬浮后的二级、再展开的三级。
怎么想:先拿到分类数组,再逐层 v-for。转写稿中接口返回 17 条,页面只想展示 15 条,因此要截取。
怎么做:组件中保存 categoryList,挂载后请求并赋值,再用 v-for 渲染。
js
data() {
return { categoryList: [] }
},
async mounted() {
const res = await getBaseCategoryList()
this.categoryList = res.data.slice(0, 15)
}html
<div v-for="c1 in categoryList" :key="c1.id">
{{ c1.categoryName }}
<div v-for="c2 in c1.children" :key="c2.id">
{{ c2.categoryName }}
<em v-for="c3 in c2.children" :key="c3.id">
{{ c3.categoryName }}
</em>
</div>
</div>易错点:转写稿里把 slice 说成会删原数据,这里要纠正:slice 返回新数组,不修改原数组;splice 才会改原数组。若发现“只剩两条”,通常是先改动了原数据或用错方法。key 要用接口里的 id,不要用索引凑。
三、优化大量 router-link:用事件委托
为什么需要:一级、二级、三级分类都要能跳搜索页。若每一项都用 router-link,组件实例非常多,转写稿中通过内存快照观察到占用上升,解析也变慢。
怎么想:把点击行为交给共同父级处理,子项改成普通 a 标签,父级绑定点击事件后统一 $router.push。
怎么做:
html
<div @click="goSearch">
<a v-for="c1 in categoryList" :key="c1.id">{{ c1.categoryName }}</a>
</div>js
methods: {
goSearch() {
this.$router.push('/search')
}
}易错点:转写稿先直接跳搜索,后续才处理搜索页渲染;不要误以为这里已经完成搜索参数传递。父级点击要确认事件范围,避免点空白也触发。
四、搜索页隐藏分类,首页显示分类
为什么需要:进入搜索页后,顶部商品分类不应继续展示;回到首页才显示。
怎么想:用 isShow 控制显示隐藏,初始值由当前路由决定;组件未销毁时 data 不会自动重置,所以要监听路由变化。
怎么做:
js
data() {
return { isShow: this.$route.name === 'home' }
},
watch: {
'$route.path'() {
this.isShow = this.$route.name === 'home'
}
}html
<div v-if="isShow">商品分类内容</div>鼠标移入显示、离开时仅非首页隐藏:
js
leaveShow() {
if (this.$route.name !== 'home') {
this.isShow = false
}
}易错点:只在 data 里写一次初始值不够,路由切换后组件复用会导致状态不更新;鼠标离开要绑在合适的父级上,否则鼠标移到子内容时会误隐藏。
五、用 Vuex 保存并渲染分类数据
为什么需要:分类数据多个页面可能共用,放进 Vuex 便于集中维护。
怎么想:安装 Vuex 3,建立 src/store/index.js 和 product 模块;模块开启 namespaced,state 存 categoryList,mutation 更新,action 请求并提交。
怎么做:
js
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import product from './product'
Vue.use(Vuex)
export default new Vuex.Store({
modules: { product }
})js
// src/store/product.js
import { getBaseCategoryList } from '@/api/product'
export default {
namespaced: true,
state: { categoryList: [] },
mutations: {
SET_CATEGORY_LIST(state, payload) {
state.categoryList = payload
}
},
actions: {
async getBaseCategoryList({ commit }, limit = 10) {
const res = await getBaseCategoryList()
commit('SET_CATEGORY_LIST', res.data.slice(0, limit))
}
}
}组件中使用:
js
import { mapState, mapActions } from 'vuex'
computed: {
...mapState('product', ['categoryList'])
},
mounted() {
this.getBaseCategoryList(15)
},
methods: {
...mapActions('product', ['getBaseCategoryList'])
}易错点:开启命名空间后,mapState、mapActions 要写模块名;action 里不要直接改 state,必须 commit mutation;默认条数可设默认值,但调用时传 15 才符合首页需求。
六、作业提示
转写稿最后提到两个作业:做“正在热映/即将上映”切换并展示电影图片、名称、描述、导演等,取前四条;底部登录、注册、搜索等区域可拆成独立组件,用 v-if 在“带顶部内容”和“仅底部组件”之间二选一。这里只保留思路,不额外编造实现。
课堂画面






代码示例
① axios 二次封装 + api 封装
js
// src/request/sphRequest.js
import axios from "axios";
import nprogress from "nprogress";
import "nprogress/nprogress.css";
const sphRequest = axios.create({
baseURL: "/api",
timeout: 5000
});
// 请求拦截
sphRequest.interceptors.request.use(config => {
nprogress.start(); // 开启进度条
return config;
});
// 响应拦截
sphRequest.interceptors.response.use(response => {
nprogress.done(); // 结束进度条
return response.data; // 返回响应体
}, (err) => {
nprogress.done(); // 结束进度条
alert(err);
return new Promise(() => {}); // 返回一个状态为 pending 的 Promise
});
export default sphRequest;js
// src/request/index.js
import sphRequest from "@/request/sphRequest";
export {
// 调用尚品汇相关接口
sphRequest
}js
// src/api/product.js
import { sphRequest } from "@/request";
// 首页三级分类 get /api/product/getBaseCategoryList
export const getBaseCategoryList = () => sphRequest("/product/getBaseCategoryList");要点
baseURL: "/api"统一前缀,业务层只写/product/getBaseCategoryList,换域名只改配置。- 响应拦截里
return response.data,所以业务层拿到的直接是响应体,不用再.data。 - 失败分支
return new Promise(() => {})是易错点:返回 pending 状态的 Promise,让后续.then永远不执行,避免报错后继续渲染;若写return err会当成成功继续走。 - 接口按业务划分文件(
api/product.js)集中管理,不要按页面划分,方便复用与维护。
② vuex 保存数据(模块化 + 命名空间)
js
// src/store/index.js
import Vue from "vue";
import Vuex from "vuex";
import product from "@/store/product";
Vue.use(Vuex);
const store = new Vuex.Store({
modules: {
product
}
});
export default store;js
// src/store/product.js
import { getBaseCategoryList } from "@/api/product";
const state = {
// 首页分类列表
categoryList: []
};
const mutations = {
// 更新首页分类列表
UP_CATEGORY_LIST(state, categoryList) {
state.categoryList = categoryList;
}
};
const actions = {
async getBaseCategoryListAsync({ commit }, num = 1) {
const { data } = await getBaseCategoryList();
commit("UP_CATEGORY_LIST", data.splice(0, num));
}
};
export default {
namespaced: true,
state,
mutations,
actions
};js
// src/main.js(挂载仓库)
import Vue from "vue";
import App from "@/App";
import router from "@/router";
import store from "@/store";
import "@/assets/css/reset.css";
Vue.config.productionTip = false;
new Vue({
el: "#app",
router,
store,
render: h => h(App)
});要点
Vue.use(Vuex)必须写在new Vuex.Store之前。namespaced: true后,调用必须带模块名:dispatch("product/getBaseCategoryListAsync", 15),mapState("product", ["categoryList"])。data.splice(0, num)是易错点:splice 会改动原数组,所以不能对同一份 data 重复截取,否则第二次只剩两条数据。num = 1是默认参数,让 action 可传条数,比写死 15 更灵活。- Vuex 安装要指定 3.x:
cnpm i vuex@3,Vue2 用不了 4.x。
③ TypeNav:渲染三级分类 + 事件委托 + 显隐控制
vue
<template>
<div class="type-nav">
<div class="container">
<div class="nav-left" @mouseleave="categoryHide">
<h2 class="all" @mouseenter="isShow = true">全部商品分类</h2>
<div class="sort" v-show="isShow">
<div @click.prevent="$router.push('/search')" class="all-sort-list2">
<div v-for="c1 in categoryList" :key="c1.categoryId" class="item">
<h3>
<a href="">{{ c1.categoryName }}</a>
</h3>
<div class="item-list clearfix">
<div v-for="c2 in c1.categoryChild" :key="c2.categoryId" class="subitem">
<dl class="fore">
<dt>
<a href="">{{ c2.categoryName }}</a>
</dt>
<dd>
<em v-for="c3 in c2.categoryChild" :key="c3.categoryId">
<a href="">{{ c3.categoryName }}</a>
</em>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
<nav class="nav">
<a>服装城</a>
<a>美妆馆</a>
<a>尚品汇超市</a>
<a>全球购</a>
<a>闪购</a>
<a>团购</a>
<a>有趣</a>
<a>秒杀</a>
</nav>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from "vuex";
export default {
name: "TypeNav",
data() {
return {
isShow: this.$route.path === "/"
};
},
methods: {
...mapActions("product", ["getBaseCategoryListAsync"]),
categoryHide() {
// 判断当前是否为首页,如果为首页不允许隐藏
if (this.$route.path !== "/") this.isShow = false;
}
},
watch: {
"$route.path": {
handler() {
this.isShow = this.$route.path === "/";
}
}
},
computed: mapState("product", ["categoryList"]),
mounted() {
// 非简写:this.$store.dispatch("product/getBaseCategoryListAsync", 15)
this.getBaseCategoryListAsync(15);
}
};
</script>要点
- 优化内存:把上千个
router-link换成a,在父级.all-sort-list2上用一次@click做事件委托,靠冒泡统一跳转,大幅减少组件实例与内存占用。 @click.prevent不能省:内部a有href="",不阻止默认行为会整页刷新、丢失跳转。@mouseleave必须加在父级.nav-left上,加在.sort上会导致鼠标移向下方列表时立刻被判定"离开"而收起,这是常见易错点。isShow初始值用this.$route.path === "/",非首页首次进入就是隐藏的。- 组件在路由切换时不会被销毁,
data不会重置,所以必须用watch监听$route.path重新赋值;若想用路由名判断,需在 router 里给首页配name: "home"。 - 三级数据字段是
categoryId / categoryName / categoryChild,v-for必须写:key,否则渲染更新异常。
常见错误
1. 取值多剥一层,报 Cannot read properties of undefined
- 现象:
getBaseCategoryList().then(res => { this.categoryList = res.data.data })拿不到数组,res.data为undefined。 - 原因:
sphRequest.js的响应拦截器已经return response.data,Promise 的 resolve 值就是后端响应体{code, message, data},组件里再点.data就多剥了一层;而 store 的 action 里用const {data} = await ...恰好是对的一层解构,两处写法不一致最容易踩。 - 修法:统一约定"调用方只解构一层"——组件里
body.data,action 里const {data} = await getBaseCategoryList()。建议在sphRequest.js顶部写注释固化该契约,团队内不再二次封装。
2. splice 截取把原数组改坏,第二次调用数据为空
- 现象:首页首次 15 条正常,切到 search 再切回首页,分类只剩 2 条甚至空白。
- 原因:
body.data.splice(0, 15)是原地删除,把返回数据里 0~15 项摘走后原数组只剩 2 条;配合"数据只请求一次"的缓存策略,后续再截取就是在 2 条里截 15 条。 - 修法:改用
body.data.slice(0, 15)(返回新数组,不动原数据);截取条数做成 action 的第二个参数传入,不要写死。
3. 分类菜单用 router-link 全量渲染,首页卡顿、内存飙高
- 现象:首页首屏出现明显白屏,DevTools 内存快照中组件实例数量远大于页面元素数量。
- 原因:三级分类 × 15 条一级,每个可点元素都实例化一个
router-link组件,组件解析与响应式开销随分类数线性放大。 - 修法:把
router-link换成普通a标签,点击事件用事件委托绑在共同父容器(.all-sort-list2)上,@click里统一this.$router.push('/search');后续再通过e.target/dataset取分类信息。
4. 用 a href="" 跳转,点击后整页刷新、路由丢失
- 现象:点分类后浏览器闪一下白屏,
$route状态被重置,Vuex 数据也没了。 - 原因:
href=""等价于当前地址,href="#"会改 hash,都属于a标签的默认跳转行为,绕过了 vue-router。 - 修法:删除
href,或写@click.prevent="$router.push('/search')";委托场景下必须在父容器的点击处理里e.preventDefault()。
5. 显示隐藏绑在错误的层级,鼠标移到二级菜单就收起
- 现象:鼠标从"全部商品分类"往右侧二级面板移动时,面板瞬间消失,点不到里面的项。
- 原因:
@mouseleave绑在h2上,一离开标题就触发隐藏,而二级面板在h2的兄弟节点里,不属于它的子级。 - 修法:
@mouseenter绑标题、@mouseleave绑两者的共同父级.nav-left,保证鼠标在标题与面板之间移动时不触发 leave。
6. isShow 初始值写在 data 里,路由切换后不更新
- 现象:从首页进 search,分类面板还开着;或刷新 search 页面后逻辑反了。
- 原因:
data(){ return { isShow: this.$route.path === '/' } }只在组件首次创建时求值一次,而 TypeNav 挂在 Header 里,路由切换时组件被复用、不会销毁重建,data永远停留在初始值。 - 修法:改为
computed依赖$route.path(或$route.name === 'home')自动重算;若必须用data,则加watch: { '$route.path': { handler(){...} } }手动同步。
7. Vuex 取不到值 / $store 为 undefined
- 现象:
mapState("product", ["categoryList"])报错或拿到undefined;this.$store是undefined。 - 原因:三种常见遗漏——没写
Vue.use(Vuex);new Vue({...})里忘了注入store;模块文件里没写namespaced: true却用了带命名空间的mapState/dispatch。 - 修法:按顺序自查
Vue.use(Vuex)→new Vue({ el, router, store, render })→ 模块导出对象里namespaced: true,且commit的 mutation 名与mutations中的大写常量完全一致。
8. 响应拦截器错误分支返回 new Promise(() => {}) 导致"假死"
- 现象:接口超时后进度条已结束,但页面无任何提示,后续
.then不执行也不报错,排查时以为是组件问题。 - 原因:返回一个永远 pending 的 Promise 会中断链式调用,既不 resolve 也不 reject,属于主动"吞掉"错误。
- 修法:确需中断时保留该写法但必须在拦截器内给出可见反馈(改用 UI 提示组件而非
alert(err),alert对 Error 对象只会输出[object Object]);业务侧若需要做降级,改为return Promise.reject(err)并在调用处catch。
练习
练习 1|把分类跳转改成带参的事件委托 在 TypeNav 中删除全部 router-link,在 .all-sort-list2 上用一个 @click 统一处理:根据被点元素所属层级(一级/二级/三级)取出对应的 categoryName 与 categoryId,this.$router.push({ path: '/search', query: { categoryName, category1Id, category2Id, category3Id } })。验收:点任意一层都能跳到 search,地址栏 query 正确,且首屏不再出现 router-link 组件实例。
练习 2|修掉 splice 隐患并加请求缓存 把 getBaseCategoryListAsync 里的截取改为 slice,并在 action 内加判断:state.categoryList.length 已有数据时直接 return,不再发请求。验收:首页 → search → 首页来回切换三次,分类始终完整、且 Network 面板中该接口只请求一次。
练习 3|用 computed 重写面板显隐 去掉 data.isShow 与 watch,改成基于 $route.name 的 computed;再补上"search 页面鼠标移入标题时也能展开"的需求。验收:首页默认展开且鼠标移出后不收起;search 页面默认收起,鼠标移入展开、移出 .nav-left 收起,点击二级/三级项能正常跳转不误触隐藏。
练习 4|统一错误处理改造 把 alert(err) 换成带 code 判断的统一处理:网络超时、HTTP 4xx/5xx、业务 code !== 200 三类分别给出不同文案(可先用 console.error + 简单的提示组件占位)。验收:把 baseURL 临时改成错误地址、把 timeout 改成 1ms,两种情况页面均有可读提示且不白屏。
练习 5|按模块拆分 api 与 store 新增 src/api/search.js,把搜索接口按 get/post 前缀命名导出;同时新建 src/store/search.js 模块并在 store/index.js 的 modules 注册。验收:TypeNav 中不再出现任何接口路径字符串,所有 URL 只存在于 src/api/* 下;mapState("search", [...]) 能正常取到状态。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | api封装 | 08:39 |
| 2 | 渲染首页商品分类 | 12:27 |
| 3 | 优化渲染内存 | 07:57 |
| 4 | search中的商品分类 | 11:58 |
| 5 | vuex保存数据并渲染 | 19:24 |
| 6 | 作业 | 02:02 |
小结
- 分层职责要守住:
sphRequest.js只管 axios 实例与拦截器,api/*.js只管 URL 与参数,组件只import函数名——组件里出现/product/getBaseCategoryList这种字符串就是分层被破坏的信号。 - 响应拦截器"剥一层"是整个项目的数据契约,所有调用方必须按同一层数取值;契约一旦确定就写进注释,不要在某个页面里特例化。
splice与slice一字之差,前者改原数组、后者返回新数组;凡是"取前 N 条展示"的场景一律用slice,避免数据源被悄悄截断。- 列表里成百上千次重复渲染同一个组件(如
router-link)是性能陷阱,事件委托是这类"多元素同行为"场景的标准解,代价是把取参逻辑从组件属性转移到dataset/DOM 上。 - 事件绑定层级要按"鼠标轨迹"设计:
mouseenter/mouseleave必须绑在包含标题与面板的共同父级,否则面板一展开就被自己的 leave 关掉。 - 路由切换时组件被复用而非重建,
data的初始值不会重算——所有依赖路由的状态优先用computed,需要副作用时才用watch。 - Vuex 的接入是固定四步:
Vue.use(Vuex)→ 模块namespaced: true→store/index.js注册modules→new Vue注入store;缺任一步的表现都是"取到undefined",按顺序排查最快。 - 错误处理要么给出可见反馈、要么正常 reject 让调用方接管,"静默 pending"只在明确要中断链式调用时使用,且必须配合 UI 提示。