Vue - Router与权限路由
Vue Router 与权限路由
这篇从”路由到底是个啥”讲起, 一路讲到真实后台项目的登录鉴权和动态权限。快速上手篇里跑通过最简路由的话, 这篇会顺很多。
1. 路由是什么
先回忆传统网站 (JSP/Thymeleaf 那个年代) 怎么换页面: 每个 URL 对应服务器上一个页面, 点链接, 浏览器丢掉当前页面、向服务器要新页面、整页刷新。URL 和页面天然一一对应。
SPA 把这套推翻了: 整个应用只有一个 index.html, “换页面”其实是 JS 把页面上的组件换掉, 浏览器根本没刷新。
爽是爽了, 但 URL 的老本事不能丢: 用户刷新后该停在原页面、收藏的链接发给别人该能打开、后退按钮该能用。
所以需要一个东西专门维护 “URL 和组件的对应关系”, 这就是前端路由, Vue 生态里就是 Vue Router。
用后端的话翻译: @RequestMapping 决定一个 URL 由哪个 Controller 方法处理, 前端路由决定一个 URL 由哪个组件渲染。一个分发请求, 一个分发界面。
2. 路由表: 对应关系写在哪
代码块收起展开
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/tasks' },
{
path: '/tasks',
name: 'task-list', // 命名路由, 跳转时用名字不拼字符串
component: () => import('@/views/task/TaskList.vue'), // 动态 import = 懒加载, 见第 8 节
meta: { requiresAuth: true, title: '任务' } // meta: 挂在路由上的自定义数据, 守卫会用
},
{
path: '/tasks/:id(\\d+)', // :id 动态参数, (\\d+) 限定必须是数字
name: 'task-detail',
component: () => import('@/views/task/TaskDetail.vue')
},
{ path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFound.vue') } // 兜底 404
],
scrollBehavior(to, from, savedPosition) {
return savedPosition ?? { top: 0 } // 后退时恢复滚动位置, 前进时回顶
}
})跳转优先用名字, 路径字符串散落一地的项目改起 URL 来是灾难:
代码块收起展开
router.push({ name: 'task-detail', params: { id: task.id } })3. 状态放哪: params、query 还是 Store
一个列表页有页码、筛选、关键词、当前用户…这些状态各有各的家:
| 状态 | 放哪 | 例子 |
|---|---|---|
| 标识当前资源 | path params | /tasks/42 |
| 刷新/分享要保留的筛选 | query | ?status=todo&page=2 |
| 只有当前组件关心 | 组件本地 state | 弹窗开关、输入草稿 |
| 跨页面且不适合进 URL | Pinia Store | 当前用户、权限、未读数 |
我刚开始把页码筛选全放组件里, 一刷新全没了, 后来才懂: 用户”能分享、刷新后还在”的状态就该进 URL。写法:
代码块收起展开
const route = useRoute() // 读当前路由信息 (参数、路径)
const router = useRouter() // 操作跳转
const page = computed(() => Math.max(1, Number(route.query.page) || 1)) // query 全是字符串, 记得转型
function updateQuery(patch: Record<string, string | number | undefined>) {
// 筛选变了就改 URL, 用 replace 避免每敲一个筛选都产生一条历史记录
router.replace({ query: { ...route.query, ...patch, page: patch.page ?? 1 } })
}4. 路由守卫: 前端的拦截器
守卫是跳转前后的钩子, 和 Java Web 的 Filter/Interceptor 是同一个思想: 请求 (这里是跳转) 到达目标前先过一道检查, 不合格就改道。最常用的 beforeEach 做登录鉴权:
代码块收起展开
router.beforeEach(async to => {
const auth = useAuthStore()
// 有 token 但用户信息还没加载 (比如刚刷新), 先补加载
if (auth.token && !auth.userLoaded) {
try {
await auth.loadCurrentUser()
} catch {
auth.clearSession() // 加载失败按未登录处理
}
}
// 目标页面需要登录但没登录: 踢去登录页, 并把想去的地址带上
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
// 角色不够: 踢去 403
if (to.meta.roles?.length && !auth.hasAnyRole(to.meta.roles)) {
return { name: 'forbidden' }
}
// 已登录还想去登录页: 没必要, 送回首页
if (to.name === 'login' && auth.isLoggedIn) return { name: 'task-list' }
})返回一个路由对象就是”改道”, 什么都不返回就是放行 (老教程里的 next() 风格别再用, 容易重复调用)。
几条用血换来的边界:
- 前端路由权限只是体验优化, 藏得住菜单藏不住接口, 后端每个接口必须自己再鉴权。这和”前端表单校验不能代替后端校验”是同一条安全常识。
- 登录页和公共页要放行, 不然”没登录→踢去登录页→登录页也要登录→再踢”无限重定向。
- 别只判断”本地有没有 token”, token 过期、被撤销、用户加载失败都要当未登录处理。
5. Token 失效的统一处理
token 过期时, 页面上可能同时有 10 个请求一起收到 401, 不加锁就是 10 次弹窗 + 10 次跳转:
代码块收起展开
let redirecting = false // 简单的模块级布尔锁
async function handleUnauthorized() {
if (redirecting) return // 已经有人在处理了, 后来的直接散了
redirecting = true
const auth = useAuthStore()
auth.clearSession()
const current = router.currentRoute.value
await router.replace({ name: 'login', query: { redirect: current.fullPath } })
redirecting = false
}登录成功后跳回 redirect 之前要校验它是站内路径, 不然攻击者构造 ?redirect=https://钓鱼站 就成了开放重定向漏洞。
6. 动态权限路由
后台系统常见需求: 不同角色看到不同菜单。两种做法:
方案一, 路由表写死, 只按权限隐藏菜单和按钮。简单可靠, 大多数系统够用 (反正守卫和后端都会拦真正的越权访问)。
方案二, 登录后按角色 router.addRoute() 动态挂路由。大型后台才需要, 复杂度在细节: 刷新后要重建、404 兜底路由的注册顺序、注销时要清干净:
代码块收起展开
const removeRoutes: Array<() => void> = []
function installRoutes(routes: RouteRecordRaw[]) {
for (const route of routes) removeRoutes.push(router.addRoute('root', route)) // addRoute 返回卸载函数
}
function resetRoutes() {
removeRoutes.splice(0).forEach(remove => remove()) // 注销时全部拆掉
}后端只会返回 component: 'TaskList' 这样的字符串, 前端不能拿字符串直接当代码执行, 要走受控映射:
代码块收起展开
const views = import.meta.glob('../views/**/*.vue') // Vite 提供: 一次性收集所有页面组件
const loader = views[`../views/${serverRoute.component}.vue`]
if (!loader) throw new Error('Unknown route component')7. 同一个组件, 路由变了没反应?
从 /tasks/1 跳到 /tasks/2, Vue Router 会复用同一个 TaskDetail 实例 (省性能), 于是 onMounted 不会再跑一次, 页面停在 1 号任务的数据上。我第一次碰到时查了半天。解法是盯着参数变化:
代码块收起展开
watch(
() => route.params.id,
id => loadTask(Number(id)),
{ immediate: true } // immediate 兼顾首次进入
)另一个实用钩子, 拦住未保存就离开:
代码块收起展开
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return window.confirm('内容尚未保存, 确定离开吗?')
})8. 懒加载与页面缓存
路由表里 component: () => import(...) 的写法让每个页面单独打成一个 JS 文件, 用户访问到才下载, 首屏体积立减。粒度按页面切就好, 别细到每个小组件。
想让切走的页面保留状态 (滚动位置、筛选), 用 KeepAlive 包住路由出口:
代码块收起展开
<RouterView v-slot="{ Component, route }">
<KeepAlive :include="cachedNames">
<component :is="Component" :key="route.name" />
</KeepAlive>
</RouterView>缓存必须配失效策略: 编辑保存成功后, 列表缓存是更新还是丢弃, 得按业务定, 不然用户看到旧数据以为没保存上。
9. History 模式的部署坑
createWebHistory() 的 URL 干净 (没有 #), 但有个必踩的部署坑: 用户直接刷新 /tasks/42, 浏览器会老老实实向服务器要这个路径, 服务器上根本没这个文件, 返回 404。解法是让服务器把所有未知路径都回退到 index.html, 剩下的交给前端路由:
代码块收起展开
location / {
try_files $uri $uri/ /index.html;
}改不了服务器配置就退而求其次用 createWebHashHistory(), URL 带 # (# 后面的部分浏览器不会发给服务器, 天然没这个问题), 就是难看点。
10. 动手验收
- 完成登录守卫 + 角色守卫 + 401 统一退出 + 登录后原路返回一整条链。
- 把列表页的分页筛选写进 query, 验证刷新、后退、复制链接三件事都正常。
- 从详情 1 跳详情 2, 验证数据真的重新加载了。
- History 模式部署一次, 直接刷新二级路径确认不 404。