分类页(一级 + 二级)
本页关键词:动态路由
:id、active-class高亮、面包屑导航、Banner 复用distributionSite、路由缓存与onBeforeRouteUpdate、Composable 函数拆分业务、二级分类嵌套路由、el-tabs筛选、v-infinite-scroll无限加载、scrollBehavior路由滚动
一、一级分类
1. 动态路由配置
分类页的路由需要接收分类 ID 参数,使用动态路由 :id 实现。点击不同分类,URL 变为 /category/1001、/category/1002 等,组件内通过 useRoute().params.id 获取当前分类 ID。
{
path: 'category/:id',
component: Category
}导航链接使用 active-class 属性,Vue Router 会自动给匹配当前路由的 <RouterLink> 添加该 CSS 类名,实现「当前分类高亮」效果,无需手动维护 active 状态:
<RouterLink active-class="active" :to="`/category/${item.id}`">
{{ item.name }}
</RouterLink>2. 面包屑导航
面包屑展示当前所在位置(首页 > 居家),数据来自分类 API 的返回结果。通过 useRoute() 获取路由参数,请求对应分类详情数据:
import { getCategoryAPI } from '@/apis/category'
import { useRoute } from 'vue-router'
const categoryData = ref({})
const route = useRoute()
const getCategory = async (id = route.params.id) => {
const res = await getCategoryAPI(id)
categoryData.value = res.result
}
onMounted(() => getCategory())<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item>
</el-breadcrumb>3. Banner 复用
首页和分类页共用同一个 Banner 接口,通过参数 distributionSite 区分投放位置。这样后端只需维护一个接口,前端也只需一个 API 函数,通过参数切换:
// 首页调用:getBannerAPI() → distributionSite 默认 '1'
// 分类页调用:getBannerAPI({ distributionSite: '2' })二、路由缓存问题与解决
问题描述
这是开发分类页时遇到的第一个「坑」:从「居家」分类切换到「美食」分类时,URL 从 /category/1001 变为 /category/1002,但页面数据没有刷新 — 还是显示居家的内容。
原因:Vue Router 发现路由 path 相同(都是 /category/:id)、仅参数不同时,会复用组件实例,不会重新执行 setup 函数。所以 onMounted 不会再次触发,请求也不会重新发送。
两种解决方案
方案一:给 <RouterView> 添加 :key="$route.fullPath"
<RouterView :key="$route.fullPath" />原理:key 变化时 Vue 会销毁旧实例、创建新实例。简单粗暴,但每次切换都要销毁重建整个组件树,性能不好。
方案二(推荐):使用 onBeforeRouteUpdate 监听路由参数变化
import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate((to) => {
// to 是即将进入的路由对象,从中获取最新的参数
getCategory(to.params.id)
})原理:在路由参数变化时精确更新数据,组件实例保持不变。注意这里把 getCategory 函数改为了接受 id 参数(有默认值 route.params.id),这样 onMounted 时用默认参数,onBeforeRouteUpdate 时传入新参数。
面试要点:方案一的 key 方案简单但性能差(整个组件树销毁+重建,触发所有子组件的生命周期),方案二只重发请求、保留组件状态和 DOM,性能更优。实际项目中推荐方案二。
onBeforeRouteUpdate是 Vue Router 提供的组合式 API 钩子,只在当前路由组件被复用时触发。
三、Composable 函数拆分业务
动机
经过前面的开发,分类页组件内已经堆积了两块独立业务:分类数据获取 + Banner 获取。它们之间没有依赖关系,但都混在同一个 <script setup> 中,随着功能增加会越来越臃肿。
拆分思路
将每块独立业务逻辑提取为 useXxx 函数(Composable),组件只负责「组合调用」。这等价于 React 的 Custom Hook — 把状态、副作用、生命周期逻辑打包成一个可复用的函数。
// composables/useCategory.js
import { onMounted, ref } from 'vue'
import { getCategoryAPI } from '@/apis/category'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
export function useCategory () {
const categoryData = ref({})
const route = useRoute()
const getCategory = async (id = route.params.id) => {
const res = await getCategoryAPI(id)
categoryData.value = res.result
}
onMounted(() => getCategory())
onBeforeRouteUpdate((to) => {
getCategory(to.params.id)
})
return { categoryData }
}// composables/useBanner.js
import { ref, onMounted } from 'vue'
import { getBannerAPI } from '@/apis/home'
export function useBanner () {
const bannerList = ref([])
const getBanner = async () => {
const res = await getBannerAPI({ distributionSite: '2' })
bannerList.value = res.result
}
onMounted(() => getBanner())
return { bannerList }
}拆分后,组件变得极其简洁 — 一目了然地看出用了哪些业务逻辑:
<script setup>
import { useBanner } from './composables/useBanner'
import { useCategory } from './composables/useCategory'
const { bannerList } = useBanner()
const { categoryData } = useCategory()
</script>面试要点:Composable 是 Composition API 的核心复用模式。优点:逻辑内聚(相关的 state + computed + watch + 生命周期在同一个函数内)、可复用(不同组件可调用同一个 composable)、可测试(纯函数,易于单元测试)。命名约定
useXxx,返回响应式数据供组件消费。
四、二级分类
1. 路由与面包屑
二级分类作为独立路由(不是一级分类的子路由),URL 形如 /category/sub/:id:
{
path: 'category/sub/:id',
component: SubCategory
}面包屑展示三级导航(首页 > 一级分类 > 二级分类)。一级分类的 name 和 id 从二级分类接口的返回数据中获取(parentName、parentId),不需要额外请求:
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/category/${categoryData.parentId}` }">
{{ categoryData.parentName }}
</el-breadcrumb-item>
<el-breadcrumb-item>{{ categoryData.name }}</el-breadcrumb-item>2. 列表筛选(Tab 切换)
通过 el-tabs 的 v-model 双向绑定排序字段。切换 Tab 时重置页码并重新请求,实现「最新/最热/评论最多」的排序切换:
const reqData = ref({
categoryId: route.params.id,
page: 1,
pageSize: 20,
sortField: 'publishTime'
})
const getGoodList = async () => {
const res = await getSubCategoryAPI(reqData.value)
goodList.value = res.result.items
}
const tabChange = () => {
reqData.value.page = 1 // 切换排序时重置到第一页
getGoodList()
}<el-tabs v-model="reqData.sortField" @tab-change="tabChange">
<el-tab-pane label="最新商品" name="publishTime" />
<el-tab-pane label="最高人气" name="orderNum" />
<el-tab-pane label="评论最多" name="evaluateNum" />
</el-tabs>3. 无限加载(InfiniteScroll)
思路:用户滚动到列表底部时自动加载下一页数据,新数据拼接到已有列表后面。当接口返回空数组时,说明没有更多数据,禁用继续加载。
使用 ElementPlus 的 v-infinite-scroll 指令(底层也是 IntersectionObserver),自动检测容器滚动到底部:
const disabled = ref(false)
const load = async () => {
reqData.value.page++
const res = await getSubCategoryAPI(reqData.value)
goodList.value = [...goodList.value, ...res.result.items] // 展开运算符拼接新旧数据
if (res.result.items.length === 0) {
disabled.value = true // 空数据 → 没有更多 → 禁用加载
}
}<div class="body" v-infinite-scroll="load" :infinite-scroll-disabled="disabled">
<GoodsItem v-for="goods in goodList" :goods="goods" :key="goods.id" />
</div>面试要点:无限加载三要素 —
page++分页递增、展开运算符[...old, ...new]拼接新旧数据(而非替换)、空数据时禁用加载防止无限请求。v-infinite-scroll底层也是 IntersectionObserver,比传统的 scroll 事件 +scrollHeight计算更高效。
五、路由滚动行为
问题:从列表页滚动到底部后点击某个商品进入详情页,返回时页面仍在底部位置。或者从一个分类切到另一个分类时,页面不在顶部。
解决:在 createRouter 时配置 scrollBehavior,每次路由切换后自动滚动到顶部:
const router = createRouter({
// ...
scrollBehavior () {
return { top: 0 }
}
})