Vue - Router与权限路由

Vue Router 与权限路由

这篇从”路由到底是个啥”讲起, 一路讲到真实后台项目的登录鉴权和动态权限。快速上手篇里跑通过最简路由的话, 这篇会顺很多。

1. 路由是什么

先回忆传统网站 (JSP/Thymeleaf 那个年代) 怎么换页面: 每个 URL 对应服务器上一个页面, 点链接, 浏览器丢掉当前页面、向服务器要新页面、整页刷新。URL 和页面天然一一对应。

SPA 把这套推翻了: 整个应用只有一个 index.html, “换页面”其实是 JS 把页面上的组件换掉, 浏览器根本没刷新。
爽是爽了, 但 URL 的老本事不能丢: 用户刷新后该停在原页面、收藏的链接发给别人该能打开、后退按钮该能用。
所以需要一个东西专门维护 “URL 和组件的对应关系”, 这就是前端路由, Vue 生态里就是 Vue Router。

用后端的话翻译: @RequestMapping 决定一个 URL 由哪个 Controller 方法处理, 前端路由决定一个 URL 由哪个组件渲染。一个分发请求, 一个分发界面。

2. 路由表: 对应关系写在哪

代码块TS · 25 行收起展开
// 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 来是灾难:

代码块TS · 1 行收起展开
router.push({ name: 'task-detail', params: { id: task.id } })

3. 状态放哪: params、query 还是 Store

一个列表页有页码、筛选、关键词、当前用户…这些状态各有各的家:

状态放哪例子
标识当前资源path params/tasks/42
刷新/分享要保留的筛选query?status=todo&page=2
只有当前组件关心组件本地 state弹窗开关、输入草稿
跨页面且不适合进 URLPinia Store当前用户、权限、未读数

我刚开始把页码筛选全放组件里, 一刷新全没了, 后来才懂: 用户”能分享、刷新后还在”的状态就该进 URL。写法:

代码块TS · 9 行收起展开
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 做登录鉴权:

代码块TS · 25 行收起展开
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 次跳转:

代码块TS · 11 行收起展开
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 兜底路由的注册顺序、注销时要清干净:

代码块TS · 9 行收起展开
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' 这样的字符串, 前端不能拿字符串直接当代码执行, 要走受控映射:

代码块TS · 3 行收起展开
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 号任务的数据上。我第一次碰到时查了半天。解法是盯着参数变化:

代码块TS · 5 行收起展开
watch(
  () => route.params.id,
  id => loadTask(Number(id)),
  { immediate: true }        // immediate 兼顾首次进入
)

另一个实用钩子, 拦住未保存就离开:

代码块TS · 4 行收起展开
onBeforeRouteLeave(() => {
  if (!dirty.value) return true
  return window.confirm('内容尚未保存, 确定离开吗?')
})

8. 懒加载与页面缓存

路由表里 component: () => import(...) 的写法让每个页面单独打成一个 JS 文件, 用户访问到才下载, 首屏体积立减。粒度按页面切就好, 别细到每个小组件。

想让切走的页面保留状态 (滚动位置、筛选), 用 KeepAlive 包住路由出口:

代码块VUE · 5 行收起展开
<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, 剩下的交给前端路由:

代码块NGINX · 3 行收起展开
location / {
  try_files $uri $uri/ /index.html;
}

改不了服务器配置就退而求其次用 createWebHashHistory(), URL 带 # (# 后面的部分浏览器不会发给服务器, 天然没这个问题), 就是难看点。

10. 动手验收

  • 完成登录守卫 + 角色守卫 + 401 统一退出 + 登录后原路返回一整条链。
  • 把列表页的分页筛选写进 query, 验证刷新、后退、复制链接三件事都正常。
  • 从详情 1 跳详情 2, 验证数据真的重新加载了。
  • History 模式部署一次, 直接刷新二级路径确认不 404。

延伸阅读