Vue3 路由进阶
基础路由能跑通两个页面了,真实项目还需要给路由起名字、URL 带参数(/goods/123)、页面嵌子页面、登录才能进的页面等,这篇把路由的进阶能力一网打尽。
命名路由
跳转按路径写,路径一旦改动,所有跳转的地方都要跟着改,可以给路由记录起个名字,跳转按名字走,路径改了不用动别处:
{ path: '/about', name: 'about', component: About }
路由记录里的 name 字段就是名字,之后 router.push({ name: 'about' }) 就能按名字跳。
要带参数就再补一个 params,写成 router.push({ name: 'goods', params: { id } }),这点后面的动态路由会反复用到。
动态路由
商品详情都长一个样,只是 id 不同,为每个 id 写一条路由不现实,可以把 id 写成占位符,让一条路由匹配所有 id,URL 里不同的值存成参数,这就是动态路由:
{ path: '/goods/:id', component: GoodsDetail }
path 里以 : 开头的段就是占位符,能匹配一段任意值,/goods/123、/goods/abc 都会命中这一条,匹配到的那段值会存进参数对象。
读取参数
组件需要知道占位符的实际值。Vue Router 把匹配到的参数统一收进 route 对象的 params 属性,组件从它身上取,不用自己解析 URL:
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
// 访问 /goods/123 时 route.params 是 { id: '123' }
console.log(route.params.id)
</script>
route.params 是只读属性,注意值永远是字符串,URL 里的一切都是字符串,需要数值时用 Number(route.params.id) 转换。
传递参数
参数不光要读,跳转时也要带过去,router.push 带参数有两种写法:
const routes = [
{ path: '/goods/:id', name: 'goods', component: GoodsDetail }
]
// 按路径写,参数自己拼进字符串,简单直接
router.push({ path: `/goods/${id}` })
// 按名字写,参数放进 params 交给路由去填,推荐
router.push({ name: 'goods', params: { id } })
按路径写,id 直接拼进 path;按名字写,参数放进 params 由路由去填。
注意,按路径写时 params 会被忽略,{ path: '/goods/123', params: {...} } 里的 params 不生效,想用 params 就得走 name 写法,这是新手最容易踩的坑。
侦听参数变化
同一个动态路由组件下,参数变了组件会被复用(不会重建),取数据的代码只在挂载时跑过一次,参数变了它不会自己再跑。
需要一个”参数一变就重新执行”的机制,这正是 watch 的本职(Vue3 计算与侦听 讲过它),盯着 route.params.id,变了就重新请求:
<script setup>
import { useRoute } from 'vue-router'
import { watch } from 'vue'
const route = useRoute()
watch(() => route.params.id, (id) => {
// 参数一变,重新请求商品详情
fetchGoods(id)
})
</script>
嵌套路由
后台管理系统最典型,外层是”侧边栏 + 头部”框架,页面结构长这样:
┌─────────────────────────────────┐
│ 顶部导航栏 │
├────────────┼────────────────────┤
│ 侧边栏 │ │
│ 菜单列表 │ 中间内容区 │
│ │ <router-view> │
└────────────┴────────────────────┘
顶栏和侧边栏是布局,永远不动,中间那块是内容,点菜单只换这一块。
落到路由表上,/admin 这一条是布局,children 里挂的是内容。
访问 /admin/users 时,路由先匹配父记录 /admin,把 AdminLayout 整页画出来,再匹配子记录 users,把 AdminUsers 填进它中间的 <router-view>,外层套内层,这就是嵌套路由:
const routes = [
{
path: '/admin',
component: AdminLayout, // 布局组件,负责顶栏和侧边栏
children: [
{ path: '', component: Dashboard }, // 访问 /admin
{ path: 'users', component: AdminUsers }, // 访问 /admin/users
{ path: 'settings', component: AdminSettings }
]
}
]
AdminLayout 画外壳,在中间内容区放一个 <router-view>,子路由的组件就渲染进这里,不占全局出口:
<!-- AdminLayout.vue -->
<template>
<div class="admin">
<header>顶部导航栏</header>
<div class="body">
<aside>侧边栏菜单</aside>
<main>
<!-- 子路由的组件渲染到这里,只有这块会换 -->
<router-view />
</main>
</div>
</div>
</template>
于是三个地址共用同一个外壳,只有内容区在换:
| 地址 | 外壳(不动) | 内容区(换) |
|---|---|---|
| /admin | AdminLayout | Dashboard |
| /admin/users | AdminLayout | AdminUsers |
| /admin/settings | AdminLayout | AdminSettings |
点侧边栏”用户管理”跳到 /admin/users,顶栏和侧边栏全程不动,只有中间 <main> 区域替换成 AdminUsers。
想再套一层,就在子组件里再放一个 <router-view>,后台系统一层套一层就是这么来的。
children 是子路由数组,写法跟路由表完全一样,子路由的 path 有讲究,'users' 不带 /,是相对父路由拼接,父是 /admin,最终地址就是 /admin/users;'/users' 带 /,是绝对路径,直接从根开始,地址变成 /users,和 /admin 没关系了,所以约定是子路由常写相对路径,把父路由的 / 开头留给父记录管。
重定向与 404
用户输入的地址不会都对应到页面,一种是旧地址已经换到新页面,要把旧地址指过去;另一种是地址根本没定义,得有个兜底页面接住,前者用 redirect,后者用 /:pathMatch(.*)*。
重定向
网站改版后旧地址作废,访问旧地址的用户要能落到新页面,比如想让访问 / 的用户直接看到 /home,路由记录加一个 redirect 字段,值是目标地址:
const routes = [
{ path: '/', redirect: '/home' }, // 访问 / 直接跳到 /home
{ path: '/home', component: Home }
]
访问 / 时,路由发现这条记录带 redirect,就自动改道去 /home,地址栏也跟着变,redirect 的值可以写字符串 '/home',也可以写对象 { name: 'home' }。
404
另一种情况是地址真没定义,比如手输错了一个 URL,所有路由都匹配不上,页面会白屏,解决办法是在路由表最后放一条兜底路由,把没被匹配的路径全部接住,渲染 404 页面:
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home', component: Home },
{ path: '/:pathMatch(.*)*', component: NotFound } // 兜底 404
]
path 里的 :pathMatch 是参数名,负责捕获剩余未匹配的路径,(.*)* 表示匹配任意多字符,/abc、/a/b/c 这种没定义的地址都会落到这条,被捕获的原始路径存在 route.params.pathMatch 里,需要时可以取出来。
懒加载
假设网站有首页、商品、订单等 10 个页面,全部用 import 直接引用,打包后 10 个页面的代码全塞进同一个 JS 文件,用户打开首页,得先下载完这个整个大文件才能看到内容,哪怕他根本不会点订单页,这部分代码也白下载了,页面越多浪费越大。
懒加载的思路是组件和访问时机绑定,访问到哪个页面,才去加载哪个页面的代码,component 不直接传组件,改传一个箭头函数,函数里用 import() 动态导入:
// 同步加载:所有页面代码挤进一个文件,打开首页就要全下载
import GoodsDetail from '../views/GoodsDetail.vue'
const routes = [
{ path: '/goods/:id', component: GoodsDetail }
]
// 懒加载:GoodsDetail.vue 单独一个文件,访问 /goods/:id 才请求
const routes = [
{ path: '/goods/:id', component: () => import('../views/GoodsDetail.vue') }
]
差别就在 component 的两种写法,直接写组件是同步引用,打包时和主文件混在一起;写成 () => import(...),Vite 会把 GoodsDetail.vue 单独拆成一个文件,浏览器第一次访问 /goods/:id 时才去请求它,首屏只下载当前页面需要的文件,页面越多差距越明显。
导航守卫
用户没登录就去点「订单」,不该直接放他进 /order,而是改道去登录页,登录完再回来,这种”进门前的检查”就是导航守卫。
router.beforeEach 是全局入口,它挂的回调在每次导航确认前都会执行一次,拦不拦、改去哪都由回调的返回值说了算:
// src/router/index.js
router.beforeEach((to, from) => {
const token = localStorage.getItem('token')
// 要去的页面需要登录,但没登录 → 跳登录页并记录来源
if (to.meta.requiresAuth && !token) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true // 放行
})
回调参数是两个路由对象,to 是要去的地方,from 是现在的地方,返回值决定导航结果,只有三种情况:
- 返回
true或不返回,放行 - 返回一个路由对象,改道去那里(可以带
query) - 返回
false,取消这次导航
守卫怎么知道”这页要登录”?靠路由上的 meta 字段,值是普通对象:
{ path: '/order', component: Order, meta: { requiresAuth: true } }
to.meta 就是 { requiresAuth: true },to.meta.requiresAuth 是 true 就说明这页要登录。
除了 beforeEach,还有几种,触发时机不同,回调写法和返回值规则都一样,先混个脸熟,用到再查:
// afterEach:导航完成后再跑,没有返回值,适合改标题、埋点
router.afterEach((to) => {
document.title = to.meta.title || '默认标题'
})
// beforeEnter:写在单条路由记录上,只进这一条时才触发
{ path: '/admin', component: Admin, beforeEnter: (to, from) => { /* 只拦这一页 */ } }
还有 beforeResolve,作用几乎等同 beforeEach,只是执行时机更靠后(等组件内守卫跑完),平常很少用到。
路由视图上的 KeepAlive
切换路由时组件默认会重新创建,表现是列表页滚到一半,切去详情页再切回来,列表重建,回到顶部、重新请求数据,滚动位置和临时状态全没了。
让路由切换也保留状态,把 <router-view> 包进 <KeepAlive>:
<router-view>
<template #default="{ Component }">
<KeepAlive :include="['List', 'Detail']">
<component :is="Component" />
</KeepAlive>
</template>
</router-view>
这段写法有点绕,拆开看:
<router-view> 的默认插槽会提供一个 Component,就是当前匹配到的组件,用 <component :is="Component"> 把它渲染出来(复习动态组件),中间套一个 <KeepAlive>,这个组件切走时不销毁,被缓存下来,再切回来直接恢复原样,滚动位置、表单都还在,include 按组件名指定缓存哪些,没列到的不缓存。
速查卡片
| 场景 | 写法 |
|---|---|
| 命名路由 | { name: 'about' } + router.push({ name }) |
| 动态参数 | path: '/goods/:id' → route.params.id |
| 参数变化再取数据 | watch(() => route.params.id, cb) |
| 嵌套路由 | children: [{ path: 'users', component }] |
| 重定向 / 404 | redirect / /:pathMatch(.*)* |
| 懒加载 | component: () => import('./X.vue') |
| 登录拦截 | router.beforeEach((to) => to.meta.requiresAuth && !token ? 登录页 : true) |
| 保留页面状态 | <KeepAlive> 包 <router-view> |
路由全通了。接下来是共享数据的治理,认识 Pinia 并用 Store 解决多个组件共享同一份数据的问题。