一个后台系统的前端用路由守卫做权限:无权限就 redirect 到 403 页。上线后用户体验很差——页面先渲染一半、再跳走,而"菜单里仍然显示无权限的入口",于是"点了才知道不行"。
路由守卫解决的是"能不能进",而它不解决"该不该显示入口"与"进入时数据是否就绪"。这个"混"的代价是"结构错位":"能不能进"是路由层,"该不该显示"是布局层,"数据就绪"是页面层——把它们都塞进守卫,等于"用一层解决三层的问题"。
三者混在一起,就是"权限对了但体验差"。
1. 守卫的三种职责
| 职责 | 时机 |
|---|---|
| 认证(有没有登录) | 进入前 |
| 授权(有没有权限) | 进入前 |
| 数据就绪(能不能渲染) | 进入前/进入后 |
第三项如果放在"进入后",就会出现"渲染一半再跳"——因为组件已经挂载。第三项的"表现"很具体:页面边框与标题已经渲染,主体区域空白,然后突然跳走——用户看到的是"闪了一下",而它比"直接 403"更让人不安。
2. "菜单可见性"与"路由守卫"的关系
| 做法 | 体验 |
|---|---|
| 只做守卫(菜单全显示) | 点了才知道不行 |
| 菜单按权限过滤 | 一眼就知道能做什么 |
| 两者都做 | 最好(防"手输 URL") |
第二项的"实现"是"权限过滤在"菜单数据"上做,而不是在"渲染时判断""——于是"看不到的入口"连"渲染成本"都不产生。
这个"友好"的量化是"点击后的失败率":菜单全显示时,无权用户点进去的失败率可能 30%——而"过滤后"接近 0。
第二项是"体验"的关键:"看不到"比"点了被拒"友好——而它不能替代守卫,因为"URL 可以手输"。
3. "守卫里做什么"的三条原则
| 原则 | 说明 |
|---|---|
| 只做"快速判断"(本地状态) | 不要"等接口" |
| 慢判断放到"数据加载"里 | 用 loading 状态 |
| 拒绝时"给出口" | 403 页要有"返回/申请" |
第一项是"体验"的核心:**守卫里等一个接口 800ms,用户看到的是"白屏 800ms"**第一项的"替代"是"乐观进入 + 页面内降级":先放行、在页面里用 skeleton 等"细粒度权限",权限不足时"局部提示"——于是"不白屏"。
——而它比"快速跳转 + 目标页 loading"更差。
// 好:守卫只做本地判断(token/权限已在内存)
router.beforeEach((to) => {
if (to.meta.requiresAuth && !auth.hasToken()) return { name: "login" };
if (to.meta.perm && !auth.can(to.meta.perm)) return { name: "forbidden" };
return true;
});
// 慢判断(如"这个单据能不能看")放到目标页的 loader,用 skeleton 表达
"守卫里该不该等接口"可以用一个"三百毫秒原则"判:
// 三种判断的成本与做法
< 50ms(本地状态):守卫里直接判,同步返回
50–300ms(缓存命中):守卫里等,但要有"过渡态"(进度条/骨架)
> 300ms(要请求):移出守卫 → 目标页 loading
// 判据:用户能忍受的"白屏"上限约 300ms
4. "权限数据从哪来"的三种形态
| 形态 | 问题 |
|---|---|
| 登录时一次性返回 | 权限变了要重新登录 |
| 每次进入拉一次 | 多一次请求(守卫里等) |
| 内存 + 定期刷新 | 折中(推荐) |
第三项的"细节"是"刷新失败要保留旧权限":网络抖动时"清空权限"会让用户"突然什么都看不到"——而它看起来像"登录失效"。
第三项是"实践中最稳"的:登录后把权限放内存,另起一个"低频刷新"——于是"守卫不用等",而"权限变更"能在几分钟内生效。
"守卫的快慢"要能"看见",否则"白屏 800ms"只会被当成"网络慢":
// 三段计时(开发期打开,生产采样)
router.beforeEach((to) => {
const t0 = performance.now();
const r = decide(to); // 同步判断
const dt = performance.now() - t0;
if (dt > 50) console.warn(`guard slow: ${to.path} ${dt.toFixed(0)}ms`);
return r;
});
// 判据:> 50ms 的守卫会被用户感知为"点了没反应"
5. "守卫的三种失败"
| 失败 | 表现 |
|---|---|
| 死循环重定向 | A→B→A |
| 刷新后丢失状态 | 内存权限没了 |
| 403 页没有出口 | 用户只能改 URL |
第一项的成因很典型:**"未登录 → 跳 login",而 login 页也标了 requiresAuth**第一项的"防法"是"守卫第一行判断"目标是否在白名单"":白名单里的路由直接放行——于是"login/403/静态资源"不会被拦。
——于是无限跳。
"403 页要给出口"是体验底线,而它只需要三个按钮:
<!-- 403 的三个出口(缺一个用户就只能改 URL) -->
<a href="/">返回首页</a>
<a href="/apply">申请该权限</a>
<button onclick="history.back()">返回上一页</button>
<!-- 额外:显示"你缺少哪个权限",并对上"找谁申请" -->
6. 排错:五种"路由相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 渲染一半跳走 | 判断在组件内 | 移到守卫/loader |
| 刷新后掉登录态 | 状态只在内存 | 持久化 token |
| 菜单显示无权入口 | 未按权限过滤 | 过滤菜单 |
| 无限重定向 | 登录页也被拦 | 白名单 |
| 手输 URL 能进 | 只做前端过滤 | 后端也要校验 |
第五项是"安全"底线:前端守卫只是"体验",不是"安全"——真正的授权必须在后端。
"权限变了"要在"不重新登录"的情况下生效——而它靠"低频刷新 + 失败保留":
// 三种刷新策略
1 每次进入都拉 → 守卫里等(差)
2 定时拉(5 分钟)+ 内存 → 守卫同步(好,推荐)
3 只在登录时拉 → 权限变更要重登(差)
// 判据:权限变更能否"几分钟内生效且不打断用户"
7. 边界:什么情况不必复杂守卫
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单角色(全员同权限) | 无授权分支 | 只做登录判断 |
| 内部工具(网络隔离) | 风险低 | 简化 |
| 纯静态页(公开) | 无需守卫 | 不做 |
| 后端渲染(SSR 带权限) | 服务端已拦 | 前端可省 |
判断口径一句话:"未授权的人能不能"绕过前端"直接请求接口"。能(后端没校验),前端守卫只是"体验层",必须先补后端;不能(后端已拦),前端守卫就只该做"体验"。
最后一个"安全"提醒,值得单独说一遍:前端守卫可以被绕过——用户可以"改 URL、改内存状态、直接调接口"。所以"前端守卫"的正确定位是"减少无效请求",而"授权"必须"在后端每个接口上做"。把这两件事混淆的团队,通常会在"某个接口忘了加权限注解"时被穿透——而前端看起来"拦得很严"。
还有一个"壳层"细节:权限过滤要作用在"菜单数据"上,而不是"点击时判断"。前者让"不可见的入口"连 DOM 都不生成;后者会"生成再隐藏"——看起来一样,但"可搜索性/可访问性"上并不一样(屏幕阅读器可能读到它)。
附一条"命名"建议:把守卫分成 authGuard 与 permGuard 两个,而不是一个"大守卫"——于是"登录态变化"与"权限变化"能分别测试,也更容易定位"是哪一层拦下的"。
这条命名建议还有个副作用:它让"新增一种拦截"变成"加一个 guard",而不是"在一个函数里加 if"。
8. 自测
- 守卫的三种职责分别是什么时机?为什么"数据就绪"放在进入后会导致"渲染一半再跳"?
- 为什么"菜单过滤"不能替代"路由守卫"?两者的分工是什么?
- 为什么"守卫里等接口"体验更差?
- "权限放内存 + 低频刷新"解决了什么矛盾?
- "无限重定向"的典型成因是什么?
- 判断"前端守卫够不够"的那个问题是什么?