解密vue-router: 從原始碼開始

玩弄心裡的鬼發表於2018-05-08

前幾天筆者看到一個問題:你真的瞭解vue-router的嗎?你知道vue-router的執行原理嗎?抱著這樣的問題,筆者開始了vue-router的原始碼探索之旅。本文並沒有逐行去深究原始碼,而是跟著筆者畫的流程圖來簡析每一步的執行流程。

剖析執行流程

筆者根據原始碼的結構和自己的理解事先畫好了一張流程圖,乍一看這張執行流程圖可能會有點蒙圈,筆者接下來會現根據這張圖分析下執行流程,然後再一步一步的剖析原始碼的核心部分。

執行流程
為了便於我們理解這張執行流程圖,我們將掛載完vue-router的Vue例項列印出來看看都增加了什麼東西:
1
2

  • $options下的router物件很好理解,這個就是我們在例項化Vue的時候掛載的那個vue-router例項;
  • _route是一個響應式的路由route物件,這個物件會儲存我們路由資訊,它是通過Vue提供的Vue.util.defineReactive來實現響應式的,下面的get和set便是對它進行的資料劫持;
  • _router儲存的就是我們從$options中拿到的vue-router物件;
  • _routerRoot指向我們的Vue根節點;
  • _routerViewCache是我們對View的快取;
  • $route$router是定義在Vue.prototype上的兩個getter。前者指向_routerRoot下的_route,後者指向_routerRoot下的_router

接下來讓我們順順這個“眼花繚亂的圖”,以便於我們後面更好的理解之後的原始碼分析。

首先我們根據Vue的外掛機制安裝了vue-router,這裡其實做的很簡單,總結起來就是封裝了一個mixin,定義了兩個'原型',註冊了兩個元件。在這個mixin中,beforeCreate鉤子被呼叫然後判斷vue-router是否例項話了並初始化路由相關邏輯,前文提到的_routerRoot、_router、_route便是在此時被定義的。定義了兩個“原型”是指在Vue.prototype上定一個兩個getter,也就$route和$router。註冊了兩個元件是指在這裡註冊了我們後續會用到的RouterView和RouterLink這兩個元件。

然後我們建立了一個VueRouter的例項,並將它掛載在Vue的例項上,這時候VueRouter的例項中的constructor初始化了各種鉤子佇列;初始化了matcher用於做我們的路由匹配邏輯並建立路由物件;初始化了history來執行過渡邏輯並執行鉤子佇列。

接下里mixin中beforeCreate做的另一件事就是執行了我們VueRouter例項的init()方法執行初始化,這一套流程和我們點選RouteLink或者函式式控制路由的流程類似,這裡我就一起說了。在init方法中呼叫了history物件的transitionTo方法,然後去通過match獲取當前路由匹配的資料並建立了一個新的路由物件route,接下來拿著這個route物件去執行confirmTransition方法去執行鉤子佇列中的事件,最後通過updateRoute更新儲存當前路由資料的物件current,指向我們剛才建立的路由物件route。

最開始的時候我們說過_route被定義成了響應式的 那麼一個路由更新之後,_route物件會接收到響應並通知RouteView去更新檢視。

到此,流程就結束了,接下來我們將深入vue-router的原始碼去深度學習其原理。

剖析原始碼

說在前面

vue-router的原始碼都採用了flow作為型別檢驗,沒有配置flow的話可能會滿屏報錯,本文不對flow做過多的介紹了。為了便於大家的理解,下面的原始碼部分我會將flow相關的語法去掉。順便附上一些flow相關:

flow官方文件(需要科學上網):https://flow.org/ flow入門:https://zhuanlan.zhihu.com/p/26204569 flow配置:https://zhuanlan.zhihu.com/p/24649359

專案結構

在拿到一個專案的原始碼時候,我們首先要去看它的目錄結構:

3
其中src是我們的專案原始碼部分,它包含如下結構:

  • componets是RouterLink和RouterView這兩個元件;
  • create-matcher.js就是我們建立match的入口檔案;
  • create-route-map.js用於建立path列表,path map,name map等;
  • history是建立hitory類的邏輯;
  • index.js就是我們的入口檔案,其中建立了VueRouter這個類;
  • install.js是我們掛載vue-router外掛的邏輯;
  • util定義了很多工具函式;

應用入口

通常我們去構建一個Vue應用程式的時候入口檔案通常會這麼寫:

// app.js
import Vue from 'vue';
import VueRouter from 'vue-router';
import Main from '../components/main';

Vue.use(VueRouter);

const router = new VueRouter({
  routes: [{
    path: '/',
    component: Main,
  }],
});

// app.js
new Vue({
  router,
  template,
}).$mount('#app')
複製程式碼

我們可以看到vue-router是以外掛的形式安裝的,並且vue-router的例項也會掛載在Vue的例項上面。

外掛安裝

此時我們將目光移入原始碼的入口檔案,發現index.js中引入了install模組,並在VueRouter類上掛載了一個靜態的install方法。而且還判斷了環境中如果已經掛載了Vue則自動去使用這個外掛。

原始碼位置:/src/index.js

import { install } from './install'
import { inBrowser } from './util/dom'
// ...
export default class VueRouter {}
// ...
// 掛載install;
VueRouter.install = install
// 判斷如果window上掛載了Vue則自動使用外掛;
if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}
複製程式碼

接下來看install.js這個檔案,這個檔案匯出了export方法以供Vue.use去安裝:

原始碼位置:/src/install.js

import View from './components/view'
import Link from './components/link'

// export一個Vue的原因是可以不講Vue打包進外掛中而使用Vue一些方法;
// 只能在install之後才會存在這個Vue的例項;
export let _Vue

export function install (Vue) {
  // 如果外掛已經安裝就return
  if (install.installed && _Vue === Vue) return
  install.installed = true

  _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 () {
      // this.$options.router為VueRouter例項;
      // 這裡判斷例項是否已經掛載;
      if (isDef(this.$options.router)) {
        // 將router的根元件指向Vue例項
        this._routerRoot = this
        this._router = this.$options.router
        // router初始化,呼叫VueRouter的init方法;
        this._router.init(this)
        // 使用Vue的defineReactive增加_route的響應式物件
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 將每一個元件的_routerRoot都指向根Vue例項;
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      // 註冊VueComponent 進行Observer處理;
      registerInstance(this, this)
    },
    destroyed () {
      // 登出VueComponent
      registerInstance(this)
    }
  })
  // 為$router和4route定義 << getter >> 分別指向_routerRoot的 _router 和 _route
  // _router 為VueRouter的例項;
  // _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)

  // Vue鉤子合併策略
  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}
複製程式碼

這裡需要注意的幾點:

  • 匯出一個Vue引用:這是為了不用將整個Vue打包進去就可以使用Vue提供的一些API,當然,這些的前提就是vue-router必須被安裝掛載;
  • 在Vue.prototype上定義兩個getter:Vue的元件都是Vue例項的一個擴充套件,他們都可以訪問prototype上的方法和屬性;
  • 定義響應式_route物件:有了這個響應式的路由物件,就可以在路由更新的時候及時的通知RouterView去更新元件了;

例項化VueRouter

接下來我們來看VueRouter類的例項化,在constructor中主要做的就兩件事,建立matcher和建立history:

原始碼位置:/src/index.js

// ...
import { createMatcher } from './create-matcher'
import { supportsPushState } from './util/push-state'
import { HashHistory } from './history/hash'
import { HTML5History } from './history/html5'
import { AbstractHistory } from './history/abstract'
// ...
export default class VueRouter {
  constructor (options) {
    this.app = null
    this.apps = []
    // VueRouter 配置項;
    this.options = options
    // 三個鉤子
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    // 建立路由匹配例項;傳人我們定義的routes:包含path和component的物件;
    this.matcher = createMatcher(options.routes || [], this)
    // 判斷模式
    let mode = options.mode || 'hash'
    // 判斷瀏覽器是否支援history,如果不支援則回退到hash模式;
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    // node執行環境 mode = 'abstract';
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode
    // 根據模式建立對應的history例項
    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}`)
        }
    }
  }
  // ...
}
複製程式碼

建立matcher

順著思路我們先看createMatcher這個函式:

原始碼位置:/src/create-matcher.js

import VueRouter from './index'
import { resolvePath } from './util/path'
import { assert, warn } from './util/warn'
import { createRoute } from './util/route'
import { fillParams } from './util/params'
import { createRouteMap } from './create-route-map'
import { normalizeLocation } from './util/location'

// routes為我們初始化VueRouter的路由配置;
// router就是我們的VueRouter例項;
export function createMatcher (routes, router) {
  // pathList是根據routes生成的path陣列;
  // pathMap是根據path的名稱生成的map;
  // 如果我們在路由配置上定義了name,那麼就會有這麼一個name的Map;
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  // 根據新的routes生成路由;
  function addRoutes (routes) {
    createRouteMap(routes, pathList, pathMap, nameMap)
  }
  // 路由匹配函式;
  function match (raw, currentRoute, redirectedFrom) {
    // 簡單講就是拿出我們path params query等等;
    const location = normalizeLocation(raw, currentRoute, false, router)
    const { name } = location

    if (name) {
      // 如果有name的話,就去name map中去找到這條路由記錄;
      const record = nameMap[name]
      if (process.env.NODE_ENV !== 'production') {
        warn(record, `Route with name '${name}' does not exist`)
      }
      // 如果沒有這條路由記錄就去建立一條路由物件;
      if (!record) return _createRoute(null, location)
      const paramNames = record.regex.keys
        .filter(key => !key.optional)
        .map(key => key.name)

      if (typeof location.params !== 'object') {
        location.params = {}
      }

      if (currentRoute && typeof currentRoute.params === 'object') {
        for (const key in currentRoute.params) {
          if (!(key in location.params) && paramNames.indexOf(key) > -1) {
            location.params[key] = currentRoute.params[key]
          }
        }
      }

      if (record) {
        location.path = fillParams(record.path, location.params, `named route "${name}"`)
        return _createRoute(record, location, redirectedFrom)
      }
    } else if (location.path) {
      location.params = {}
      for (let i = 0; i < pathList.length; i++) {
        const path = pathList[i]
        const record = pathMap[path]
        // 根據當前路徑進行路由匹配
        // 如果匹配就建立一條路由物件;
        if (matchRoute(record.regex, location.path, location.params)) {
          return _createRoute(record, location, redirectedFrom)
        }
      }
    }
    // no match
    return _createRoute(null, location)
  }
  
  // ...

  function _createRoute (record, location, redirectedFrom) {
    // 根據不同的條件去建立路由物件;
    if (record && record.redirect) {
      return redirect(record, redirectedFrom || location)
    }
    if (record && record.matchAs) {
      return alias(record, location, record.matchAs)
    }
    return createRoute(record, location, redirectedFrom, router)
  }

  return {
    match,
    addRoutes
  }
}

function matchRoute (regex, path, params) {
  const m = path.match(regex)

  if (!m) {
    return false
  } else if (!params) {
    return true
  }

  for (let i = 1, len = m.length; i < len; ++i) {
    const key = regex.keys[i - 1]
    const val = typeof m[i] === 'string' ? decodeURIComponent(m[i]) : m[i]
    if (key) {
      params[key.name] = val
    }
  }

  return true
}

function resolveRecordPath (path, record) {
  return resolvePath(path, record.parent ? record.parent.path : '/', true)
}
複製程式碼

首先createMatcher會根據我們初始化VueRouter例項時候定義的routes配置,通過createRouteMap生成一份含有對應關係的map,具體邏輯下面我們會說到。然後返回一個包含match和addRoutes兩個方法的物件match,就是我們實現路由匹配的詳細邏輯,他會返回匹配的路由物件;addRoutes會就是新增路由的方法。

接下來我們順著剛才的思路去看create-route-map.js

原始碼位置:/src/create-route-map.js

/* @flow */

import Regexp from 'path-to-regexp'
import { cleanPath } from './util/path'
import { assert, warn } from './util/warn'

export function createRouteMap (routes, oldPathList, oldPathMap, oldNameMap) {
  // the path list is used to control path matching priority
  const pathList = oldPathList || []
  // $flow-disable-line
  const pathMap = oldPathMap || Object.create(null)
  // $flow-disable-line
  const nameMap = oldNameMap || Object.create(null)
  // path列表
  // path的map對映
  // name的map對映
  // 為配置的路由項增加路由記錄
  routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })

  // ensure wildcard routes are always at the end
  for (let i = 0, l = pathList.length; i < l; i++) {
    if (pathList[i] === '*') {
      pathList.push(pathList.splice(i, 1)[0])
      l--
      i--
    }
  }
  // 返回包含path陣列,path map和name map的物件;
  return {
    pathList,
    pathMap,
    nameMap
  }
}

function addRouteRecord (pathList, pathMap, nameMap, route, parent, matchAs) {
  const { path, name } = route
  if (process.env.NODE_ENV !== 'production') {
    assert(path != null, `"path" is required in a route configuration.`)
    assert(
      typeof route.component !== 'string',
      `route config "component" for path: ${String(path || name)} cannot be a ` +
      `string id. Use an actual component instead.`
    )
  }

  // 定義 path 到 Reg 的選項;
  const pathToRegexpOptions: PathToRegexpOptions = route.pathToRegexpOptions || {}
  // 序列化path,'/'將會被替換成'';
  const normalizedPath = normalizePath(
    path,
    parent,
    pathToRegexpOptions.strict
  )

  // 正則匹配是否區分大小寫;
  if (typeof route.caseSensitive === 'boolean') {
    pathToRegexpOptions.sensitive = route.caseSensitive
  }

  const record = {
    path: normalizedPath,
    regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
    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 }
  }
  // 如果有巢狀的子路由,則遞迴新增路由記錄;
  if (route.children) {
    // Warn if route is named, does not redirect and has a default child route.
    // If users navigate to this route by name, the default child will
    // not be rendered (GH Issue #629)
    if (process.env.NODE_ENV !== 'production') {
      if (route.name && !route.redirect && route.children.some(child => /^\/?$/.test(child.path))) {
        warn(
          false,
          `Named Route '${route.name}' has a default child route. ` +
          `When navigating to this named route (:to="{name: '${route.name}'"), ` +
          `the default child route will not be rendered. Remove the name from ` +
          `this route and use the name of the default child route for named ` +
          `links instead.`
        )
      }
    }
    route.children.forEach(child => {
      const childMatchAs = matchAs
        ? cleanPath(`${matchAs}/${child.path}`)
        : undefined
      addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
    })
  }

  // 如果路由含有別名,則為其新增別名路由記錄
  // 關於alias
  // https://router.vuejs.org/zh-cn/essentials/redirect-and-alias.html
  if (route.alias !== undefined) {
    const aliases = Array.isArray(route.alias)
      ? route.alias
      : [route.alias]

    aliases.forEach(alias => {
      const aliasRoute = {
        path: alias,
        children: route.children
      }
      addRouteRecord(
        pathList,
        pathMap,
        nameMap,
        aliasRoute,
        parent,
        record.path || '/' // matchAs
      )
    })
  }

  // 更新path map
  if (!pathMap[record.path]) {
    pathList.push(record.path)
    pathMap[record.path] = record
  }
  // 為定義了name的路由更新 name map
  if (name) {
    if (!nameMap[name]) {
      nameMap[name] = record
    } else if (process.env.NODE_ENV !== 'production' && !matchAs) {
      warn(
        false,
        `Duplicate named routes definition: ` +
        `{ name: "${name}", path: "${record.path}" }`
      )
    }
  }
}

function compileRouteRegex (path, pathToRegexpOptions) {
  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
}

function normalizePath (path, parent, strict): string {
  if (!strict) path = path.replace(/\/$/, '')
  if (path[0] === '/') return path
  if (parent == null) return path
  return cleanPath(`${parent.path}/${path}`)
}
複製程式碼

從上述程式碼可以看出,create-route-map.js的就是根據使用者的routes配置的path、alias以及name來生成對應的路由記錄。

建立history

matcher這一部分算是講完了,接下來該說History的例項化了,從原始碼來說history資料夾下是有4個檔案的,base作為基類,另外三個繼承這個基類來分別處理vue-router的各種mode情況,這裡我們主要看base的邏輯就可以了。

// install 到處的Vue,避免Vue打包進專案增加體積;
import { START, isSameRoute } from '../util/route'

export class History {
  constructor (router, base) {
    this.router = router
    this.base = normalizeBase(base)
    // start with a route object that stands for "nowhere"
    // 生成一個基礎的route物件;
    this.current = START
    this.pending = null
    this.ready = false
    this.readyCbs = []
    this.readyErrorCbs = []
    this.errorCbs = []
  }
  // ...
}
// ...
function normalizeBase (base: ?string): string {
  if (!base) {
    if (inBrowser) {
      // respect <base> tag
      const baseEl = document.querySelector('base')
      base = (baseEl && baseEl.getAttribute('href')) || '/'
      // strip full URL origin
      base = base.replace(/^https?:\/\/[^\/]+/, '')
    } else {
      base = '/'
    }
  }
  // make sure there's the starting slash
  if (base.charAt(0) !== '/') {
    base = '/' + base
  }
  // remove trailing slash
  return base.replace(/\/$/, '')
}
複製程式碼

基礎的掛載和各種例項化都說完了之後,我們可以從init入手去看之後的流程了。

5
之前在講install的時候知道了在mixin中的beforeCreate鉤子裡執行了init,現在我們移步到VueRouter的init方法。

原始碼位置:/src/index.js

// ...
init (app) {
    process.env.NODE_ENV !== 'production' && assert(
      install.installed,
      `not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
      `before creating root instance.`
    )
    // 從install中的呼叫我們知道,這個app就是我們例項化的vVue例項;
    this.apps.push(app)

    // main app already initialized.
    if (this.app) {
      return
    }
    // 將VueRouter內的app指向我們亙Vue例項;
    this.app = app

    const history = this.history
    // 針對於 HTML5History 和 HashHistory 特殊處理,
    // 因為在這兩種模式下才有可能存在進入時候的不是預設頁,
    // 需要根據當前瀏覽器位址列裡的 path 或者 hash 來啟用對應的路由
    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }
    //...
  }
// ...
複製程式碼

可以看到初始化主要就是給app賦值,並且針對於HTML5History和HashHistory進行特殊的處理,因為在這兩種模式下才有可能存在進入時候的不是預設頁,需要根據當前瀏覽器位址列裡的path或者hash來啟用對應的路由,此時就是通過呼叫transitionTo來達到目的;

接下來來看看這個具體的transitionTo:

原始碼位置:/src/history/base.js

transitionTo (location, onComplete, onAbort) {
    // localtion為我們當前頁面的路由;
    // 呼叫VueRouter的match方法獲取匹配的路由物件,建立下一個狀態的路由物件;
    // this.current是我們儲存的當前狀態的路由物件;
    const route = this.router.match(location, this.current)
    this.confirmTransition(route, () => {
      // 更新當前的route物件;
      this.updateRoute(route)
      onComplete && onComplete(route)
      // 呼叫子類的方法更新url
      this.ensureURL()
      // fire ready cbs once
      // 呼叫成功後的ready的回撥函式;
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => {
      if (onAbort) {
        onAbort(err)
      }
      // 呼叫失敗的err回撥函式;
      if (err && !this.ready) {
        this.ready = true
        this.readyErrorCbs.forEach(cb => { cb(err) })
      }
    })
  }
  confirmTransition (route, onComplete, onAbort) {
    const current = this.current
    const abort = err => {
      if (isError(err)) {
        if (this.errorCbs.length) {
          this.errorCbs.forEach(cb => { cb(err) })
        } else {
          warn(false, 'uncaught error during route navigation:')
          console.error(err)
        }
      }
      onAbort && onAbort(err)
    }
    // 如果是同一個路由就不去跳轉;
    if (
      isSameRoute(route, current) &&
      // in the case the route map has been dynamically appended to
      route.matched.length === current.matched.length
    ) {
      // 呼叫子類的方法更新url
      this.ensureURL()
      return abort()
    }
    // 交叉比對當前路由的路由記錄和現在的這個路由的路由記錄
    // 以便能準確得到父子路由更新的情況下可以確切的知道
    // 哪些元件需要更新 哪些不需要更新
    const {
      updated,
      deactivated,
      activated
    } = resolveQueue(this.current.matched, route.matched)
    // 注意,matched裡頭儲存的是路由記錄的陣列;

    // // 整個切換週期的佇列,待執行的各種鉤子更新佇列
    const queue: Array<?NavigationGuard> = [].concat(
      // in-component leave guards
      // 提取元件的 beforeRouteLeave 鉤子
      extractLeaveGuards(deactivated),
      // global before hooks
      this.router.beforeHooks,
      // in-component update hooks
      // 提取元件的 beforeRouteUpdate 鉤子
      extractUpdateHooks(updated),
      // in-config enter guards
      activated.map(m => m.beforeEnter),
      // async components
      // 非同步處理元件
      resolveAsyncComponents(activated)
    )
    // 儲存下一個狀態的路由
    this.pending = route
    // 每一個佇列執行的 iterator 函式
    const iterator = (hook: NavigationGuard, next) => {
      if (this.pending !== route) {
        return abort()
      }
      try {
        hook(route, current, (to: any) => {
          if (to === false || isError(to)) {
            // next(false) -> abort navigation, ensure current URL
            this.ensureURL(true)
            abort(to)
          } else if (
            typeof to === 'string' ||
            (typeof to === 'object' && (
              typeof to.path === 'string' ||
              typeof to.name === 'string'
            ))
          ) {
            // next('/') or next({ path: '/' }) -> redirect
            abort()
            if (typeof to === 'object' && to.replace) {
              this.replace(to)
            } else {
              this.push(to)
            }
          } else {
            // confirm transition and pass on the value
            next(to)
          }
        })
      } catch (e) {
        abort(e)
      }
    }
    // 執行各種鉤子佇列
    runQueue(queue, iterator, () => {
      const postEnterCbs = []
      const isValid = () => this.current === route
      // wait until async components are resolved before
      // extracting in-component enter guards
      // 等待非同步元件 OK 時,執行元件內的鉤子
      const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
      const queue = enterGuards.concat(this.router.resolveHooks)
      // 在上次的佇列執行完成後再執行元件內的鉤子
      // 因為需要等非同步元件以及是OK的情況下才能執行
      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() })
          })
        }
      })
    })
  }
  updateRoute (route) {
    const prev = this.current
    // 將current指向我們更新後的route物件;
    this.current = route
    this.cb && this.cb(route)
    this.router.afterHooks.forEach(hook => {
      hook && hook(route, prev)
    })
  }
複製程式碼

邏輯看似複雜,實際上就是各種鉤子函式的來回處理,但是這裡要注意下,每一個路由route物件都會有一個matchd屬性,這個屬性包含一個路由記錄,這個記錄的生成在create-matcher.js中已經提到了。

等一下,我們好像漏了點東西,init後面還有一點沒說:

原始碼位置:/src/index.js

// 設定路由改變時候的監聽;
history.listen(route => {
    this.apps.forEach((app) => {
        app._route = route
    })
})
複製程式碼

在這裡設定了route改變之後的回撥函式, 會在confirmTransition中的onComplete回撥中呼叫, 並更新當前的_route的值,前面我們提到,_route是響應式的,那麼當其更新的時候就會去通知元件重新render渲染。

兩個元件

大體流程都看完了,接下來可以看看兩個元件了,我們先看RouterView元件: 原始碼位置:/src/components/view.js

import { warn } from '../util/warn'

export default {
  name: 'RouterView',
  functional: true,
  props: {
    // 試圖名稱,預設是default
    name: {
      type: String,
      default: 'default'
    }
  },
  render (_, { props, children, parent, data }) {
    data.routerView = true
    // directly use parent context's createElement() function
    // so that components rendered by router-view can resolve named slots
    // 渲染函式
    const h = parent.$createElement
    const name = props.name
    // 拿到_route物件和快取物件;
    const route = parent.$route
    const cache = parent._routerViewCache || (parent._routerViewCache = {})
    // determine current view depth, also check to see if the tree
    // has been toggled inactive but kept-alive.
    // 元件層級
    // 當 _routerRoot 指向 Vue 例項時就終止迴圈
    let depth = 0
    let inactive = false
    while (parent && parent._routerRoot !== parent) {
      if (parent.$vnode && parent.$vnode.data.routerView) {
        depth++
      }
      // 處理 keep-alive 元件
      if (parent._inactive) {
        inactive = true
      }
      parent = parent.$parent
    }
    data.routerViewDepth = depth

    // render previous view if the tree is inactive and kept-alive
    // 渲染快取的 keep-alive 元件
    if (inactive) {
      return h(cache[name], data, children)
    }
    const matched = route.matched[depth]
    // render empty node if no matched route
    if (!matched) {
      cache[name] = null
      return h()
    }
    const component = cache[name] = matched.components[name]
    // attach instance registration hook
    // this will be called in the instance's injected lifecycle hooks
    // 新增註冊鉤子, 鉤子會被注入到元件的生命週期鉤子中
    // 在 src/install.js, 會在 beforeCreate 鉤子中呼叫
    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
      }
    }
    // also register instance in prepatch hook
    // in case the same component instance is reused across different routes
    ;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
      matched.instances[name] = vnode.componentInstance
    }
    // resolve props
    let propsToPass = data.props = resolveProps(route, matched.props && matched.props[name])
    if (propsToPass) {
      // clone to prevent mutation
      propsToPass = data.props = extend({}, propsToPass)
      // pass non-declared props as attrs
      const attrs = data.attrs = data.attrs || {}
      for (const key in propsToPass) {
        if (!component.props || !(key in component.props)) {
          attrs[key] = propsToPass[key]
          delete propsToPass[key]
        }
      }
    }

    return h(component, data, children)
  }
}

function resolveProps (route, config) {
  switch (typeof config) {
    case 'undefined':
      return
    case 'object':
      return config
    case 'function':
      return config(route)
    case 'boolean':
      return config ? route.params : undefined
    default:
      if (process.env.NODE_ENV !== 'production') {
        warn(
          false,
          `props in "${route.path}" is a ${typeof config}, ` +
          `expecting an object, function or boolean.`
        )
      }
  }
}

function extend (to, from) {
  for (const key in from) {
    to[key] = from[key]
  }
  return to
}
複製程式碼

然後是RouterLink元件:

原始碼位置:/src/components/link.js

/* @flow */

import { createRoute, isSameRoute, isIncludedRoute } from '../util/route'
import { _Vue } from '../install'

// work around weird flow bug
const toTypes: Array<Function> = [String, Object]
const eventTypes: Array<Function> = [String, Array]

export default {
  name: 'RouterLink',
  props: {
    to: {
      type: toTypes,
      required: true
    },
    tag: {
      type: String,
      default: 'a'
    },
    exact: Boolean,
    append: Boolean,
    replace: Boolean,
    activeClass: String,
    exactActiveClass: String,
    event: {
      type: eventTypes,
      default: 'click'
    }
  },
  render (h: Function) {
    // 獲取掛載的VueRouter例項
    const router = this.$router
    // 獲取當前的路由物件
    const current = this.$route
    // 獲取當前匹配的路由資訊
    const { location, route, href } = router.resolve(this.to, current, this.append)

    const classes = {}
    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
    const activeClass = this.activeClass == null
      ? activeClassFallback
      : this.activeClass
    const exactActiveClass = this.exactActiveClass == null
      ? exactActiveClassFallback
      : this.exactActiveClass
    const compareTarget = location.path
      ? createRoute(null, location, null, router)
      : route

    classes[exactActiveClass] = isSameRoute(current, compareTarget)
    classes[activeClass] = this.exact
      ? classes[exactActiveClass]
      : isIncludedRoute(current, compareTarget)

    const handler = e => {
      if (guardEvent(e)) {
        if (this.replace) {
          router.replace(location)
        } else {
          router.push(location)
        }
      }
    }
    
    // 事件繫結
    const on = { click: guardEvent }
    if (Array.isArray(this.event)) {
      this.event.forEach(e => { on[e] = handler })
    } else {
      on[this.event] = handler
    }

    const data: any = {
      class: classes
    }

    if (this.tag === 'a') {
      data.on = on
      data.attrs = { href }
    } else {
      // find the first <a> child and apply listener and href
      // 找到第一個 <a> 給予這個元素事件繫結和href屬性
      const a = findAnchor(this.$slots.default)
      if (a) {
        // in case the <a> is a static node
        a.isStatic = false
        const extend = _Vue.util.extend
        const aData = a.data = extend({}, a.data)
        aData.on = on
        const aAttrs = a.data.attrs = extend({}, a.data.attrs)
        aAttrs.href = href
      } else {
        // doesn't have <a> child, apply listener to self
        // 沒有 <a> 的話就給當前元素自身繫結事件
        data.on = on
      }
    }

    return h(this.tag, data, this.$slots.default)
  }
}

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
}

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
      }
    }
  }
}
複製程式碼

結語

到這裡,vue-router的原始碼剖析就告一段落了,雖然沒有逐行去理解作者的思想,但也算是整體上捋順了專案的執行原理,理解了原理也就更方便我們日常的需求開發了。最後,謝謝大家喜歡。

相關文章