前端權限管理之 addRoutes 動態加載路由踩坑

這幾天在開發後臺管理系統的路由權限,在開始作以前,我查閱了很多資料,發現先後端分離的權限管理基本就如下兩種方式:前端

  1. 後端生成當前用戶相應的路由後由前端(用 Vue Router 提供的API)addRoutes 動態加載路由。
  2. 前端寫好全部的路由,後端返回當前用戶的角色,而後根據事先約定好的每一個角色擁有哪些路由對角色的路由進行分配。

兩種方法的不一樣

第一種,徹底由後端控制路由,但這也意味着若是前端須要修改或者增減路由都須要通過後端大大的贊成,也是我司目前採用的方式;vue

第二種,相對於第一種,前端相對會自由一些,可是若是角色權限發生了改變就須要先後端一塊兒修改,並且若是某些(技術型)用戶在前端修改了本身的角色權限就能夠經過路由看到一些本不被容許看到的頁面,雖然拿不到數據,可是有些頁面仍是不但願被不相關的人看到(雖然我我的jio得並無什麼關係,可是無奈leader仍是偏向不想被看到不應看到的頁面)。git

接下來我主要講一下第一種方式得作法以及踩的一些坑。github

addRoutes 須要的數據格式

官方文檔vuex

router.addRouteselement-ui

函數簽名:後端

router.addRoutes(routes: Array<RouteConfig>)
複製代碼

動態添加更多的路由規則。參數必須是一個符合 routes 選項要求的數組。api

前端初始化路由

我的認爲 addRoutes 能夠理解爲往現有的路由後面添加新的路由,因此在 addRoutes 以前咱們須要初始化一些不須要權限的路由頁面,好比登陸頁、首頁、404頁面等,這個過程很簡單,就是往路由文件裏面加入靜態路由就好了,這裏就不贅述了。數組

接下來就是設計後端路由表,肯定先後端交互的數據格式。瀏覽器

設計後端路由表

字段名 說明
*id id
*pid 父級id
*path 路由路徑
name 路由名稱
*component 路由組件路徑
redirect 重定向路徑
hidden 是否隱藏
meta 標識

* 的爲必有字段

接收後端生成的路由並解析

經過上面設計的路由表能夠發現路由之間時是經過 pid 來肯定上下級的,因此在接收到後端傳來的路由數據時咱們須要在前端解析成符合 addRoutes 入參的格式。

在接收到後端生成的路由後經過如下函數進行解析成相應的格式:

parse_routes.js

import Router from '@/router'

/**
 * @desc: 解析原始路由信息(路由之間經過pid肯定上下級)並動態添加路由及跳轉頁面
 * @param {Array} menus - (從後端獲取的)菜單路由信息
 * @param {String} to - 解析成功後須要跳轉的路由路徑
 * @example
 * // 引入parse_routes
 * const menus = [ // 由後端傳入
 *  { "id": 1, "pid": 0, "path": "/receipt", "name": "", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
 *  { "id": 2, "pid": 1, "path": "index", "name": "Receipt", "component": "receipt/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"收款管理\", \"icon\": \"receipt\"}" },
 *  { "id": 3, "pid": 0, "path": "/payment", "name": "", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
 *  { "id": 4, "pid": 3, "path": "index", "name": "Payment", "component": "payment/index", "redirect": "", "hidden": "false", "meta": "{\"title\": \"付款管理\", \"icon\": \"payment\"}" },
 *  { "id": 5, "pid": 0, "path": "/crm", "name":"", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
 *  { "id": 6, "pid": 5, "path": "index","name": "Crm", "component": "crm/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"客戶管理\", \"icon\": \"people\"}" },
 *  { "id": 7, "pid": 0, "path": "/upload_product", "name":"", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": ""},
 *  { "id": 8, "pid": 7, "path": "index","name": "productUpload", "component": "productUpload/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"測評商品上傳\", \"icon\": \"upload\"}" }
 * ]
 * ParseRoutes(menus, '/payment/index')
 */
 
export default (menus, to = '/') => {
  // 初始路由
  const defRoutes = [
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/views/login/index'),
      hidden: true
    },
    {
      path: '/',
      component: () => import('@/views/layout/Layout'),
      redirect: '/dashboard',
      name: 'Dashboard',
      children: [
        {
          path: 'dashboard',
          meta: { title: '首頁', icon: 'home' },
          component: () => import('@/views/dashboard/index')
        }
      ]
    },
    {
      path: '/404',
      name: '404',
      component: () => import('@/views/404'),
      hidden: true
    },
    {
      path: '*',
      redirect: '/404',
      hidden: true
    }
  ]

  // 初始化路由信息對象
  const menusMap = {}
  menus.map(v => {
    const { path, name, component, redirect, hidden, meta } = v
    // 從新構建路由對象
    const item = {
      path,
      name,
      component: () => import(`@/views/${component}`),
      redirect,
      hidden: JSON.parse(hidden)
    }
    meta.length !== 0 && (item.meta = JSON.parse(meta))
    // 判斷是否爲根節點
    if (v.pid === 0) {
      menusMap[v.id] = item
    } else {
      !menusMap[v.pid].children && (menusMap[v.pid].children = [])
      menusMap[v.pid].children.push(item)
    }
  })

  // 將生成數組樹結構的菜單
  const routes = Object.values(menusMap)
  // 默認路由拼接生成的路由(注意順序)
  const integralRoutes = defRoutes.concat(routes)

  Router.options.routes = integralRoutes
  Router.addRoutes(routes)
  Router.push({ path: to })
}

複製代碼

渲染側邊欄菜單

在成功解析數據以後就須要渲染側邊欄了,我這裏參考的是大佬(PanJiaChen)的 element-ui-admin,具體能夠參考大佬的代碼,這裏也再也不贅述了。

若是堅持看到了這裏,那麼恭喜你,基本就能夠經過 addRoutes 動態加載路由了。

接下來就開始講我在使用 addRoutes 的過程當中遇到的一些坑。(讀者內心os: mmp,終於進入正題了~)

重點難點1:跳轉頁面後404

在咱們成功動態添加路由後,改變地址欄或者刷新頁面,你會發現頁面跳到了404。

根據咱們上面的路由配置:

[
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/views/login/index'),
      hidden: true
    },
    {
      path: '/',
      component: () => import('@/views/layout/Layout'),
      redirect: '/dashboard',
      name: 'Dashboard',
      children: [
        {
          path: 'dashboard',
          meta: { title: '首頁', icon: 'home' },
          component: () => import('@/views/dashboard/index')
        }
      ]
    },
    {
      path: '/404',
      name: '404',
      component: () => import('@/views/404'),
      hidden: true
    },
    {
      path: '*',
      redirect: '/404',
      hidden: true
    }
  ]
複製代碼

你會發現咱們在這裏面初始化了404路由,因此在路由沒有找到強匹配的地址時,就會跳轉到404頁面。

解決的方法不少,咱們這裏只講一種。

解決方案

就是不在初始化路由的時候初始化404路由,而是在解析接收到的路由數據時拼接路由便可解決問題。

parse_routes.js

...
// 將生成數組樹結構的菜單並拼接404路由
  const routes = Object.values(menusMap).concat(notFoundRoutes)
複製代碼

重點難點2:刷新頁面路由失效

解決了404的問題後,再次刷新頁面會發現頁面變空白了,這是由於刷新頁面router實例會從新初始化到初始狀態。

解決方案

咱們在獲取到後端數據的時候將之存入 vuex 和 瀏覽器緩存(我用的是 sessionStorage) 中。注意,這裏是將獲取到的數據直接存入,由於 sessionStorage 只能存字符串,而咱們在轉換格式的過程當中是須要解析某些字段,例如 component, hidden等。

actions.js

...
const menus = data.data.menus
// 將獲取到的數據存入 sessionStorage 和 vuex 中
sessionStorage.setItem('_c_unparseRoutes', JSON.stringify(menus))
commit('GET_ROUTES', menus) // 解析函數
ParseRoutes(menus)
複製代碼

而後在 App.vue 中的鉤子函數 created() 或者 mounted() 中檢測 vuex 中的數據是否爲空且 sessionStorage 中是否有存入關的數據,並監聽頁面刷新。

App.vue

...
created() {
  const unparseRoutes = JSON.parse(sessionStorage.getItem('_c_unparseRoutes'))
  if (this.localRoutes.length === 0 && unparseRoutes) {
    const toPath = sessionStorage.getItem('_c_lastPath')
    ParseRoutes(unparseRoutes, toPath) // 解析函數
  }
  // 監聽頁面刷新
  window.addEventListener('beforeunload', () => {
    sessionStorage.setItem('_c_lastPath', this.$router.currentRoute.path)
  })
}
複製代碼

解析函數(完整版)

import Router from '@/router'

/**
 * @desc: 解析原始路由信息(路由之間經過pid肯定上下級)並動態添加路由及跳轉頁面
 * @param {Array} menus - (從後端獲取的)菜單路由信息
 * @param {String} to - 解析成功後須要跳轉的路由路徑
 * @example
 * // 引入parse_routes
 * const menus = [ // 由後端傳入
 *  { "id": 1, "pid": 0, "path": "/receipt", "name": "", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
 *  { "id": 2, "pid": 1, "path": "index", "name": "Receipt", "component": "receipt/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"收款管理\", \"icon\": \"receipt\"}" },
 *  { "id": 3, "pid": 0, "path": "/payment", "name": "", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
 *  { "id": 4, "pid": 3, "path": "index", "name": "Payment", "component": "payment/index", "redirect": "", "hidden": "false", "meta": "{\"title\": \"付款管理\", \"icon\": \"payment\"}" },
 *  { "id": 5, "pid": 0, "path": "/crm", "name":"", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": "" },
 *  { "id": 6, "pid": 5, "path": "index","name": "Crm", "component": "crm/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"客戶管理\", \"icon\": \"people\"}" },
 *  { "id": 7, "pid": 0, "path": "/upload_product", "name":"", "component": "layout/Layout", "redirect": "", "hidden": "false", "meta": ""},
 *  { "id": 8, "pid": 7, "path": "index","name": "productUpload", "component": "productUpload/index", "redirect": "","hidden": "false", "meta": "{\"title\": \"測評商品上傳\", \"icon\": \"upload\"}" }
 * ]
 * ParseRoutes(menus, '/payment/index')
 */
 
export default (menus, to = '/') => {
  // 初始路由
  const defRoutes = [
    {
      path: '/login',
      name: 'Login',
      component: () => import('@/views/login/index'),
      hidden: true
    },
    {
      path: '/',
      component: () => import('@/views/layout/Layout'),
      redirect: '/dashboard',
      name: 'Dashboard',
      children: [
        {
          path: 'dashboard',
          meta: { title: '首頁', icon: 'home' },
          component: () => import('@/views/dashboard/index')
        }
      ]
    }
  ]
  // 404路由
  const notFoundRoutes = [
    { path: '/404', name: '404', component: () => import('@/views/404'), hidden: true },
    { path: '*', redirect: '/404', hidden: true }
  ]
  // 初始化路由信息對象
  const menusMap = {}
  menus.map(v => {
    const { path, name, component, redirect, hidden, meta } = v
    // 從新構建路由對象
    const item = {
      path,
      name,
      component: () => import(`@/views/${component}`),
      redirect,
      hidden: JSON.parse(hidden)
    }
    meta.length !== 0 && (item.meta = JSON.parse(meta))
    // 判斷是否爲根節點
    if (v.pid === 0) {
      menusMap[v.id] = item
    } else {
      !menusMap[v.pid].children && (menusMap[v.pid].children = [])
      menusMap[v.pid].children.push(item)
    }
  })

  // 將生成數組樹結構的菜單並拼接404路由
  const routes = Object.values(menusMap).concat(notFoundRoutes)
  // 默認路由拼接生成的路由(注意順序)
  const integralRoutes = defRoutes.concat(routes)

  Router.options.routes = integralRoutes
  Router.addRoutes(routes)
  Router.push({ path: to })
}

複製代碼

寫在最後,以上就是我這兩天在寫權限管理時使用 addRoutes 動態加載路由的方法以及時遇到的一些坑。

第一次寫這麼長的文章,若是內容有什麼不對,望海涵並指出!若是有什麼更好的建議也請多多指出!!

若是有喜歡的老鐵記得雙擊加點贊~(開個玩笑)

相關文章
相關標籤/搜索