淺析 vue-router 源碼和動態路由權限分配

72 篇原創好文~ 本文首發於政採雲前端團隊博客:淺析 vue-router 源碼和動態路由權限分配 javascript

淺析 vue-router 源碼和動態路由權限分配

背景

上月立過一個 flag,看完 vue-router 的源碼,可到後面逐漸發現 vue-router 的源碼並非像不少總結的文章那麼容易理解,閱讀過你就會發現裏面的不少地方都會有多層的函數調用關係,還有大量的 this 指向問題,並且會有不少輔助函數須要去理解。但仍是堅持啃下來了(固然還沒看完,內容是真的多),下面是我在政採雲(實習)工做閒暇時間閱讀源碼的一些感悟和總結,並帶分析了大三時期使用的 vue-element-admin 這個 vuer 無所不知的後臺框架的動態路由權限控制原理。順便附帶本文實踐 demo 地址: 基於後臺框架開發的 學生管理系統前端

vue-router 源碼分析

首先閱讀源碼以前最好是將 Vuevue-router 的源碼克隆下來,而後第一遍閱讀建議先跟着 官方文檔 先走一遍基礎用法,而後第二遍開始閱讀源碼,先理清楚各層級目錄的做用和抽出一些核心的文件出來,過一遍代碼的同時寫個小的 demo 邊看邊打斷點調試,看不懂不要緊,能夠邊看邊參考一些總結的比較好的文章,最後將比較重要的原理過程根據本身的理解整理出來,而後畫一畫相關的知識腦圖加深印象。vue

前置知識: flow 語法

JS 在編譯過程當中可能看不出一些隱蔽的錯誤,但在運行過程當中會報各類各樣的 bug。flow 的做用就是編譯期間進行靜態類型檢查,儘早發現錯誤,拋出異常。java

VueVue-router 等大型項目每每須要這種工具去作靜態類型檢查以保證代碼的可維護性和可靠性。本文所分析的 vue-router 源碼中就大量的採用了 flow 去編寫函數,因此學習 flow 的語法是有必要的。node

首先安裝 flow 環境,初始化環境git

npm install flow-bin -g
flow init

index.js 中輸入這一段報錯的代碼github

/*@flow*/
function add(x: string, y: number): number {
  return x + y
}
add(2, 11)

在控制檯輸入 flow ,這個時候不出意外就會拋出異常提示,這就是簡單的 flow 使用方法。正則表達式

具體用法還須要參考 flow官網,另外這種語法是相似於 TypeScript 的。vue-router

註冊

咱們平時在使用 vue-router 的時候一般須要在 main.js 中初始化 Vue 實例時將 vue-router 實例對象當作參數傳入vuex

例如:

import Router from 'vue-router'
Vue.use(Router)
const routes = [
   {
    path: '/student',
    name: 'student',
    component: Layout,
    meta: { title: '學生信息查詢', icon: 'documentation', roles: ['student'] },
    children: [
      {
        path: 'info',
        component: () => import('@/views/student/info'),
        name: 'studentInfo',
        meta: { title: '信息查詢', icon: 'form' }
      },
      {
        path: 'score',
        component: () => import('@/views/student/score'),
        name: 'studentScore',
        meta: { title: '成績查詢', icon: 'score' }
      }
    ]
  }
  ...
];
const router = new Router({
  mode: "history",
  linkActiveClass: "active",
  base: process.env.BASE_URL,
  routes
});
new Vue({
    router,
    store,
    render: h => h(App)
}).$mount("#app");
Vue.use

那麼 Vue.use(Router) 又在作什麼事情呢

問題定位到 Vue 源碼中的 src/core/global-api/use.js 源碼地址

export function initUse (Vue: GlobalAPI) {
  Vue.use = function (plugin: Function | Object) {
    // 拿到 installPlugins 
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    // 保證不會重複註冊
    if (installedPlugins.indexOf(plugin) > -1) {
      return this
    }
    // 獲取第一個參數 plugins 之外的參數
    const args = toArray(arguments, 1)
    // 將 Vue 實例添加到參數
    args.unshift(this)
    // 執行 plugin 的 install 方法 每一個 insatll 方法的第一個參數都會變成 Vue,不須要額外引入
    if (typeof plugin.install === 'function') {
      plugin.install.apply(plugin, args)
    } else if (typeof plugin === 'function') {
      plugin.apply(null, args)
    }
    // 最後用 installPlugins 保存 
    installedPlugins.push(plugin)
    return this
  }
}

能夠看到 Vueuse 方法會接受一個 plugin 參數,而後使用 installPlugins 數組保存已經註冊過的 plugin 。 首先保證 plugin 不被重複註冊,而後將 Vue 從函數參數中取出,將整個 Vue 做爲 plugininstall 方法的第一個參數,這樣作的好處就是不須要麻煩的另外引入 Vue,便於操做。 接着就去判斷 plugin 上是否存在 install 方法。存在則將賦值後的參數傳入執行 ,最後將全部的存在 install 方法的 plugin 交給 installPlugins維護。

install

瞭解清楚 Vue.use 的結構以後,能夠得出 Vue 註冊插件其實就是在執行插件的 install 方法,參數的第一項就是 Vue,因此咱們將代碼定位到 vue-router 源碼中的 src/install.js 源碼地址

// 保存 Vue 的局部變量
export let _Vue
export function install (Vue) {
  // 若是已安裝
  if (install.installed && _Vue === Vue) return
  install.installed = true
 // 局部變量保留傳入的 Vue
  _Vue = Vue
  const isDef = v => v !== undefined
  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }
  // 全局混入鉤子函數 每一個組件都會有這些鉤子函數,執行就會走這裏的邏輯
  Vue.mixin({
    beforeCreate () {
      if (isDef(this.$options.router)) {
        // new Vue 時傳入的根組件 router router對象傳入時就能夠拿到 this.$options.router
        // 根 router
        this._routerRoot = this
        this._router = this.$options.router
        this._router.init(this)
        // 變成響應式
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 非根組件訪問根組件經過$parent
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      registerInstance(this, this)
    },
    destroyed () {
      registerInstance(this)
    }
  })
  // 原型加入 $router 和 $route
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })
// 全局註冊
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)
// 獲取合併策略
  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

能夠看到這段代碼核心部分就是在執行 install 方法時使用 mixin 的方式將每一個組件都混入 beforeCreate,destroyed 這兩個生命週期鉤子。在 beforeCreate 函數中會去判斷當前傳入的 router 實例是不是根組件,若是是,則將 _routerRoot 賦值爲當前組件實例、_router 賦值爲傳入的VueRouter 實例對象,接着執行 init 方法初始化 router,而後將 this_route 響應式化。非根組件的話 _routerRoot 指向 $parent 父實例。
而後執行 registerInstance(this,this) 方法,該方法後會,接着原型加入 $router$route,最後註冊 RouterViewRouterLink,這就是整個 install 的過程。

小結

Vue.use(plugin) 實際上在執行 plugin上的 install 方法,insatll 方法有個重要的步驟:

  • 使用 mixin 在組件中混入 beforeCreate , destory 這倆個生命週期鉤子
  • beforeCreate 這個鉤子進行初始化。
  • 全局註冊 router-viewrouter-link組件

VueRouter

接着就是這個最重要的 class : VueRouter。這一部分代碼比較多,因此不一一列舉,挑重點分析。 vueRouter源碼地址

構造函數
constructor (options: RouterOptions = {}) {
    this.app  = null
    this.apps = []
    // 傳入的配置項
    this.options = options
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    this.matcher = createMatcher(options.routes || [], this)
    // 通常分兩種模式 hash 和 history 路由 第三種是抽象模式
    let mode = options.mode || 'hash'
    // 判斷當前傳入的配置是否能使用 history 模式
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    // 降級處理
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode
    // 根據模式實例化不一樣的 history,history 對象會對路由進行管理 繼承於history class
    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
  }

首先在初始化 vueRouter 整個對象時定義了許多變量,app 表明 Vue 實例,options 表明傳入的配置參數,而後就是路由攔截有用的 hooks 和重要的 matcher (後文會寫到)。構造函數其實在作兩件事情: 1. 肯定當前路由使用的 mode2. 實例化對應的 history 對象。

init

接着完成實例化 vueRouter 以後,若是這個實例傳入後,也就是剛開始說的將 vueRouter 實例在初始化 Vue 時傳入,它會在執行 beforeCreate 時執行 init 方法

init (app: any) {
  ...
  this.apps.push(app)
  // 確保後面的邏輯只走一次
  if (this.app) {
    return
  }
  // 保存 Vue 實例
  this.app = app
  const history = this.history
  // 拿到 history 實例以後,調用 transitionTo 進行路由過渡
  if (history instanceof HTML5History) {
    history.transitionTo(history.getCurrentLocation())
  } else if (history instanceof HashHistory) {
    const setupHashListener = () => {
      history.setupListeners()
    }
    history.transitionTo(
      history.getCurrentLocation(),
      setupHashListener,
      setupHashListener
    )
  }
}

init 方法傳入 Vue 實例,保存到 this.apps 當中。Vue實例 會取出當前的 this.history,若是是哈希路由,先走 setupHashListener 函數,而後調一個關鍵的函數 transitionTo 路由過渡,這個函數其實調用了 this.matcher.match 去匹配。

小結

首先在 vueRouter 構造函數執行完會完成路由模式的選擇,生成 matcher ,而後初始化路由須要傳入 vueRouter 實例對象,在組件初始化階段執行 beforeCreate 鉤子,調用 init 方法,接着拿到 this.history 去調用 transitionTo 進行路由過渡。

Matcher

以前在 vueRouter 的構造函數中初始化了 macther,本節將詳細分析下面這句代碼到底在作什麼事情,以及 match 方法在作什麼源碼地址

this.matcher = createMatcher(options.routes || [], this)

首先將代碼定位到create-matcher.js

export function createMatcher (
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  // 建立映射表
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  // 添加動態路由
  function addRoutes(routes){...}
  // 計算新路徑
  function match (
    raw: RawLocation,
    currentRoute?: Route,
    redirectedFrom?: Location
  ): Route {...}
  // ... 後面的一些方法暫不展開
  
   return {
    match,
    addRoutes
  }
}

createMatcher 接受倆參數,分別是 routes,這個就是咱們平時在 router.js 定義的路由表配置,而後還有一個參數是 router 他是 new vueRouter 返回的實例。

createRouteMap

下面這句代碼是在建立一張 path-record,name-record 的映射表,咱們將代碼定位到 create-route-map.js 源碼地址

export function createRouteMap (
  routes: Array<RouteConfig>,
  oldPathList?: Array<string>,
  oldPathMap?: Dictionary<RouteRecord>,
  oldNameMap?: Dictionary<RouteRecord>
): {
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>
} {
  // 記錄全部的 path
  const pathList: Array<string> = oldPathList || []
  // 記錄 path-RouteRecord 的 Map
  const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
   // 記錄 name-RouteRecord 的 Map
  const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)
  // 遍歷全部的 route 生成對應映射表
  routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })
  // 調整優先級
  for (let i = 0, l = pathList.length; i < l; i++) {
    if (pathList[i] === '*') {
      pathList.push(pathList.splice(i, 1)[0])
      l--
      i--
    }
  }
  return {
    pathList,
    pathMap,
    nameMap
  }
}

createRouteMap 須要傳入路由配置,支持傳入舊路徑數組和舊的 Map 這一步是爲後面遞歸和 addRoutes 作好準備。 首先用三個變量記錄 path,pathMap,nameMap,接着咱們來看 addRouteRecord 這個核心方法。
這一塊代碼太多了,列舉幾個重要的步驟

// 解析路徑
const pathToRegexpOptions: PathToRegexpOptions =
    route.pathToRegexpOptions || {}
// 拼接路徑
const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)
// 記錄路由信息的關鍵對象,後續會依此創建映射表
const record: RouteRecord = {
  path: normalizedPath,
  regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
  // route 對應的組件
  components: route.components || { default: route.component },
  // 組件實例
  instances: {},
  name,
  parent,
  matchAs,
  redirect: route.redirect,
  beforeEnter: route.beforeEnter,
  meta: route.meta || {},
  props: route.props == null
    ? {}
    : route.components
      ? route.props
      : { default: route.props }
}

使用 recod 對象 記錄路由配置有利於後續路徑切換時計算出新路徑,這裏的 path 實際上是經過傳入父級 record 對象的path和當前 path 拼接出來的 。而後 regex 使用一個庫將 path 解析爲正則表達式。
若是 route 有子節點就遞歸調用 addRouteRecord

// 若是有 children 遞歸調用 addRouteRecord
    route.children.forEach(child => {
      const childMatchAs = matchAs
        ? cleanPath(`${matchAs}/${child.path}`)
        : undefined
      addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
    })

最後映射兩張表,並將 record·path 保存進 pathList,nameMap 邏輯類似就不列舉了

if (!pathMap[record.path]) {
    pathList.push(record.path)
    pathMap[record.path] = record
  }

廢了這麼大勁將 pathListpathMapnameMap 抽出來是爲啥呢?
首先 pathList 是記錄路由配置全部的 path,而後 pathMapnameMap 方便咱們傳入 path 或者 name 快速定位到一個 record,而後輔助後續路徑切換計算路由的。

addRoutes

這是在 vue2.2.0 以後新添加的 api ,或許不少狀況路由並非寫死的,須要動態添加路由。有了前面的 createRouteMap 的基礎上咱們只須要傳入 routes 便可,他就能在原基礎上修改

function addRoutes (routes) {
  createRouteMap(routes, pathList, pathMap, nameMap)
}

而且看到在 createMathcer 最後返回了這個方法,因此咱們就可使用這個方法

return {
    match,
    addRoutes
  }
match
function match (
  raw: RawLocation,
  currentRoute?: Route,
  redirectedFrom?: Location
): Route {
  ...
}

接下來就是 match 方法,它接收 3 個參數,其中 rawRawLocation 類型,它能夠是一個 url 字符串,也能夠是一個 Location 對象;currentRouteRoute 類型,它表示當前的路徑;redirectedFrom 和重定向相關。
match 方法返回的是一個路徑,它的做用是根據傳入的 raw 和當前的路徑 currentRoute 計算出一個新的路徑並返回。至於他是如何計算出這條路徑的,能夠詳細看一下如何計算出locationnormalizeLocation 方法和 _createRoute 方法。

小結
  • createMatcher: 根據路由的配置描述創建映射表,包括路徑、名稱到路由 record 的映射關係, 最重要的就是 createRouteMap 這個方法,這裏也是動態路由匹配和嵌套路由的原理。
  • addRoutes: 動態添加路由配置
  • match: 根據傳入的 raw 和當前的路徑 currentRoute 計算出一個新的路徑並返回。

路由模式

vue-router 支持三種路由模式(mode):hashhistoryabstract,其中 abstract 是在非瀏覽器環境下使用的路由模式源碼地址

這一部分在前面初始化 vueRouter 對象時提到過,首先拿到配置項的模式,而後根據當前傳入的配置判斷當前瀏覽器是否支持這種模式,默認 ie9 如下會降級爲 hash。 而後根據不一樣的模式去初始化不一樣的 history 實例。

// 通常分兩種模式 hash 和 history 路由 第三種是抽象模式不經常使用
    let mode = options.mode || 'hash'
    // 判斷當前傳入的配置是否能使用 history 模式
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    // 降級處理
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode
    // 根據模式實例化不一樣的 history history 對象會對路由進行管理 繼承於 history class
    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
小結

vue-router 支持三種路由模式,hashhistoryabstract。默認爲 hash ,若是當前瀏覽器不支持history則會作降級處理,而後完成 history 的初始化。

路由切換


切換 url 主要是調用了 push 方法,下面以哈希模式爲例,分析push方法實現的原理 。push 方法切換路由的實現原理 源碼地址

首先在 src/index.js 下找到 vueRouter 定義的 push 方法

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // $flow-disable-line
    if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
      return new Promise((resolve, reject) => {
        this.history.push(location, resolve, reject)
      })
    } else {
      this.history.push(location, onComplete, onAbort)
    }
  }

接着咱們須要定位到 history/hash.js。這裏首先獲取到當前路徑而後調用了 transitionTo 作路徑切換,在回調函數當中執行 pushHash 這個核心方法。

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    // 路徑切換的回調函數中調用 pushHash
    this.transitionTo(
      location,
      route => {
        pushHash(route.fullPath)
        handleScroll(this.router, route, fromRoute, false)
        onComplete && onComplete(route)
      },
      onAbort
    )
  }

pushHash 方法在作完瀏覽器兼容判斷後調用的 pushState 方法,將 url 傳入

export function pushState (url?: string, replace?: boolean) {
  const history = window.history
  try {
   // 調用瀏覽器原生的 history 的 pushState 接口或者 replaceState 接口,pushState 方法會將 url 入棧
    if (replace) {
      history.replaceState({ key: _key }, '', url)
    } else {
      _key = genKey()
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

能夠發現,push 底層調用了瀏覽器原生的 historypushStatereplaceState 方法,不是 replace 模式 會將 url 推歷史棧當中。

另外提一嘴拼接哈希的原理

源碼位置

初始化 HashHistory 時,構造函數會執行 ensureSlash 這個方法

export class HashHistory extends History {
  constructor (router: Router, base: ?string, fallback: boolean) {
    ...
    ensureSlash()
  }
  ...
  }

這個方法首先調用 getHash,而後執行 replaceHash()

function ensureSlash (): boolean {
  const path = getHash()
  if (path.charAt(0) === '/') {
    return true
  }
  replaceHash('/' + path)
  return false
}

下面是這幾個方法

export function getHash (): string {
  const href = window.location.href
  const index = href.indexOf('#')
  return index === -1 ? '' : href.slice(index + 1)
}
// 真正拼接哈希的方法 
function getUrl (path) {
  const href = window.location.href
  const i = href.indexOf('#')
  const base = i >= 0 ? href.slice(0, i) : href
  return `${base}#${path}`
}
function replaceHash (path) {
  if (supportsPushState) {
    replaceState(getUrl(path))
  } else {
    window.location.replace(getUrl(path))
  }
}
export function replaceState (url?: string) {
  pushState(url, true)
}

舉個例子來講: 假設當前URL是 http://localhost:8080,path 爲空,執行 replcaeHash('/' + path),而後內部執行 getUrl 計算出 urlhttp://localhost:8080/#/,最後執行 pushState(url,true),就大功告成了!

小結

hash 模式的 push 方法會調用路徑切換方法 transitionTo,接着在回調函數中調用pushHash方法,這個方法調用的 pushState 方法底層是調用了瀏覽器原生 history 的方法。pushreplace 的區別就在於一個將 url 推入了歷史棧,一個沒有,最直觀的體現就是 replace 模式下瀏覽器點擊後退不會回到上一個路由去 ,另外一個則能夠。

router-view & router-link

vue-routerinstall 時全局註冊了兩個組件一個是 router-view 一個是 router-link,這兩個組件都是典型的函數式組件。源碼地址

router-view

首先在 router 組件執行 beforeCreate 這個鉤子時,把 this._route 轉爲了響應式的一個對象

Vue.util.defineReactive(this, '_route', this._router.history.current)

因此說每次路由切換都會觸發 router-view 從新 render 從而渲染出新的視圖。

核心的 render 函數做用請看代碼註釋

render (_, { props, children, parent, data }) {
    ...
    // 經過 depth 由 router-view 組件向上遍歷直到根組件,遇到其餘的 router-view 組件則路由深度+1 這裏的 depth 最直接的做用就是幫助找到對應的 record
    let depth = 0
    let inactive = false
    while (parent && parent._routerRoot !== parent) {
      // parent.$vnode.data.routerView 爲 true 則表明向上尋找的組件也存在嵌套的 router-view 
      if (parent.$vnode && parent.$vnode.data.routerView) {
        depth++
      }
      if (parent._inactive) {
        inactive = true
      }
      parent = parent.$parent
    }
    data.routerViewDepth = depth
    if (inactive) {
      return h(cache[name], data, children)
    }
   // 經過 matched 記錄尋找出對應的 RouteRecord 
    const matched = route.matched[depth]
    if (!matched) {
      cache[name] = null
      return h()
    }
 // 經過 RouteRecord 找到 component
    const component = cache[name] = matched.components[name]
   // 往父組件註冊 registerRouteInstance 方法
    data.registerRouteInstance = (vm, val) => {     
      const current = matched.instances[name]
      if (
        (val && current !== vm) ||
        (!val && current === vm)
      ) {
        matched.instances[name] = val
      }
    }
  // 渲染組件
    return h(component, data, children)
  }

觸發更新也就是 setter 的調用,位於 src/index.js,當修改 _route 就會觸發更新。

history.listen(route => {
  this.apps.forEach((app) => {
    // 觸發 setter
    app._route = route
  })
})
router-link

分析幾個重要的部分:

  • 設置 active 路由樣式

router-link 之因此能夠添加 router-link-activerouter-link-exact-active 這兩個 class 去修改樣式,是由於在執行 render 函數時,會根據當前的路由狀態,給渲染出來的 active 元素添加 class

render (h: Function) {
  ...
  const globalActiveClass = router.options.linkActiveClass
  const globalExactActiveClass = router.options.linkExactActiveClass
  // Support global empty active class
  const activeClassFallback = globalActiveClass == null
    ? 'router-link-active'
    : globalActiveClass
  const exactActiveClassFallback = globalExactActiveClass == null
    ? 'router-link-exact-active'
    : globalExactActiveClass
    ...
}
  • router-link 默認渲染爲 a 標籤,若是不是會去向上查找出第一個 a 標籤
if (this.tag === 'a') {
      data.on = on
      data.attrs = { href }
    } else {
      // find the first <a> child and apply listener and href
      const a = findAnchor(this.$slots.default)
      if (a) {
        // in case the <a> is a static node
        a.isStatic = false
        const aData = (a.data = extend({}, a.data))
        aData.on = on
        const aAttrs = (a.data.attrs = extend({}, a.data.attrs))
        aAttrs.href = href
      } else {
        // 不存在則渲染自己元素
        data.on = on
      }
    }
  • 切換路由,觸發相應事件
const handler = e => {
  if (guardEvent(e)) {
    if (this.replace) {
      // replace路由
      router.replace(location)
    } else {
      // push 路由
      router.push(location)
    }
  }
}

權限控制動態路由原理分析

我相信,開發事後臺項目的同窗常常會碰到如下的場景: 一個系統分爲不一樣的角色,而後不一樣的角色對應不一樣的操做菜單和操做權限。例如: 教師能夠查詢教師本身的我的信息查詢而後還能夠查詢操做學生的信息和學生的成績系統、學生用戶只容許查詢我的成績和信息,不容許更改。在 vue2.2.0 以前尚未加入 addRoutes 這個 API 是十分困難的的。

目前主流的路由權限控制的方式是:

  1. 登陸時獲取 token 保存到本地,接着前端會攜帶 token 再調用獲取用戶信息的接口獲取當前用戶的角色信息。
  2. 前端再根據當前的角色計算出相應的路由表拼接到常規路由表後面。

登陸生成動態路由全過程

瞭解 如何控制動態路由以後,下面是一張全過程流程圖

前端在 beforeEach 中判斷:

  • 緩存中存在 JWT 令牌

    • 訪問/login: 重定向到首頁 /
    • 訪問/login之外的路由: 首次訪問,獲取用戶角色信息,而後生成動態路由,而後訪問以 replace 模式訪問 /xxx 路由。這種模式用戶在登陸以後不會在 history 存放記錄
  • 不存在 JWT 令牌

    • 路由在白名單中: 正常訪問 /xxx 路由
    • 不在白名單中: 重定向到 /login 頁面

結合框架源碼分析

下面結合 vue-element-admin 的源碼分析該框架中如何處理路由邏輯的。

路由訪問邏輯分析

首先能夠定位到和入口文件 main.js 同級的 permission.js, 全局路由守衛處理就在此。源碼地址

const whiteList = ['/login', '/register'] // 路由白名單,不會重定向
// 全局路由守衛
router.beforeEach(async(to, from, next) => {
  NProgress.start() //路由加載進度條
  // 設置 meta 標題
  document.title = getPageTitle(to.meta.title)
  // 判斷 token 是否存在
  const hasToken = getToken()
  if (hasToken) {
    if (to.path === '/login') {
      // 有 token 跳轉首頁
      next({ path: '/' })
      NProgress.done()
    } else {
      const hasRoles = store.getters.roles && store.getters.roles.length > 0
      if (hasRoles) {
        next()
      } else {
        try {
          // 獲取動態路由,添加到路由表中
          const { roles } = await store.dispatch('user/getInfo')
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
          router.addRoutes(accessRoutes)
          //  使用 replace 訪問路由,不會在 history 中留下記錄,登陸到 dashbord 時回退空白頁面
          next({ ...to, replace: true })
        } catch (error) {
          next('/login')
          NProgress.done()
        }
      }
    }
  } else {
    // 無 token
    // 白名單不用重定向 直接訪問
    if (whiteList.indexOf(to.path) !== -1) {
      next()
    } else {
      // 攜帶參數爲重定向到前往的路徑
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

這裏的代碼我都添加了註釋方便你們好去理解,總結爲一句話就是訪問路由 /xxx,首先須要校驗 token 是否存在,若是有就判斷是否訪問的是登陸路由,走的不是登陸路由則須要判斷該用戶是不是第一訪問首頁,而後生成動態路由,若是走的是登陸路由則直接定位到首頁,若是沒有 token 就去檢查路由是否在白名單(任何狀況都能訪問的路由),在的話就訪問,不然重定向回登陸頁面。

下面是通過全局守衛後路由變化的截圖

結合Vuex生成動態路由

下面就是分析這一步 const accessRoutes = await store.dispatch('permission/generateRoutes', roles) 是怎麼把路由生成出來的。源碼地址

首先 vue-element-admin 中路由是分爲兩種的:

  • constantRoutes: 不須要權限判斷的路由
  • asyncRoutes: 須要動態判斷權限的路由
// 無需校驗身份路由
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login/index'),
    hidden: true
  }
  ...
  ],
 // 須要校驗身份路由 
export const asyncRoutes = [
  // 學生角色路由
  {
    path: '/student',
    name: 'student',
    component: Layout,
    meta: { title: '學生信息查詢', icon: 'documentation', roles: ['student'] },
    children: [
      {
        path: 'info',
        component: () => import('@/views/student/info'),
        name: 'studentInfo',
        meta: { title: '信息查詢', icon: 'form' }
      },
      {
        path: 'score',
        component: () => import('@/views/student/score'),
        name: 'studentScore',
        meta: { title: '成績查詢', icon: 'score' }
      }
    ]
  }]
  ...

生成動態路由的源碼位於 src/store/modules/permission.js 中的 generateRoutes 方法,源碼以下:

generateRoutes({ commit }, roles) {
    return new Promise(resolve => {
      let accessedRoutes
      if (roles.includes('admin')) {
        accessedRoutes = asyncRoutes || []
      } else {
      // 不是 admin 去遍歷生成對應的權限路由表
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      }
      // vuex 中保存異步路由和常規路由
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }

route.js 讀取 asyncRoutesconstantRoutes 以後首先判斷當前角色是不是 admin,是的話默認超級管理員可以訪問全部的路由,固然這裏也能夠自定義,不然去過濾出路由權限路由表,而後保存到 Vuex 中。 最後將過濾以後的 asyncRoutesconstantRoutes 進行合併。
過濾權限路由的源碼以下:

export function filterAsyncRoutes(routes, roles) {
  const res = []
  routes.forEach(route => {
    // 淺拷貝
    const tmp = { ...route }
    // 過濾出權限路由
    if (hasPermission(roles, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
      res.push(tmp)
    }
  })
  return res
}

首先定義一個空數組,對傳入 asyncRoutes 進行遍歷,判斷每一個路由是否具備權限,未命中的權限路由直接捨棄
判斷權限方法以下:

function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
    // roles 有對應路由元定義的 role 就返回 true
    return roles.some(role => route.meta.roles.includes(role))
  } else {
    return true
  }
}

接着須要判斷二級路由、三級路由等等的狀況,再作一層迭代處理,最後將過濾出來的路由推動數組返回。而後追加到 constantRoutes 後面

SET_ROUTES: (state, routes) => {
    state.addRoutes = routes
    state.routes = constantRoutes.concat(routes)
  }

動態路由生成全過程

總結

  • vue-router 源碼分析部分

    • 註冊: 執行 install 方法,注入生命週期鉤子初始化
    • vueRouter: 當組件執行 beforeCreate 傳入 router 實例時,執行 init 函數,而後執行 history.transitionTo 路由過渡
    • matcher : 根據傳入的 routes 配置建立對應的 pathMapnameMap ,能夠根據傳入的位置和路徑計算出新的位置並匹配對應的 record
    • 路由模式: 路由模式在初始化 vueRouter 時完成匹配,若是瀏覽器不支持則會降級
    • 路由 切換: 哈希模式下底層使用了瀏覽器原生的 pushStatereplaceState 方法
    • router-view: 調用父組件上存儲的 $route.match 控制路由對應的組件的渲染狀況,而且支持嵌套。
    • router-link: 經過 to 來決定點擊事件跳轉的目標路由組件,而且支持渲染成不一樣的 tag,還能夠修改激活路由的樣式。
  • 權限控制動態路由部分

    • 路由邏輯: 全局路由攔截,從緩存中獲取令牌,存在的話若是首次進入路由須要獲取用戶信息,生成動態路由,這裏須要處理 /login 特殊狀況,不存在則判斷白名單而後走對應的邏輯
    • 動態生成路由: 傳入須要 router.js 定義的兩種路由。判斷當前身份是不是管理員,是則直接拼接,不然須要過濾出具有權限的路由,最後拼接到常規路由後面,經過 addRoutes 追加。

讀後感想

或許閱讀源碼的做用不能像一篇開發文檔同樣直接立馬對平常開發有所幫助,可是它的影響是長遠的,在讀源碼的過程當中均可以學到衆多知識,相似閉包、設計模式、時間循環、回調等等 JS 進階技能,並穩固並提高了你的 JS 基礎。固然這篇文章是有缺陷的,有幾個地方都沒有分析到,好比導航守衛實現原理和路由懶加載實現原理,這一部分,我還在摸索當中。

若是一味的死記硬背一些所謂的面經,或者直接死記硬背相關的框架行爲或者 API ,你很難在遇到比較複雜的問題下面去快速定位問題,瞭解怎麼去解決問題,並且我發現不少人在使用一個新框架以後遇到點問題都會立馬去提對應的 Issues,以致於不少流行框架 Issues 超過幾百個或者幾千個,可是許多問題都是由於咱們並未按照設計者開發初設定的方向才致使錯誤的,更多都是些粗枝大葉形成的問題。

參考文章

帶你全面分析vue-router源碼 (萬字長文)

vuejs 源碼解析

招賢納士

政採雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政採雲產品研發部,Base 在風景如畫的杭州。團隊現有 40 餘個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在平常的業務對接以外,還在物料體系、工程平臺、搭建平臺、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推進並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

若是你想改變一直被事折騰,但願開始能折騰事;若是你想改變一直被告誡須要多些想法,卻無從破局;若是你想改變你有能力去作成那個結果,卻不須要你;若是你想改變你想作成的事須要一個團隊去支撐,但沒你帶人的位置;若是你想改變既定的節奏,將會是「5 年工做時間 3 年工做經驗」;若是你想改變原本悟性不錯,但老是有那一層窗戶紙的模糊… 若是你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的本身。若是你但願參與到隨着業務騰飛的過程,親手推進一個有着深刻的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我以爲咱們該聊聊。任什麼時候間,等着你寫點什麼,發給 ZooTeam@cai-inc.com

相關文章
相關標籤/搜索