banner
NEWS LETTER

Vue3 + TS 实战复盘:打造企业级动态权限路由(避坑指南)

Scroll down

动态权限路由示意图

前言:从“假权限”到“真安全”

最近在开发后台管理系统时,我遇到了一个经典需求:不同角色的用户,登录后看到的侧边栏菜单必须不同,且不能通过 URL 偷跑访问。

起初我的想法很简单:把所有路由都写在 router/index.ts 里,然后在侧边栏组件里用 v-if 判断一下用户角色,隐藏掉不该看的菜单不就行了吗?
但朋友问了我一个问题:“如果用户知道了管理员页面的 URL(比如 /admin/settings),直接在浏览器地址栏输入并回车,会发生什么?”

答案是:他能直接进去! 😱

这时候我才意识到,真正的权限控制不能只靠“藏菜单”,必须做到 动态控制路由 ——如果我没有权限,这个路由在 Vue Router 里压根就不应该存在。

本文将分享我是如何从零实现这套 Vue3 + TypeScript + Pinia 的动态路由方案的,以及过程中踩过的“刷新白屏”和“无限循环”大坑。


一、 核心思路拆解

我们采用 “后端控制权限,前端维护映射表” 的方案。

  1. 静态路由:Login、404 等所有人都能访问的页面,初始化时直接挂载。
  2. 动态路由:将原本写死的业务路由(Approval, Admin 等)抽离出来,不要放入 createRouter
  3. 权限过滤:用户登录 -> 获取 Role -> 拿着 Role 去过滤动态路由表 -> 得到 accessRoutes
  4. 动态挂载:使用 router.addRoute() 将过滤后的路由添加到 Router 实例中。

二、 代码重构与实现

项目结构如下

1
2
3
4
5
6
7
8
9
10
11
src/
├── router/
│ ├── index.ts // 路由入口:挂载静态路由
│ ├── routes.ts // 路由配置表
│ └── guard.ts // 全局守卫:核心逻辑
├── store/
│ └── permission.ts // 权限仓库
├── types/
│ └── router.d.ts // TS 类型扩展
└── views/
└── ...

1. 扩展 TypeScript 类型

在使用 meta 字段存储 requireAdmin 等信息时,TS 可能会报错。我们需要扩展 Vue Router 的类型定义。

注意Vue RouterRouteMeta 设计为空接口,本质上是留给开发者自定义的“插槽”。但在 TypeScript 的严格模式下,访问未定义的接口属性是非法的。meta 字段默认是空接口(或者基础定义),所以需要加入我们所需的字段,防止报错,并且也能让编辑器自动提示出所有可用字段

文件:src/types/vue-router.d.ts

1
2
3
4
5
6
7
8
9
10
import "vue-router";

declare module "vue-router" {
interface RouteMeta {
title: string;
icon?: string;
requiresAuth?: boolean;
requireAdmin?: boolean; // 自定义字段:是否需要权限
}
}

2. 路由表拆分(关键)

痛点修正:一定要把业务路由从初始配置中拿走,否则动态添加就没有意义了。

文件:src/router/routes.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import type { RouteRecordRaw } from "vue-router";

// 1. 静态路由(白名单):任何人都能访问
export const constantRoutes: RouteRecordRaw[] = [
{
path: "/login",
name: "Login",
component: () => import("@/views/login/index.vue"),
meta: { title: "登录", requiresAuth: false },
},
// 注意:404 页面千万不要在这里加!后面解释为什么
];

// 2. 动态路由(映射表):等待权限过滤
export const asyncRoutes: RouteRecordRaw[] = [
{
path: "/",
name: "Layout",
component: () => import("@/layouts/default/index.vue"),
redirect: "/approval",
children: [
{
path: "/visitors",
name: "Visitors",
component: () => import("@/views/visitors/index.vue"),
meta: {
title: "访客管理",
requiresAuth: true,
requireAdmin: true, // 只有管理员能看
},
},
// ... 其他业务路由
],
},
];

3. 路由初始化

文件:src/router/index.ts

1
2
3
4
5
6
7
8
9
10
import { createRouter, createWebHistory } from "vue-router";
import { constantRoutes } from "./routes";

// 初始化时,只挂载静态路由
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes, // ❌ 这里千万别放 asyncRoutes
});

export default router;

三、 核心难点:全局守卫与“无限循环”陷阱

这是整个功能最容易出 Bug 的地方。我们需要在 beforeEach 中判断:如果用户已登录,但此时路由还没生成,就需要立刻去生成路由。

文件:src/router/guards.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import type { Router } from "vue-router";
import { useUserStore } from "@/store/modules/user";
import { usePermissionStore } from "@/store/modules/permission";

const WHITE_LIST = ["/login"];

export function setupRouterGuards(router: Router) {
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
const permissionStore = usePermissionStore();
const token = userStore.token;

if (token) {
// 1. 如果已登录,又想去登录页,踢回首页
if (to.path === "/login") {
next({ path: "/" });
return;
}

// 2. 核心判断:路由是否已生成?
if (!permissionStore.isRoutesGenerated) {
try {
// A. 获取用户信息(角色)
// B. 根据角色过滤路由
const accessRoutes = await permissionStore.generateRoutes(
userStore.roles,
);

// C. 动态添加路由 (重点!)
// 这里的逻辑通常写在 Store Action 里,本质是调用 router.addRoute
accessRoutes.forEach((route) => {
router.addRoute(route);
});

// D. 添加 404 兜底路由 (坑点预警)
router.addRoute({
path: "/:pathMatch(.*)*",
redirect: "/404",
});

// E. 关键重定向!
// ❌ 如果直接 next(),因为路由刚加上去,Router 还没来得及更新匹配,会通过不了
// ✅ 必须使用 { ...to, replace: true } 重新触发一次导航
next({ ...to, replace: true });
} catch (err) {
// 出错就重置 Token,退回登录页
await userStore.logout();
next(`/login?redirect=${to.path}`);
}
} else {
// 路由已存在,直接放行
next();
}
} else {
// 未登录处理
if (WHITE_LIST.includes(to.path)) {
next();
} else {
next(`/login?redirect=${to.path}`);
}
}
});
}

四、 踩坑笔记

坑一:刷新页面后 404

例子:我明明在动态路由里配了 /visitors,正常点击能进去,但只要在那个页面按 F5 刷新,直接跳到了 404 页面。
原因
Vue Router 初始化的顺序是:

  1. 浏览器刷新,JS 重新执行。
  2. createRouter 创建实例,此时只有 constantRoutes(静态路由)。
  3. 如果我把 /:pathMatch(.*)* 放在了静态路由里,URL /visitors 进来时,发现静态路由里没这个路径,直接匹配到了通配符,跳转 404。
  4. 此时 beforeEach 里的动态添加逻辑甚至还没执行完。

解决方案
永远不要在 constantRoutes 里写通配符! 必须在动态路由加载完毕后(即 forEach 循环 addRoute 之后),再手动 router.addRoute 把 404 规则加进去。

坑二:死循环

RangeError: Maximum call stack size exceeded

例子:浏览器卡死,控制台报错栈溢出。
原因
我在 beforeEach 里写了 next({ ...to, replace: true })
这个操作会再次触发 beforeEach。如果此时 permissionStore.isRoutesGenerated 的状态没有正确更新为 true,或者判断条件写错了,就会无限重复“拦截 -> 添加 -> 重定向 -> 拦截”的过程。

解决方案
确保 Pinia 中的 isRoutesGenerated 状态变更是在 next({ ...to }) 之前完成的。


五、 总结

通过这次重构,大家应该能感受到 Vue Router “addRoute” 的威力。真正的权限管理不仅仅是前端 UI 的隐藏,更是对路由逻辑的严密控制。

当然还有一点,虽然前端做了拦截,但别忘了:前端是防君子不防小人的。最核心的数据接口权限,依然需要后端同学在 API 层做好验证。

如果你也在做类似的后台管理系统,希望这篇文章能帮你少踩几个坑!

其他文章
目录导航 置顶
  1. 1. 前言:从“假权限”到“真安全”
  2. 2. 一、 核心思路拆解
  3. 3. 二、 代码重构与实现
    1. 3.1. 1. 扩展 TypeScript 类型
    2. 3.2. 2. 路由表拆分(关键)
    3. 3.3. 3. 路由初始化
  4. 4. 三、 核心难点:全局守卫与“无限循环”陷阱
  5. 5. 四、 踩坑笔记
    1. 5.1. 坑一:刷新页面后 404
    2. 5.2. 坑二:死循环
  6. 6. 五、 总结