认证请求与路由权限链路重构
这次整理的对象主要来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin 里的最小后台壳子,重点参考下面这些文件:
11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin/src/view/login/index.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin/src/utils/fetch.js11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin/src/router/index.js11Vue学习/vue2相关文档学习笔记/vue-router/1使用.md11Vue学习/vue2相关文档学习笔记/vue-router/3VueRouter对象.md
和前面的案例一样,我们不直接改这个独立子项目,而是把其中真正值得长期复用的链路抽出来,沉淀成一篇认证、请求与路由权限场景的重构文档。
这类项目最容易被低估的地方,不是“登录页怎么写”,而是下面这条完整链路会不会越来越散:
- 登录表单收集凭据
- 会话层持久化 token 和用户信息
- 请求层注入认证头
- 响应层处理失效与异常
- 路由层决定页面是否可进入
- 页面层根据权限装配能力和入口
只要这条链路没有统一结构,后面就很容易出现几个典型问题:
- 登录页自己管提交、跳转、弹窗、错误提示和会话持久化
- 请求拦截器直接依赖 UI 组件、store 细节和路由实例
- 路由守卫只剩布尔判断,没有显式权限协议
- token 失效、重新登录、返回原页面这些流程彼此打架
这类场景真正的复杂度中心
认证场景的复杂度中心,通常不是单个表单,而是“认证状态如何穿过请求层和路由层”。
拿当前示例来说,文件虽然不多,但已经隐含了完整问题链:
login/index.vue同时承担表单状态、校验、提交、加载态和跳转fetch.js负责 token 注入和错误提示,但直接依赖store与Messagerouter/index.js目前还很薄,意味着真正的权限边界还没有收口成一层稳定协议
一旦页面数量变多,这种结构很容易继续膨胀成:
- 每个页面都各自判断登录态
- 每个请求各自兜底 401
- 每种角色各自写一套跳转逻辑
- 登录成功后的回跳路径四散在组件里
所以这类重构最关键的,不是先补更多守卫,而是先把认证链路拆成几层职责明确的能力。
推荐的重构边界
更适合长期维护的结构,通常会把认证链路拆成下面几层:
- 会话状态层:管理 token、用户信息、角色和会话恢复
- 请求适配层:统一注入认证头、刷新逻辑、错误归一化
- 路由权限层:统一处理
requiresAuth、角色准入和回跳意图 - 页面装配层:只负责表单输入、按钮交互和页面级反馈
- 展示组件层:只负责字段展示和输入输出
如果换成更具体的能力,大概可以这样分:
useAuthSession():负责登录、退出、恢复会话、读取角色createApiClient():负责请求拦截、错误映射、认证头注入createAuthGuard():负责路由 meta 解释、未登录跳转、登录后回跳LoginPage:只负责表单与页面反馈LoginFormCard:只负责 props / emits
这里最重要的一条原则是:
- 登录页不直接拥有认证系统
- 请求层不直接拥有 UI 提示系统
- 路由守卫不直接拥有业务页面逻辑
登录页不该直接拥有认证流程
当前示例里的登录页已经暴露出一个很常见的问题:表单校验、提交状态、登录调用、成功跳转、第三方弹窗都挤在一个组件里。
这在 demo 里能跑,但在真实项目里会迅速失控。因为登录页天生只是“认证入口页面”,它不应该同时承担下面这些职责:
- 保存会话
- 决定首页或回跳地址
- 统一处理认证失败
- 决定后续页面的权限可见性
更稳的做法,是让登录页只消费 useAuthSession() 暴露出来的最小接口。
import { computed, ref } from 'vue'
export interface LoginPayload {
username: string
password: string
}
export interface SessionUser {
id: string
name: string
roles: string[]
}
export function useAuthSession() {
const token = ref<string | null>(null)
const user = ref<SessionUser | null>(null)
const pending = ref(false)
const isAuthenticated = computed(() => Boolean(token.value))
async function login(payload: LoginPayload) {
pending.value = true
try {
const result = await authApi.login(payload)
token.value = result.token
user.value = result.user
persistSession(result)
} finally {
pending.value = false
}
}
function logout() {
token.value = null
user.value = null
clearSession()
}
function restore() {
const saved = readPersistedSession()
token.value = saved?.token ?? null
user.value = saved?.user ?? null
}
return {
token,
user,
pending,
isAuthenticated,
login,
logout,
restore
}
}这样登录页只需要处理:
- 用户输入了什么
- 当前是否可提交
- 点击提交后调用哪一个 action
- 成功后交给路由层决定去哪里
页面边界会清晰很多。
请求层要从 UI 通知和 store 细节里解耦
当前 fetch.js 最大的问题,不是拦截器本身,而是它已经同时耦合了三层职责:
- 认证头注入
- 错误提示 UI
- store 取值方式
这种结构短期写起来快,但长期会带来几个问题:
- 请求层很难复用到 SSR、测试或 Node 端场景
- 每次换状态管理方案都要改请求层
- 每次换通知组件都要改请求层
- 登录失效后的处理路径不统一
更稳的做法,是把请求层收敛成一个纯适配器,再通过回调或依赖注入接入外部系统。
export interface ApiClientOptions {
getToken: () => string | null
onUnauthorized: () => void
onError?: (message: string) => void
}
export function createApiClient(options: ApiClientOptions) {
const client = $fetch.create({
onRequest({ options: requestOptions }) {
const token = options.getToken()
if (token) {
requestOptions.headers = new Headers(requestOptions.headers)
requestOptions.headers.set('Authorization', `Bearer ${token}`)
}
},
onResponseError({ response }) {
if (response.status === 401) {
options.onUnauthorized()
return
}
options.onError?.(response._data?.message ?? '请求失败')
}
})
return client
}这样请求层只知道:
- 令牌从哪里来
- 未授权时要通知谁
- 普通错误如何上抛
它不再直接依赖 store、router 或具体 UI 库。
路由权限要靠显式 meta 和 guard 协议驱动
示例里的路由文件还很薄,这反而是一个很好的切入点:可以在复杂逻辑堆进来之前,把权限协议先定清楚。
更适合长期维护的方式,是给页面准入条件定义显式 meta,而不是在每个页面里各写一套 if。
export interface RouteAccessMeta {
requiresAuth?: boolean
roles?: string[]
redirectIfAuthed?: boolean
}路由守卫再围绕它工作:
import type { Router } from 'vue-router'
export function createAuthGuard(router: Router, session: ReturnType<typeof useAuthSession>) {
router.beforeEach((to) => {
const access = to.meta as RouteAccessMeta
if (access.redirectIfAuthed && session.isAuthenticated.value) {
return { path: '/' }
}
if (access.requiresAuth && !session.isAuthenticated.value) {
return {
path: '/login',
query: { redirect: to.fullPath }
}
}
if (access.roles?.length) {
const roles = session.user.value?.roles ?? []
const allowed = access.roles.some(role => roles.includes(role))
if (!allowed) {
return { path: '/403' }
}
}
})
}这样认证与权限就变成了显式协议:
- 页面通过
meta声明自己需要什么 - 守卫统一解释协议
- 页面组件不再各自处理准入逻辑
会话恢复、权限校验、跳转意图要拆开
很多项目把“我是不是登录了”理解成一个布尔值,但真正的认证链路至少包含三种不同状态:
属于会话状态的
- token
- 当前用户信息
- 角色集合
- 会话是否已从本地恢复
属于路由权限状态的
- 当前路由是否需要登录
- 当前路由需要哪些角色
- 当前是否应该阻止进入
属于页面交互状态的
- 登录表单 loading
- 当前错误提示是否显示
- 密码是否可见
- 登录后是否打开某个引导弹窗
如果这三层状态混在一起,就会出现很典型的问题:
- 为了跳转回原页面,把
redirect塞进 store - 为了控制按钮 loading,把提交状态塞进全局 session
- 为了做角色判断,让页面自己再请求一次用户信息
更稳的方式是:
useAuthSession()只处理会话与身份route.meta和beforeEach只处理准入协议- 页面组件只处理表单和局部反馈
统一错误模型比散落的消息提示更稳
示例里的 fetch.js 直接在拦截器里弹 Message,这在最初阶段很常见,但它会让错误处理越来越碎。
更稳的做法,是先把错误统一收敛为有限类型,再决定每层如何展示。
export type AuthErrorCode =
| 'INVALID_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NETWORK_ERROR'
| 'UNKNOWN'
export interface AppError {
code: AuthErrorCode
message: string
}然后让不同层只做自己该做的事:
- 请求层负责把响应映射成
AppError - 会话层负责处理
UNAUTHORIZED - 页面层决定是否显示表单错误
- 全局通知层决定是否弹 toast
这样以后无论你换成 Element、Naive UI、Nuxt UI,还是服务端调用场景,错误模型都不会跟着一起重写。
更适合现代 Vue 3 / Nuxt 的组织方式
如果把这类旧式 Vue 2 登录链路迁到现代项目,更推荐的目录边界通常是:
composables/auth/useAuthSession.tscomposables/auth/useLoginForm.tsutils/http/createApiClient.tsrouter/guards/auth.tspages/login.vuecomponents/auth/LoginFormCard.vue
在这套结构里:
useLoginForm()只处理字段、校验和提交协调useAuthSession()只处理身份与会话createApiClient()只处理 HTTP 协议适配auth guard只处理准入规则- 页面只做装配
这和前面几个案例的统一方法也完全一致:
- 先识别真正的复杂度中心
- 先稳定状态与协议
- 再拆页面与展示边界
- 最后才优化模板和 UI 细节
这类项目重构时最值得先检查的 6 个问题
以后再遇到类似“登录页 + 请求拦截 + 路由守卫 + 角色准入”的项目,可以先检查这几个问题:
- token、用户信息、角色是否有唯一来源
- 请求层是否直接依赖 store、router 和 UI 库
- 路由是否通过显式
meta声明准入规则 - 登录成功后的回跳意图是否有统一协议
- 401、403 和普通异常是否有清晰分层
- 页面组件是否只保留表单与局部交互职责
如果这 6 个问题答不清楚,就说明认证链路还没有真正完成收口。
这篇案例最后沉淀出的核心方法
这轮最重要的不是补一个登录页,而是沉淀出一条可复用的认证重构思路:
- 先把会话层从页面里抽出来
- 再把请求层从 UI 与 store 里解耦
- 再把权限准入收口到路由协议
- 最后让页面只消费这些能力
这样以后面对后台系统、内容社区、SaaS 工作台、运营平台这类项目时,登录、请求和路由权限都能挂到同一套稳定框架里,而不会每个项目重来一遍。
