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

https://juejin.im/post/5e456513f265da573c0c6d4bjavascript


前言

在前一篇文章——聊一聊實現Vue路由組件緩存遇到的’坑‘中遇到的vue路由組件緩存問題已經解決並對項目進行了適當的優化改進,可是並未開探尋究vue-router的源碼,這篇就繼續上次的話題,深刻分析vue-router源碼中對導航守衛、動態參數匹配、過渡效果和異步組件等的實現。html

本文分析的源碼爲vue-router@3.1.3vue@2.6.11vue

萬丈高樓平地起

參考官網的起步中的使用實例,vue-router的基本思路是根據路由記錄生成VueRouter實例並傳入Vue的app實例的router屬性上,同時使用router-view來掛載路由匹配的路由組件到頁面某一位置。java

const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }

const routes = [
  { path: '/foo', component: Foo },
  { path: '/bar', component: Bar }
]

const router = new VueRouter({
  routes // (縮寫) 至關於 routes: routes
})

const app = new Vue({
  router
}).$mount('#app')
複製代碼

使用流程

image.png

這裏vue-router的設計哲學與react-routerV4不太同樣,前者是以路由配置來統一配置路由,後者是路由即組件的概念(不須要統一的路由配置,不過自行封裝成路由配置)node

核心特性

image.png

以上這些是vue-router提供的核心功能,完整的用法說明能夠參考官方文檔,下面將逐步分析vue-router源碼中是如何實現以上這些核心功能的。react

閱讀源碼的前置條件

源碼目錄結構

image.png

源碼結構算是秉承着vue系列的一目瞭然的特性,主要分爲組件link和view、維護路由的history、vue插件的註冊方法install.js、模塊導出文件index.jswebpack

基礎概念——路由實例router

路由實例router是在使用vue-router的時候經過傳入路由記錄等配置而生成的實例對象,重點在於其VueRouter類的實現。git

image.png

init方法的實現

這裏的init方法與install.js中註冊的全局mixin關聯最大,是vue組件在create時執行的初始化路由方法,須要重點分析一下。github

init (app: any /* Vue component instance */) {
  this.apps.push(app)

  app.$once('hook:destroyed', () => {
    const index = this.apps.indexOf(app)
    if (index > -1) this.apps.splice(index, 1)
    if (this.app === app) this.app = this.apps[0] || null
  })

  if (this.app) {
    return
  }

  this.app = app

  const history = this.history

  if (history instanceof HTML5History) {
    history.transitionTo(history.getCurrentLocation())
  } else if (history instanceof HashHistory) {
    const setupHashListener = () => {
      history.setupListeners()
    }
    history.transitionTo(
      history.getCurrentLocation(),
      setupHashListener,
      setupHashListener
    )
  }

  history.listen(route => {
    this.apps.forEach((app) => {
      app._route = route
    })
  })
}
複製代碼

源碼:L83web

這裏的app是vue組件的實例,經過 app.$once('hook:destroyed', () => {} 聲明式地註冊組件destroyed生命週期鉤子,保證對應組件銷燬時組件app實例從router.apps上移除。

保證路由初僅始化一次:因爲init是被全局註冊的mixin調用,此處經過this.app是否存在的判斷邏輯保證路由初始化僅僅在根組件 <App /> 上初始化一次,this.app最後保存的根據組件實例。

觸發路由變化&開始路由監聽:使用 history.transitionTo 分路由模式觸發路由變化,使用 history.listen 監聽路由變化來更新根組件實例 app._route 是當前跳轉的路由。

基礎概念——路由匹配器matcher

路由匹配器macther是由create-matcher生成一個對象,其將傳入VueRouter類的路由記錄進行內部轉換,對外提供根據location匹配路由方法——match、註冊路由方法——addRoutes。

  • match方法:根據內部的路由映射匹配location對應的路由對象route
  • addRoutes方法:將路由記錄添加到matcher實例的路由映射中

生成matcher

// src/index.js
constructor (options: RouterOptions = {}) {
    ...
    this.matcher = createMatcher(options.routes || [], this)
  	...
}
複製代碼

源碼:L42

options.routes爲進行 new VueRoute 操做是傳入的路由記錄

createMatcher內部

createMatcher來自於import { createMatcher } from './create-matcher', 內部進行路由地址到路由對象的轉換、路由記錄的映射、路由參數處理等操做

// src/create-matcher.js
export function createMatcher ( routes: Array<RouteConfig>, router: VueRouter ): Matcher {
  ...
  function addRoutes (routes) {
    createRouteMap(routes, pathList, pathMap, nameMap)
  }
  function match ( raw: RawLocation, currentRoute?: Route, redirectedFrom?: Location ): Route {
    ...
  }
  function _createRoute ( record: ?RouteRecord, location: Location, redirectedFrom?: Location ): Route {
    ...
  }
    
  return {
    match,
    addRoutes
  }
}
複製代碼

源碼:L16

createRoute:將外部傳入的路由記錄轉換成統一的route對象,傳入組件實例的$route就是此處返回的 alias:處理路由別名 nameMap:處理命名路由 路由參數解析:解析路由location.params、query參數、hash等,動態路由匹配正是來自此處

動態路由匹配&嵌套路由

動態路由匹配

動態路由匹配指的是能夠在路徑中設置多段參數,參數將會被設置到 $route.params 上,例如:

模式 匹配路徑 $route.params
/user/:username /user/evan { username: 'evan' }
/user/:username/post/:post_id /user/evan/post/123 { username: 'evan', post_id: '123' }

參考:官網例子

嵌套路由

嵌套路由指的是路由能夠像組件同樣具備嵌套關係,一條路由記錄下能夠經過 children 屬性嵌套由多個子路由記錄組成的數組,例如:

const router = new VueRouter({
  routes: [
    { path: '/user/:id', component: User,
      children: [
        {
          // 當 /user/:id/profile 匹配成功,
          // UserProfile 會被渲染在 User 的 <router-view> 中
          path: 'profile',
          component: UserProfile
        },
        {
          // 當 /user/:id/posts 匹配成功
          // UserPosts 會被渲染在 User 的 <router-view> 中
          path: 'posts',
          component: UserPosts
        }
      ]
    }
  ]
})
複製代碼

參考:官網例子

在項目中只要使用vue-router,幾乎不可避免要使用到動態路由匹配和嵌套路由,可見兩個功能在vue-router是何等重要,在研究其源碼時這兩個功能確定是要研究的,下面將探究上述功能在vue-router是如何實現的。

主要實現思路

要實現動態路由匹配主要是要實現路由記錄的path屬性與實際的路由路徑的參數進行匹配,而要實現嵌套路由則須要根據嵌套規則對路由記錄解析,這兩個都在create-route-map進行實現,實現的思路以下:

image.png

create-route-map中的核心代碼以下:

export function createRouteMap ( routes: Array<RouteConfig>, oldPathList?: Array<string>, oldPathMap?: Dictionary<RouteRecord>, oldNameMap?: Dictionary<RouteRecord> ): {
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>
} {
 	...
 	routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })
  ...
  /** * TODO: * 處理路由的優先級循序:將路由記錄中的通配符*表示的路由按循序移動到路由記錄末尾 * 採用的哪一種排序算法? */
  for (let i = 0, l = pathList.length; i < l; i++) {
    if (pathList[i] === '*') {
      pathList.push(pathList.splice(i, 1)[0])
      l--
      i--
    }
  }
  ...
  /** * TODO: * 路由記錄,將路由全部的路由記錄映射到pathMap、nameMap中,pathMap:按路徑映射,nameMap:按名稱映射,pathList全部路由path組成的數組 * 處理嵌套路由:遞歸調用此方法,parent表示父級路由 * 處理路由別名:把路徑別名當作是指向同一個組件的路由記錄,由此方法處理一遍這個別名組成的路由 * 處理路由名稱:若存在路由名稱,則將該路由映射到nameMap中存儲 */
  function addRouteRecord ( pathList: Array<string>, pathMap: Dictionary<RouteRecord>, nameMap: Dictionary<RouteRecord>, route: RouteConfig, parent?: RouteRecord, matchAs?: string ) {
    ...
  }
  ...
  return {
    pathList,
    pathMap,
    nameMap
  }
}
複製代碼

源碼:L7

createRouteMap方法主要是遍歷路由配置routes,調用 addRouteRecord 方法來處理路由,處理完路由後獲得 pathList pathMap nameMap ,並將其組成對象並返回。

動態路由匹配的實現

addRouteRecord 方法處理路由的實現中由將 route.path 使用path-to-regexp轉換成正則表達式, record 是處理完成後保存在 pahtMap nameMap 映射中的值。

const record: RouteRecord = {
	...
  regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
  ...
}
...
/** * TODO: * 調用path-to-regexp生成路由匹配用的正則 */
function compileRouteRegex ( path: string, pathToRegexpOptions: PathToRegexpOptions ): RouteRegExp {
  const regex = Regexp(path, [], pathToRegexpOptions)
  if (process.env.NODE_ENV !== 'production') {
    const keys: any = Object.create(null)
    regex.keys.forEach(key => {
      warn(
        !keys[key.name],
        `Duplicate param keys in route with path: "${path}"`
      )
      keys[key.name] = true
    })
  }
  return regex
}
複製代碼

源碼:L178

而後在create-matcher提供的match方法中根據 route.name 、 route.path 進行路由匹配,匹配的時候回調用上述獲得的正則表達式進行路由匹配及參數解析,從而獲得路徑或者路由名稱匹配的路由以及動態參數。

嵌套路由的實現

addRouteRecord 方法實現嵌套路由部分的源碼以下:

export function createRouteMap ( routes: Array<RouteConfig>, oldPathList?: Array<string>, oldPathMap?: Dictionary<RouteRecord>, oldNameMap?: Dictionary<RouteRecord> ): {
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>
} {
 	...
  if (route.children) {
    route.children.forEach(child => {
      const childMatchAs = matchAs
        ? cleanPath(`${matchAs}/${child.path}`)
        : undefined
      addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
    })
  }
  ...
}
複製代碼

源碼:L102

路由記錄的children表示當前路由下嵌套的路由記錄,當其存在時遞歸處理路由;處理子路由時會拼接完整的路由path放入 pathMap nameMap 。

故不管是否嵌套路由與否,最後都是進行評級的路由映射,統一路由match方法進行路由匹配。

導航守衛機制

導航守衛給給路由使用者傳入自定義的控制路由跳轉邏輯的鉤子方法,經過 next 方法串行執行下一個路由的匹配邏輯,根據定義導航守衛的位置不一樣能夠將其分爲三類:全局導航守衛、路由獨享守衛、組件內部導航守衛

image.png

導航守衛的註冊

用註冊導航守衛方法或導航守衛配置會被註冊到執行隊列中,在路由跳轉時根據根據路由配置映射計算出組件實例的更新、替換、重用等狀況,而後在對應組件上遍歷執行導航守衛隊列。

註冊全局導航守衛

全局導航守衛分爲:全局前置導航守衛、全局解析守衛、全局後置守衛,分別經過 router.beforeEach router.beforeResolve router.afterEach 進行註冊。

// src/index.js
beforeEach (fn: Function): Function {
  return registerHook(this.beforeHooks, fn)
}

beforeResolve (fn: Function): Function {
  return registerHook(this.resolveHooks, fn)
}

afterEach (fn: Function): Function {
  return registerHook(this.afterHooks, fn)
}
複製代碼

源碼:L133

註冊全局導航守衛是調用 registerHook 方法向鉤子函數隊列中推入鉤子函數,同時返回刪除鉤子函數的方法。

這是常見隊列入棧出棧用法,vue源碼中很常見的一種用法

registerHook方法源碼以下:

// src/index.js
function registerHook (list: Array<any>, fn: Function): Function {
  list.push(fn)
  return () => {
    const i = list.indexOf(fn)
    if (i > -1) list.splice(i, 1)
  }
}
複製代碼

註冊路由獨享守衛

路由獨享守衛是以路由配置的形式進行註冊,例如:

const router = new VueRouter({
  routes: [
    {
      path: '/foo',
      component: Foo,
      beforeEnter: (to, from, next) => {
        // ...
      }
    }
  ]
})
複製代碼

註冊組件內部守衛

組件內部守衛是經過配置組件的導航守衛屬性進行註冊,例如:

const Foo = {
  template: `...`,
  beforeRouteEnter (to, from, next) {
    // 在渲染該組件的對應路由被 confirm 前調用
    // 不!能!獲取組件實例 `this`
    // 由於當守衛執行前,組件實例還沒被建立
  },
  beforeRouteUpdate (to, from, next) {
    // 在當前路由改變,可是該組件被複用時調用
    // 舉例來講,對於一個帶有動態參數的路徑 /foo/:id,在 /foo/1 和 /foo/2 之間跳轉的時候,
    // 因爲會渲染一樣的 Foo 組件,所以組件實例會被複用。而這個鉤子就會在這個狀況下被調用。
    // 能夠訪問組件實例 `this`
  },
  beforeRouteLeave (to, from, next) {
    // 導航離開該組件的對應路由時調用
    // 能夠訪問組件實例 `this`
  }
}
複製代碼

導航守衛的解析流程

將上面在全局註冊、路由配置註冊、組件內部註冊的導航守衛解析出來,按照導航鉤子解析順序推動隊列中

const queue: Array<?NavigationGuard> = [].concat(
  // in-component leave guards
  extractLeaveGuards(deactivated),  // 失效組件的beforeRouterLeave
  // global before hooks
  this.router.beforeHooks,					// 全局的前置鉤子beforeEach
  // in-component update hooks
  extractUpdateHooks(updated),			// 重用的組件beforeRouteUpdate
  // in-config enter guards
  activated.map(m => m.beforeEnter),// 路由配置的beforeRouteEnter
  // async components
  resolveAsyncComponents(activated) // 路由配置中異步組件的加載解析
)
複製代碼

源碼:L133

導航鉤子的解析流程

image.png

導航鉤子解析對應的源碼

// 執行前置守衛
runQueue(queue, iterator, () => {
  const postEnterCbs = []
  const isValid = () => this.current === route
  const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
  const queue = enterGuards.concat(this.router.resolveHooks)
  // 執行解析守衛
  runQueue(queue, iterator, () => {
    if (this.pending !== route) {
      return abort()
    }
    this.pending = null
    onComplete(route)
    if (this.router.app) {
      this.router.app.$nextTick(() => {
        // 執行後置守衛
        postEnterCbs.forEach(cb => {
          cb()
        })
      })
    }
  })
})
複製代碼

源碼:L179

路由懶加載

路由懶加載是以路由爲基礎單位對頁面代碼進行分包,在匹配到對應路由時候在異步下載對應路由組件的代碼,以vue-cli新建的項目能夠直接使用webpack 的 code-splitting 功能,一個結合vue異步組件+ES新語法的路由懶加載的例子以下:

vue VueRouter({
	routes: [
    {
     	path: '/foot',
      component: () => import('./my-async-component')
    }
  ]
})
複製代碼

異步組件的加載須要關注其加載狀態,在vue-router中的異步組件的加載狀態只有loading、error等,而在vue-router是從新實現的組件懶加載實現了更細緻的組件加載狀態控制、路由解析控制。

vue-router的異步組件解析在導航守衛隊列的解析流程裏面,其中重點的解析異步組件的方法源碼以下:

// util/resolve-components.js
/** * TODO: * 異步路由解析:重寫異步組件的resolve、reject方法,添加了組件加載狀態控制、路由解析控制;對異步組件的傳統寫法及promise寫進行兼容 */
export function resolveAsyncComponents (matched: Array<RouteRecord>): Function {
  return (to, from, next) => {
    let hasAsync = false
    let pending = 0
    let error = null

    flatMapComponents(matched, (def, _, match, key) => {
      if (typeof def === 'function' && def.cid === undefined) {
        hasAsync = true
        pending++
        // 重寫vue異步組件的resolve和reject方法
        const resolve = once(resolvedDef => {
          if (isESModule(resolvedDef)) {
            resolvedDef = resolvedDef.default
          }
          // save resolved on async factory in case it's used elsewhere
          def.resolved = typeof resolvedDef === 'function'
            ? resolvedDef
            : _Vue.extend(resolvedDef)
          match.components[key] = resolvedDef
          pending--
          if (pending <= 0) {
            next()
          }
        })

        const reject = once(reason => {
          const msg = `Failed to resolve async component ${key}: ${reason}`
          process.env.NODE_ENV !== 'production' && warn(false, msg)
          if (!error) {
            error = isError(reason)
              ? reason
              : new Error(msg)
            next(error)
          }
        })

        let res
        try {
          res = def(resolve, reject)
        } catch (e) {
          reject(e)
        }
        // 兼容異步組件的promise寫法
        if (res) {
          if (typeof res.then === 'function') {
            res.then(resolve, reject)
          } else {
            // new syntax in Vue 2.3
            const comp = res.component
            if (comp && typeof comp.then === 'function') {
              comp.then(resolve, reject)
            }
          }
        }
      }
    })
  }
}
複製代碼

源碼:L6

重寫了vue異步組件加載的resolve和reject方法來實現對路由解析是否進入下一個匹配的控制,加入了路由匹配的組件解析失敗的異常處理,同時還對異步組件的promise寫法也進行了兼容。

router-view組件

router-view是vue-router提供的兩個核心組件之一,它是一個函數式組件不存在本身的組件實例,只負責調用父組件上存儲的 keepAlive $route.match 等相關的屬性/方法來控制路由對應的組件的渲染狀況。

router-view組件能夠嵌套來配合實現嵌套路由,其自身所在的頁面位置最終是其匹配上的路由組件所掛載的位置。

其源碼render部分的核心源碼以下:

render (_, { props, children, parent, data }) {
  // 標識當前組件是router-view
  data.routerView = true
  const h = parent.$createElement
  const name = props.name
  const route = parent.$route
  const cache = parent._routerViewCache || (parent._routerViewCache = {})
  
  let depth = 0
  let inactive = false
  // 由router-view組件向上遍歷直到跟組件,遇到其餘的router-view組件則路由深度+1
  // vnodeData.keepAlivepj
  while (parent && parent._routerRoot !== parent) {
    const vnodeData = parent.$vnode ? parent.$vnode.data : {}
    if (vnodeData.routerView) {
      depth++
    }
    if (vnodeData.keepAlive && parent._directInactive && parent._inactive) {
      inactive = true
    }
    parent = parent.$parent
  }
  data.routerViewDepth = depth

	// 啓用緩存時
  if (inactive) {
    const cachedData = cache[name]
    const cachedComponent = cachedData && cachedData.component
    if (cachedComponent) {
      if (cachedData.configProps) {
        fillPropsinData(cachedComponent, data, cachedData.route, cachedData.configProps)
      }
      return h(cachedComponent, data, children)
    } else {
      return h()
    }
  }

  const matched = route.matched[depth]
  const component = matched && matched.components[name]
  
  if (!matched || !component) {
    cache[name] = null
    return h()
  }

  cache[name] = { component }

  // 往父組件註冊registerRouteInstance方法
  data.registerRouteInstance = (vm, val) => {
    // val could be undefined for unregistration
    const current = matched.instances[name]
    if (
      (val && current !== vm) ||
      (!val && current === vm)
    ) {
      matched.instances[name] = val
    }
  }
	...
  return h(component, data, children)
}
複製代碼

源碼:L13

路由緩存的判斷

parent表示router-view組件的直接父級組件實例,從當router-view往外層組件遍歷,遇到router-view則說明存在嵌套路由,路由深度+1,同時若知足條件則表示路由啓用了緩存;

即以下結構會使用路由緩存

<keep-alive>
  <router-view></router-view>
</keep-alive>
複製代碼

緩存的路由組件實例存在父級組件實例上,若啓用了路由緩存則用父級緩存的已匹配的路由組件進行渲染,無則用 $route.match 來匹配 matcher 中匹配上的路由進行渲染。

**parent._inactive**由vue核心模塊的observer/scheduler調度器更新 **parent._directInactive**由vue核心模塊的instance/lifecycle更新,二者都是用於標識當前組件是否處於active狀態,具體區別可參考這個issue#1212

router-link組件

router-link是vue-router提供的兩個核心組件之一,它是一個普通組件,內部取消了a標籤的默認跳轉行爲,並控制了組件與controlmeta等按鍵同時存在的兼容性問題,提供了當前激活路由匹配時的樣式類;

經過 to 來決定點擊事件跳轉的目標路由,經過 append replace等屬性改變默認路由跳轉的行爲。

經過slot分發內容

const scopedSlot =
  !this.$scopedSlots.$hasNormal &&
  this.$scopedSlots.default &&
  this.$scopedSlots.default({
    href,
    route,
    navigate: handler,
    isActive: classes[activeClass],
    isExactActive: classes[exactActiveClass]
  })

if (scopedSlot) {
  if (scopedSlot.length === 1) {
    return scopedSlot[0]
  } else if (scopedSlot.length > 1 || !scopedSlot.length) {
    if (process.env.NODE_ENV !== 'production') {
      warn(
        false,
        `RouterLink with to="${ this.to }" is trying to use a scoped slot but it didn't provide exactly one child. Wrapping the content with a span element.`
      )
    }
    return scopedSlot.length === 0 ? h() : h('span', {}, scopedSlot)
  }
}
複製代碼

源碼:L91

統一處理點擊事件兼容性

function guardEvent (e) {
  // don't redirect with control keys
  if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
  // don't redirect when preventDefault called
  if (e.defaultPrevented) return
  // don't redirect on right click
  if (e.button !== undefined && e.button !== 0) return
  // don't redirect if `target="_blank"`
  if (e.currentTarget && e.currentTarget.getAttribute) {
    const target = e.currentTarget.getAttribute('target')
    if (/\b_blank\b/i.test(target)) return
  }
  // this may be a Weex event which doesn't have this method
  if (e.preventDefault) {
    e.preventDefault()
  }
  return true
}
複製代碼

源碼:L158

查找渲染的a標籤

遞歸查找children中的a標籤做爲組件默認插槽的默認替換內容

function findAnchor (children) {
  if (children) {
    let child
    for (let i = 0; i < children.length; i++) {
      child = children[i]
      if (child.tag === 'a') {
        return child
      }
      if (child.children && (child = findAnchor(child.children))) {
        return child
      }
    }
  }
}
複製代碼

源碼:L177

總結

通過以上的種種分析,vue-router中的核心特性的實現基本已經分析完成。因爲目前做者水平有限,部分源碼的分析還不夠完全,好比:router-view源碼中涉及到與vue核心相關部分,甚至有地方存在疏漏或者錯誤,還請各位讀者指正。

這篇文章寫到這裏幾乎花了我一個多星期的時間,文章長度已經徹底超過了起初的預估,若是你能堅持看到這裏至少說明你應該已經很厲害了,應該給本身點個贊。

若此文對你有一點點幫助請點個贊鼓勵下做者,畢竟原創不易:)

首發自語雀:www.yuque.com/johniexu/fr…

做者博客地址:blog.lessing.online/

做者github:github.com/johniexu

相關文章
相關標籤/搜索