Skip to content

分类页(一级 + 二级)

本页关键词:动态路由 :idactive-class 高亮、面包屑导航、Banner 复用 distributionSite、路由缓存与 onBeforeRouteUpdate、Composable 函数拆分业务、二级分类嵌套路由、el-tabs 筛选、v-infinite-scroll 无限加载、scrollBehavior 路由滚动


一、一级分类

1. 动态路由配置

分类页的路由需要接收分类 ID 参数,使用动态路由 :id 实现。点击不同分类,URL 变为 /category/1001/category/1002 等,组件内通过 useRoute().params.id 获取当前分类 ID。

ts
{
  path: 'category/:id',
  component: Category
}

导航链接使用 active-class 属性,Vue Router 会自动给匹配当前路由的 <RouterLink> 添加该 CSS 类名,实现「当前分类高亮」效果,无需手动维护 active 状态:

vue
<RouterLink active-class="active" :to="`/category/${item.id}`">
  {{ item.name }}
</RouterLink>

2. 面包屑导航

面包屑展示当前所在位置(首页 > 居家),数据来自分类 API 的返回结果。通过 useRoute() 获取路由参数,请求对应分类详情数据:

js
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())
vue
<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 函数,通过参数切换:

js
// 首页调用:getBannerAPI()                        → distributionSite 默认 '1'
// 分类页调用:getBannerAPI({ distributionSite: '2' })

二、路由缓存问题与解决

问题描述

这是开发分类页时遇到的第一个「坑」:从「居家」分类切换到「美食」分类时,URL 从 /category/1001 变为 /category/1002,但页面数据没有刷新 — 还是显示居家的内容

原因:Vue Router 发现路由 path 相同(都是 /category/:id)、仅参数不同时,会复用组件实例,不会重新执行 setup 函数。所以 onMounted 不会再次触发,请求也不会重新发送。

两种解决方案

方案一:给 <RouterView> 添加 :key="$route.fullPath"

vue
<RouterView :key="$route.fullPath" />

原理:key 变化时 Vue 会销毁旧实例、创建新实例。简单粗暴,但每次切换都要销毁重建整个组件树,性能不好。

方案二(推荐):使用 onBeforeRouteUpdate 监听路由参数变化

js
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 — 把状态、副作用、生命周期逻辑打包成一个可复用的函数。

js
// 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 }
}
js
// 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 }
}

拆分后,组件变得极其简洁 — 一目了然地看出用了哪些业务逻辑:

vue
<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

ts
{
  path: 'category/sub/:id',
  component: SubCategory
}

面包屑展示三级导航(首页 > 一级分类 > 二级分类)。一级分类的 name 和 id 从二级分类接口的返回数据中获取(parentNameparentId),不需要额外请求:

vue
<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-tabsv-model 双向绑定排序字段。切换 Tab 时重置页码并重新请求,实现「最新/最热/评论最多」的排序切换:

js
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()
}
vue
<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),自动检测容器滚动到底部:

js
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    // 空数据 → 没有更多 → 禁用加载
  }
}
vue
<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,每次路由切换后自动滚动到顶部:

ts
const router = createRouter({
  // ...
  scrollBehavior () {
    return { top: 0 }
  }
})