路由与布局骨架篇:Vue Router 实战 | 动态路由、嵌套路由与多级菜单

141 阅读7分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

前言

很多人在做 Vue 项目时,会用 Vue Router 配置路由,但到了动态路由、嵌套路由、多级菜单这些场景,就容易遇到:

  • 菜单和路由脱节,手动维护两套配置
  • 嵌套层级一多就不知道该写 children 还是 path
  • 面包屑要手动维护,改路由结构就要改面包屑

本文会围绕这几个点,讲清楚怎么配置、为什么这样配置、常见坑在哪,目标是写出来就能在实际项目里用得上。

一、先搞清楚:路由和菜单是什么关系?

可以简单理解为:

  • 路由:决定访问某个 URL 时展示哪个页面,是“入口”
  • 菜单:页面上展示的导航结构,是“入口的入口”

路由和菜单常常是同一份配置的不同展示形式:
一份配置描述“有哪些页面、层级关系”,路由用它来做匹配,菜单用它来渲染。

接下来就从动态路由讲起,然后引出嵌套路由和菜单。

二、动态路由:用占位符匹配不同页面

2.1 什么是动态路由?

比如:/user/1/user/2 都进同一个组件,只是用户 ID 不同。
这种“路径里有一段是变量”的路由,就是动态路由。

2.2 基本写法

// router/index.js
{
  path: '/user/:id',  // :id 是动态参数
  name: 'UserDetail',
  component: () => import('@/views/UserDetail.vue')
}

在组件里获取参数:

// UserDetail.vue
export default {
  mounted() {
    // 方式1:this.$route.params.id
    console.log(this.$route.params.id);

    // 方式2:组合式 API
    // const route = useRoute();
    // console.log(route.params.id);
  }
}

2.3 一个常见坑:组件不复用导致不刷新

路由从 /user/1 切到 /user/2 时,如果还是同一个路由组件,Vue 会复用这个组件,mounted 不会再触发,可能拿到的还是旧的 id

可以用 watch 监听 $route

export default {
  watch: {
    '$route'(to) {
      // 路由变化时重新拉数据
      this.loadUser(to.params.id);
    }
  }
}

或者用 key 强制重新渲染(不推荐滥用,会有性能开销):

<router-view :key="$route.fullPath" />

小结:动态路由用 :参数名,组件里从 $route.params 取;路由在组件间切换时要特别注意“复用”导致的生命周期不触发问题。

三、嵌套路由:父级是布局,子级才是内容

3.1 为什么需要嵌套路由?

典型布局:顶部导航 + 侧边栏 + 内容区。

  • 顶部导航、侧边栏:切换菜单时不变
  • 内容区:随菜单切换而变

这种结构用嵌套路由建模:父路由负责布局,子路由负责内容。

3.2 配置方式

// router/index.js
{
  path: '/system',
  component: Layout,  // 布局组件:包含侧边栏 + <router-view/>
  children: [
    {
      path: 'user',   // 最终 path 为 /system/user
      name: 'SystemUser',
      component: () => import('@/views/system/User.vue')
    },
    {
      path: 'role',
      name: 'SystemRole',
      component: () => import('@/views/system/Role.vue')
    }
  ]
}

布局组件里放一个 <router-view>,子路由对应的组件就会渲染在这里:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <aside>侧边栏</aside>
    <main>
      <router-view />  <!-- 子路由组件渲染在这里 -->
    </main>
  </div>
</template>

3.3 两个容易踩的坑

坑 1:path 要不要加 /

  • path: 'user':相对于父路由,最终是 /system/user
  • path: '/user':绝对路径,最终是 /user,和父路由无关

嵌套路由里,一般子路由用相对路径(不加前导 /)。

坑 2:子路由的 path: '' 作为默认子路由

想让 /system 一进去就显示某个子页面,可以这样写:

{
  path: '/system',
  component: Layout,
  children: [
    {
      path: '',      // 空字符串 = 默认子路由
      name: 'SystemHome',
      component: () => import('@/views/system/Home.vue')
    },
    {
      path: 'user',
      name: 'SystemUser',
      component: () => import('@/views/system/User.vue')
    }
  ]
}

这样访问 /system 会渲染 SystemHome/system/user 会渲染 SystemUser

四、多级菜单 + 路由:一份配置,自动生成

多级菜单的本质是多层嵌套路由,可以在一份路由配置里同时描述层级和菜单信息,再由代码自动生成菜单。

4.1 路由配置加 meta:用于菜单和权限

// router/index.js
export default [
  {
    path: '/system',
    component: Layout,
    redirect: '/system/user',  // 默认重定向到第一个子路由
    meta: {
      title: '系统管理',
      icon: 'Setting'
    },
    children: [
      {
        path: 'user',
        name: 'SystemUser',
        component: () => import('@/views/system/User.vue'),
        meta: { title: '用户管理', icon: 'User' }
      },
      {
        path: 'role',
        name: 'SystemRole',
        component: () => import('@/views/system/Role.vue'),
        meta: { title: '角色管理', icon: 'Role' }
      },
      {
        path: 'menu',
        name: 'SystemMenu',
        component: Layout,  // 二级菜单也有自己的布局
        meta: { title: '菜单管理', icon: 'Menu' },
        children: [
          {
            path: 'list',
            name: 'MenuList',
            component: () => import('@/views/system/menu/List.vue'),
            meta: { title: '菜单列表' }
          }
        ]
      }
    ]
  }
]

4.2 从路由自动生成菜单

思路:遍历路由,过滤掉不展示的(如 meta.hidden),递归处理 children

<!-- components/Sidebar.vue -->
<template>
  <aside class="sidebar">
    <template v-for="route in menuRoutes" :key="route.path">
      <!-- 有子菜单:展示为可展开项 -->
      <el-sub-menu v-if="route.children?.length" :index="route.path">
        <template #title>
          <span>{{ route.meta?.title || route.name }}</span>
        </template>
        <sidebar-item :routes="route.children" />
      </el-sub-menu>
      <!-- 无子菜单:展示为可点击项 -->
      <el-menu-item v-else :index="resolvePath(route)" @click="go(route)">
        {{ route.meta?.title || route.name }}
      </el-menu-item>
    </template>
  </aside>
</template>

<script>
import { useRouter, useRoute } from 'vue-router'

export default {
  name: 'Sidebar',
  props: {
    routes: {
      type: Array,
      default: () => []
    }
  },
  setup() {
    const router = useRouter()
    const route = useRoute()

    // 过滤出需要展示的菜单项(可加权限等条件)
    const menuRoutes = computed(() => {
      return filterMenus(props.routes)
    })

    function filterMenus(routes) {
      return routes.filter(r => !r.meta?.hidden).map(r => {
        if (r.children?.length) {
          return { ...r, children: filterMenus(r.children) }
        }
        return r
      })
    }

    function resolvePath(item) {
      // 若 path 以 / 开头则是绝对路径,否则需要拼接父级
      if (item.path.startsWith('/')) return item.path
      // 简化:实际应递归拼接父 path,这里假设已在正确层级
      return '/' + item.path
    }

    function go(item) {
      router.push(resolvePath(item))
    }

    return { menuRoutes, resolvePath, go }
  }
}
</script>

使用时传入 router.options.routes 或过滤后的路由树即可。
resolvePath 实际项目中要按父子层级正确拼接,避免路径错误。

五、多级面包屑:从当前路由回溯出层级

面包屑要展示“当前页面的完整层级”,例如:系统管理 > 菜单管理 > 菜单列表。

5.1 思路

  • 当前路由在 $route.matched 里,matched 是从根到当前路由的数组
  • matched 做过滤、映射,就能得到面包屑数组

5.2 示例实现

<!-- components/Breadcrumb.vue -->
<template>
  <el-breadcrumb separator="/">
    <el-breadcrumb-item
      v-for="(item, index) in breadcrumbList"
      :key="item.path"
    >
      <router-link v-if="index < breadcrumbList.length - 1" :to="item.path">
        {{ item.meta?.title || item.name }}
      </router-link>
      <span v-else>{{ item.meta?.title || item.name }}</span>
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script>
import { useRoute } from 'vue-router'
import { computed } from 'vue'

export default {
  setup() {
    const route = useRoute()

    const breadcrumbList = computed(() => {
      const matched = route.matched.filter(item => item.meta?.title)
      return matched
    })

    return { breadcrumbList }
  }
}
</script>

route.matched 已经按层级排好序,只过滤掉没有 title 的项即可,无需再手动维护面包屑配置。

5.3 可选:首项固定为「首页」

const breadcrumbList = computed(() => {
  const matched = route.matched.filter(item => item.meta?.title)
  return [{ path: '/', meta: { title: '首页' } }, ...matched]
})

六、一个完整的简化示例

把路由、布局、菜单、面包屑串起来,形成可复制的结构。

6.1 路由配置

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { title: '首页', icon: 'Home' }
      },
      {
        path: 'system',
        name: 'System',
        component: () => import('@/layout/index.vue'),  // 可复用布局
        redirect: '/system/user',
        meta: { title: '系统管理', icon: 'Setting' },
        children: [
          {
            path: 'user',
            name: 'SystemUser',
            component: () => import('@/views/system/User.vue'),
            meta: { title: '用户管理' }
          },
          {
            path: 'role',
            name: 'SystemRole',
            component: () => import('@/views/system/Role.vue'),
            meta: { title: '角色管理' }
          }
        ]
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

6.2 布局组件(含侧边栏 + 面包屑 + 内容区)

<!-- layout/index.vue -->
<template>
  <div class="layout">
    <aside>
      <Breadcrumb />
      <Sidebar :routes="menuRoutes" />
    </aside>
    <main>
      <router-view />
    </main>
  </div>
</template>

<script>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import Breadcrumb from './Breadcrumb.vue'
import Sidebar from './Sidebar.vue'

export default {
  components: { Breadcrumb, Sidebar },
  setup() {
    const router = useRouter()
    const menuRoutes = computed(() => router.options.routes[0].children || [])
    return { menuRoutes }
  }
}
</script>

这里只是示意,实际中可能需要根据当前激活的路由层级,只展示对应层级的路由给侧边栏。

七、常见问题速查

问题可能原因建议
切换动态路由参数,页面不刷新组件被复用,生命周期未重新执行watch 监听 $route 或合理使用 key
子路由 404父路由未写 component 或没有 <router-view>检查布局组件是否有 <router-view>
菜单和路由不一致菜单单独维护,未从路由生成用路由 + meta 自动生成菜单
面包屑层级错乱手动维护或未用 route.matchedroute.matched 自动生成

八、小结

  1. 动态路由:用 :id 占位,在 $route.params 中取参,注意组件复用导致的不刷新问题。
  2. 嵌套路由:父路由负责布局 + <router-view>,子路由用相对 path
  3. 多级菜单:在路由 meta 中写 titleicon 等,用递归组件从路由树生成菜单。
  4. 多级面包屑:用 route.matched 自动生成,无需单独维护。

按这个思路,可以在项目中做到:一份路由配置 → 自动生成菜单 + 面包屑,维护成本低,也更容易保持一致。

🔍 本系列专栏导航

一、《路由与布局骨架篇:Vue Router 实战 | 动态路由、嵌套路由与多级菜单》

二、《路由与布局骨架篇:登录态与路由守卫 | token 校验、白名单、重定向》

三、《路由与布局骨架篇:多标签页(Tab)与缓存 | keep-alive、includeexclude、路由 meta》

四、《路由与布局骨架篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~