从 0 搭建企业级 Vue3 权限路由与按钮级权限指令深度实践 – JasonCode编程

从 0 搭建企业级 Vue3 权限路由与按钮级权限指令深度实践 – JasonCode编程


エンタープライズレベルのアプリケーションを開発する場合、権限制御は決して単純な「ログイン後のジャンプ」ではありません。通常、次の 3 つの側面における複雑な訴えが含まれています。

これら 3 つの制御層を実現するために、従来の「フロントエンドでハードコーディングされた完全なルーティング」アプローチを放棄し、RBAC (役割ベースのアクセス制御) モデルに基づくフロントエンドとバックエンドの分離動的ルーティング ソリューション。

Vue Router 4 は非常に強力な機能を提供します addRoute API。我们的核心设计思想是:静态路由包含基础页面,动态路由由后端下发。

为了配合前端渲染,后端需要返回两类数据:菜单树(用于渲染侧边栏) 和 权限编码列表(用于按钮级控制)。

// 后端返回的权限数据格式
interface PermissionResponse 

3. コア実装 1: グローバル ルーター ガードと動的登録

新しい手書きルーティング ガードの多くは、ページが更新されると動的ルーティングが失われるという 2 つの致命的なバグを最も起こしやすいです。 無限ループのリダイレクト。

私たちのソリューションは次のとおりです guards.ts 以下の点で究極のステータス管理を実現します。

// src/router/guards.ts
import type  menu.name,
          icon: menu.meta?.icon,
          permission: menu.meta?.permission
         from 'vue-router'
import  from '@/stores/user'
import  from '@/stores/permission'

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

    // 1. 白名单放行(无需登录的页面)
    if (to.meta.requiresAuth === false) {
      return next()
    }

    // 2. 判断是否已登录(是否有 Token)
    if (!userStore.token) {
      return next({ path: " query: { redirect: to.fullPath } })
    }

    // 3. 核心:判断是否已拉取过权限
    // 第一次进入,或者刷新页面时,permissionStore.loaded 为 false
    if (!permissionStore.loaded) {
      try {
        // 拉取用户信息和权限路由,并动态 addRoute
        await permissionStore.fetchPermissions()
        // 关键操作:路由注册完成后,必须使用 replace 重新进入当前路由
        // 否则会出现白屏或者 404
        return next({ ...to, replace: true })
      } catch (error) {
        // 如果获取权限失败(如 Token 过期),清除凭证并踢回登录页
        userStore.clearToken()
        return next("
      }
    }

    // 4. 按钮级权限的细粒度路由校验(可选)
    const requiredPermission = to.meta?.permission as string
    if (requiredPermission && !permissionStore.hasPermission(requiredPermission)) {
      return next('/403')
    }

    next()
  })
}

  

キーポイント: なぜルートだけではだめなのか next() 過去?

初めてアクセスしたときはルートがまだ確立されていないため、 addRoute 現時点で直接完了 next() それでも 404 と一致します。 next({ ...to, replace: true }) 現在のナビゲーションを中断し、登録が完了するまで待ってから、新しいルーティング テーブルに基づいてナビゲーションを再開できます。

ピット踏みつけ記録1:存在 next() 崩壊の午後

動的ルーティングが初めて導入されたとき、ローカル開発は非常にスムーズでした。テスト環境に置くとすぐに、ページを更新するとすぐにブラウザがフリーズし、さらにプロンプ​​トが表示されました。 Maximum call stack size exceeded(スタックオーバーフロー)。

開けてみた router.beforeEach ログを見てみると、 login そして dashboard 激しく何度も飛び跳ねる。

原因分析:
ページを更新すると、permissionStore.loaded イニシャルは false。ルーティングが遮断された後、インターフェイスをプルし、プルを終了します。 loaded に設定 true、それから next() リリース。しかし、私は論理の致命的な部分を見逃していました。この時だったら token 期限が切れると、インターフェイスは 401 を返します。
私の Axios インターセプターは 401 をキャプチャし、トークンをクリアしてジャンプします /login。このとき、ルーティング ガードが再びトリガーされ、トークンが存在しないことがわかり、再びホップをインターセプトしました。 /login;ただしルートマッチングの混乱のため、または next({...to}) が間違って書かれていたため、行き止まりで激しく回転してしまいました。

本当の解決策:
ガードでは許可を引くアクションを追加する必要があります try...catch そして勢いよく飛び出して協力する。

if (!permissionStore.loaded) {
  try {
    await permissionStore.fetchPermissions()
    // 坑点:千万别写 next(),必须重新触发一次导航,让新挂载的路由生效
    return next({ ...to, replace: true }) 
  } catch (err) {
    // 坑点:这里如果不手动清理 token,死循环绝对跑不掉
    userStore.clearToken()
    return next({ path: " })
  }
}

  

4. コア実装 2: Pinia 状態管理と動的ルーティング分析

存在する 許可.ts 中,我们需要处理后端传来的菜单树,将其转换为 Vue Router 能够识别的ルートレコード生格式。

// src/stores/permission.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import router from '@/router'
import type { RouteRecordRaw } from 'vue-router'

export const usePermissionStore = defineStore('permission', () => {
  // 状态
  const loaded = ref(false)
  const permissions = ref([])
  const dynamicRoutes = ref([])

  // 动态导入组件的黑魔法
  const modules = import.meta.glob('@/views/**/*.vue')

  // 将后端菜单转换为 Vue Router 格式
  const transformMenuToRoute = (menus: any[]): RouteRecordRaw[] => {
    return menus.map(menu => {
      const route: RouteRecordRaw = {
        path: menu.path,
        name: menu.name,
        // 核心:通过 glob 映射,避免后端返回字符串导致无法加载组件
        component: modules[`/src/views/${menu.component}.vue`], 
        meta: {
          title: menu.meta?.title || menu.name,
          icon: menu.meta?.icon,
          permission: menu.meta?.permission
        },
        children: menu.children ? transformMenuToRoute(menu.children) : []
      }
      return route
    })
  }

  // 拉取权限并动态挂载
  const fetchPermissions = async () => {
    // 模拟 API 请求
    const res = await api.getPermissions() 
    permissions.value = res.permissions
    dynamicRoutes.value = transformMenuToRoute(res.menus)

    // 关键:将解析后的路由,动态添加到主 Layout 路由下
    dynamicRoutes.value.forEach(route => {
      router.addRoute('Layout', route) 
    })

    loaded.value = true
  }

  const hasPermission = (code: string) => {
    return permissions.value.includes(code)
  }

  return { loaded, permissions, dynamicRoutes, fetchPermissions, hasPermission }
})

重要なポイント:バックエンドによって返されました component 文字列のみにすることができます (例: "system/user/index")。絶対に使用しないでください import(menu.component) この種の動的な文字列連結は、Vite がパッケージ化するときに変数として処理するため、コンポーネントが失われます。必ず使用する import.meta.glob すべてのビュー ファイルをプリロードします。

蹂躙記録2:Vite Dynamic Importの「行き止まり」

動的ルーティング解析を作成した直後に、私は興奮して新しいメニュー テストを追加しました。結果をクリックして更新するとすぐにページが空白になり、コンソールにエラーが報告されます。Failed to fetch dynamically imported module: /src/views/system/user/index.vue。

トラブルシューティングに 4 時間を費やしましたが、Vite のバグかと思うところでした。後で、Vue Router のネイティブであることを発見しました。 component: () => import(XXX) 静的文字列のみをサポートします。バックエンドが戻るため menu.component 変数です(例: 'system/user/index')、Vite はパッケージ化時にこの変数がどのファイルを指しているかを分析できず、その結果、このコンポーネントはパッケージ化後にまったく含まれなくなります。

本当の解決策(キラーバージョン):
Vite が提供するものを使用する必要があります import.meta.glob。しかし、非常に陥りやすい別の落とし穴があります。glob パスの接合。 Windows システムの場合、パスはバックスラッシュになります。私の最終的なコードは次のとおりです。
 

// 解决前: modules[`/src/views/${menu.component}.vue`] 容易因为大小写或斜杠报错
// 解决后:加入容错处理,并在路径中强制替换
const modules = import.meta.glob('../../views/**/*.vue')

const getComponent = (componentPath: string) => {
  const path = `../../views/${componentPath}.vue`
  // 如果找不到,就给个 404 占位组件,防止整个应用崩溃
  return modules[path] || (() => import('../../views/error/404.vue'))
}

(その時は一つ少なく書いたので ../../私は画面に目がくらんでしまいました。 )

5. コア実装 3: ボタンレベルの許可ディレクティブ (カスタマイズされたディレクティブ)

ボタンレベルの権限制御の場合、使用する場合 v-if="hasPermission('xxx')"ページのコードは非常に醜くなり、保守が困難になります。

カスタム命令をカプセル化します v-permission エレガントなコントロールを実現します。このコマンドはマウント時に判断するだけでなく、アクセス許可が変更されたときにも応答する必要があります (これはめったに起こりません)。

// src/directives/permission.ts
import { type Directive, type DirectiveBinding } from 'vue'
import { usePermissionStore } from '@/stores/permission'

export const permission: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const { value } = binding
    const permissionStore = usePermissionStore()

    // 支持传入数组,如 v-permission="['user:add', 'user:edit']" (满足其一即可)
    const requiredPermissions = Array.isArray(value) ? value : [value]

    if (requiredPermissions.length > 0) {
      const hasPerm = requiredPermissions.some(perm => permissionStore.hasPermission(perm))

      if (!hasPerm) {
        // 如果没有权限,直接从 DOM 树中移除该元素
        el.parentNode?.removeChild(el)
      }
    } else {
      throw new Error('v-permission 指令需要指定权限编码,如 v-permission="\'user:add\'"')
    }
  }
}

  メインエントランスにて(main.ts) このディレクティブを登録するには:

app.directive('permission', permission)

ビジネスコードでは、使用法は非常に簡潔です。

  

悲劇の記録 3: フロントエンドの指示だけでは不十分

書き終えました v-permission 説明の後、私は興奮してデモを行ってみました。管理者には「削除」ボタンが表示されますが、一般のテスターがログインすると、ボタンは表示されなくなります。
しかし、私のフロントエンド リーダー (または反省) は、F12 開発者ツールを直接開き、要素検査で、

**私はジェイソン、独立系開発者です。フルスタック開発とアーキテクチャ設計、および AI インテリジェントな実装に焦点を当てます。私の個人ホームページにAITest実践レビューを連載しています。いつでも注意を払い、コミュニケーションをとることを歓迎します。 **



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *