Skip to content

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 会增加解析和内存开销。分类条目很多时,页面切换也可能出现短暂白屏。

怎么想:所有分类项都要跳搜索,可以把点击行为交给共同父级处理,即事件委托。父级监听点击,通过 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 结果直接覆盖原数据导致后续无法再截取。

本节要解决的是 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,组件实例非常多,转写稿中通过内存快照观察到占用上升,解析也变慢。
怎么想:把点击行为交给共同父级处理,子项改成普通 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 在“带顶部内容”和“仅底部组件”之间二选一。这里只保留思路,不额外编造实现。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

① 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", [...]) 能正常取到状态。

原视频对照 ​

#视频时长
1api封装08:39
2渲染首页商品分类12:27
3优化渲染内存07:57
4search中的商品分类11:58
5vuex保存数据并渲染19:24
6作业02:02

小结 ​

  1. 分层职责要守住:sphRequest.js 只管 axios 实例与拦截器,api/*.js 只管 URL 与参数,组件只 import 函数名——组件里出现 /product/getBaseCategoryList 这种字符串就是分层被破坏的信号。
  2. 响应拦截器"剥一层"是整个项目的数据契约,所有调用方必须按同一层数取值;契约一旦确定就写进注释,不要在某个页面里特例化。
  3. splice 与 slice 一字之差,前者改原数组、后者返回新数组;凡是"取前 N 条展示"的场景一律用 slice,避免数据源被悄悄截断。
  4. 列表里成百上千次重复渲染同一个组件(如 router-link)是性能陷阱,事件委托是这类"多元素同行为"场景的标准解,代价是把取参逻辑从组件属性转移到 dataset/DOM 上。
  5. 事件绑定层级要按"鼠标轨迹"设计:mouseenter/mouseleave 必须绑在包含标题与面板的共同父级,否则面板一展开就被自己的 leave 关掉。
  6. 路由切换时组件被复用而非重建,data 的初始值不会重算——所有依赖路由的状态优先用 computed,需要副作用时才用 watch。
  7. Vuex 的接入是固定四步:Vue.use(Vuex) → 模块 namespaced: true → store/index.js 注册 modules → new Vue 注入 store;缺任一步的表现都是"取到 undefined",按顺序排查最快。
  8. 错误处理要么给出可见反馈、要么正常 reject 让调用方接管,"静默 pending"只在明确要中断链式调用时使用,且必须配合 UI 提示。