前端許可權管理之 addRoutes 動態載入路由踩坑

w6a發表於2019-03-09

這幾天在開發後臺管理系統的路由許可權,在開始做之前,我查閱了不少資料,發現前後端分離的許可權管理基本就以下兩種方式:

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

兩種方法的不同

第一種,完全由後端控制路由,但這也意味著如果前端需要修改或者增減路由都需要經過後端大大的同意,也是我司目前採用的方式;

第二種,相對於第一種,前端相對會自由一些,但是如果角色許可權發生了改變就需要前後端一起修改,而且如果某些(技術型)使用者在前端修改了自己的角色許可權就可以通過路由看到一些本不被允許看到的頁面,雖然拿不到資料,但是有些頁面還是不希望被不相關的人看到(雖然我個人jio得並沒有什麼關係,但是無奈leader還是偏向不想被看到不該看到的頁面)。

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

addRoutes 需要的資料格式

官方文件

router.addRoutes

函式簽名:

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

動態新增更多的路由規則。引數必須是一個符合 routes 選項要求的陣列。

前端初始化路由

個人認為 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 動態載入路由的方法以及時遇到的一些坑。

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

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

相關文章