Skip to content

认证请求与路由权限链路重构

这次整理的对象主要来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin 里的最小后台壳子,重点参考下面这些文件:

  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin/src/view/login/index.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin/src/utils/fetch.js
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-admin/src/router/index.js
  • 11Vue学习/vue2相关文档学习笔记/vue-router/1使用.md
  • 11Vue学习/vue2相关文档学习笔记/vue-router/3VueRouter对象.md

和前面的案例一样,我们不直接改这个独立子项目,而是把其中真正值得长期复用的链路抽出来,沉淀成一篇认证、请求与路由权限场景的重构文档。

这类项目最容易被低估的地方,不是“登录页怎么写”,而是下面这条完整链路会不会越来越散:

  • 登录表单收集凭据
  • 会话层持久化 token 和用户信息
  • 请求层注入认证头
  • 响应层处理失效与异常
  • 路由层决定页面是否可进入
  • 页面层根据权限装配能力和入口

只要这条链路没有统一结构,后面就很容易出现几个典型问题:

  • 登录页自己管提交、跳转、弹窗、错误提示和会话持久化
  • 请求拦截器直接依赖 UI 组件、store 细节和路由实例
  • 路由守卫只剩布尔判断,没有显式权限协议
  • token 失效、重新登录、返回原页面这些流程彼此打架

这类场景真正的复杂度中心

认证场景的复杂度中心,通常不是单个表单,而是“认证状态如何穿过请求层和路由层”。

拿当前示例来说,文件虽然不多,但已经隐含了完整问题链:

  • login/index.vue 同时承担表单状态、校验、提交、加载态和跳转
  • fetch.js 负责 token 注入和错误提示,但直接依赖 storeMessage
  • router/index.js 目前还很薄,意味着真正的权限边界还没有收口成一层稳定协议

一旦页面数量变多,这种结构很容易继续膨胀成:

  • 每个页面都各自判断登录态
  • 每个请求各自兜底 401
  • 每种角色各自写一套跳转逻辑
  • 登录成功后的回跳路径四散在组件里

所以这类重构最关键的,不是先补更多守卫,而是先把认证链路拆成几层职责明确的能力。

推荐的重构边界

更适合长期维护的结构,通常会把认证链路拆成下面几层:

  • 会话状态层:管理 token、用户信息、角色和会话恢复
  • 请求适配层:统一注入认证头、刷新逻辑、错误归一化
  • 路由权限层:统一处理 requiresAuth、角色准入和回跳意图
  • 页面装配层:只负责表单输入、按钮交互和页面级反馈
  • 展示组件层:只负责字段展示和输入输出

如果换成更具体的能力,大概可以这样分:

  • useAuthSession():负责登录、退出、恢复会话、读取角色
  • createApiClient():负责请求拦截、错误映射、认证头注入
  • createAuthGuard():负责路由 meta 解释、未登录跳转、登录后回跳
  • LoginPage:只负责表单与页面反馈
  • LoginFormCard:只负责 props / emits

这里最重要的一条原则是:

  • 登录页不直接拥有认证系统
  • 请求层不直接拥有 UI 提示系统
  • 路由守卫不直接拥有业务页面逻辑

登录页不该直接拥有认证流程

当前示例里的登录页已经暴露出一个很常见的问题:表单校验、提交状态、登录调用、成功跳转、第三方弹窗都挤在一个组件里。

这在 demo 里能跑,但在真实项目里会迅速失控。因为登录页天生只是“认证入口页面”,它不应该同时承担下面这些职责:

  • 保存会话
  • 决定首页或回跳地址
  • 统一处理认证失败
  • 决定后续页面的权限可见性

更稳的做法,是让登录页只消费 useAuthSession() 暴露出来的最小接口。

ts
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 端场景
  • 每次换状态管理方案都要改请求层
  • 每次换通知组件都要改请求层
  • 登录失效后的处理路径不统一

更稳的做法,是把请求层收敛成一个纯适配器,再通过回调或依赖注入接入外部系统。

ts
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
}

这样请求层只知道:

  • 令牌从哪里来
  • 未授权时要通知谁
  • 普通错误如何上抛

它不再直接依赖 storerouter 或具体 UI 库。

路由权限要靠显式 meta 和 guard 协议驱动

示例里的路由文件还很薄,这反而是一个很好的切入点:可以在复杂逻辑堆进来之前,把权限协议先定清楚。

更适合长期维护的方式,是给页面准入条件定义显式 meta,而不是在每个页面里各写一套 if。

ts
export interface RouteAccessMeta {
  requiresAuth?: boolean
  roles?: string[]
  redirectIfAuthed?: boolean
}

路由守卫再围绕它工作:

ts
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.metabeforeEach 只处理准入协议
  • 页面组件只处理表单和局部反馈

统一错误模型比散落的消息提示更稳

示例里的 fetch.js 直接在拦截器里弹 Message,这在最初阶段很常见,但它会让错误处理越来越碎。

更稳的做法,是先把错误统一收敛为有限类型,再决定每层如何展示。

ts
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.ts
  • composables/auth/useLoginForm.ts
  • utils/http/createApiClient.ts
  • router/guards/auth.ts
  • pages/login.vue
  • components/auth/LoginFormCard.vue

在这套结构里:

  • useLoginForm() 只处理字段、校验和提交协调
  • useAuthSession() 只处理身份与会话
  • createApiClient() 只处理 HTTP 协议适配
  • auth guard 只处理准入规则
  • 页面只做装配

这和前面几个案例的统一方法也完全一致:

  • 先识别真正的复杂度中心
  • 先稳定状态与协议
  • 再拆页面与展示边界
  • 最后才优化模板和 UI 细节

这类项目重构时最值得先检查的 6 个问题

以后再遇到类似“登录页 + 请求拦截 + 路由守卫 + 角色准入”的项目,可以先检查这几个问题:

  1. token、用户信息、角色是否有唯一来源
  2. 请求层是否直接依赖 store、router 和 UI 库
  3. 路由是否通过显式 meta 声明准入规则
  4. 登录成功后的回跳意图是否有统一协议
  5. 401、403 和普通异常是否有清晰分层
  6. 页面组件是否只保留表单与局部交互职责

如果这 6 个问题答不清楚,就说明认证链路还没有真正完成收口。

这篇案例最后沉淀出的核心方法

这轮最重要的不是补一个登录页,而是沉淀出一条可复用的认证重构思路:

  • 先把会话层从页面里抽出来
  • 再把请求层从 UI 与 store 里解耦
  • 再把权限准入收口到路由协议
  • 最后让页面只消费这些能力

这样以后面对后台系统、内容社区、SaaS 工作台、运营平台这类项目时,登录、请求和路由权限都能挂到同一套稳定框架里,而不会每个项目重来一遍。

共 20 个模块,1301 篇 Markdown 文档。