Vue-Router源碼簡析

開始

首先是碎碎念,換了工作之後,終於有些閒暇時間,突然才發現自己竟然有一年多沒有寫博客,回頭想想這段時間似乎都沒有多少新的技術積累,感覺好慚愧,無法吐槽自己了。
好吧,還是立馬進入主題,今天的主角就是Vue-Router,作爲Vue全家桶的一員,肯定是再熟悉不過了,但是自己卻沒有去閱讀過源碼,有些地方還不是很瞭解,終於在最近的項目還是遇到坑了(不遇到坑可能猴年馬月都不會去看一下源碼吧,哈哈),所以還是花了一些時間去學習了一下源碼吧。

路由

路由在一個app開發中是不可缺少的,理論上在一個app開發中,首先就是要定義各個路由:哪些頁面需要鑑權才能打開,什麼時候需要重定向指定的頁面,頁面切換的時候怎麼傳遞數據,等等。可以想象當應用越是龐大,路由的重要性就會發凸顯,路由可以說是整個應用的骨架。但是web的路由功能相對原生應用開發是很弱的,控制不好,就會出現一些莫名其妙的跳轉和交互。

構建Router

直接進入VueRouter構建流程:

  1. 調用createMatcher方法,創建route map
  2. 默認是hash模式路由
  3. 如果選擇了history模式,但是剛好瀏覽器又不支持history,fallback選項就會設置爲true,fallback選項會影響hash模式下url的處理,後面會分析到。
  4. 根據當前模式選擇對應的history(hash,history,abstract)

先看第一步createMatcher方法:

export function createMatcher (
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
    
    ...
  
  return {
    match,
    addRoutes
  }
}

主要通過createRouteMap方法創建出pathList,pathMap和nameMap,pathMap和nameMap裏面都是RouteRcord對象;Vue-Router會爲每個路由配置項生成一個RouteRecord對象,然後就返回match和addRoutes方法;
這裏match方法,就是通過當前的路由路徑去返回匹配的RouteRecord對象,而addRoutes則能夠動態添加新的路由配置信息,正與官方文檔描述一樣。

再看createRouteMap方法:

function createRouteMap (
  routes: Array<RouteConfig>,
  oldPathList?: Array<string>,
  oldPathMap?: Dictionary<RouteRecord>,
  oldNameMap?: Dictionary<RouteRecord>
): {
  ...
  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--
    }
  }
  ...
}

遍歷所有的路由配置信息,創建RouteRecord對象並添加到pathMap,nameMap中;最後調整pathList中通配符的位置,所以最後無法準確找匹配的路由,都會返回最後通配符的RouteRecord。

那麼RouteRecord究竟是個什麼樣的對象尼:

const record: RouteRecord = {
    path,
    regex,
    components,
    instances, //路由創建的組件實例
    name,
    parent, //父級RouteRecord
    matchAs, //alias
    redirect,
    beforeEnter,
    meta,
    props, //後面會分析
  }

這樣一看RouteRcord其實有很多屬性跟我們初始配置的路由屬性是一致的。

RouteRecord的關鍵屬性是parent會指向父級的RouteRecord對象,在嵌套的router-view場景下,當我們找到匹配的的RouteRecord就可以順帶把父級的RouteRecord找出來直接匹配到同樣depth的router-view上;

instances屬性保存了路由創建的組件實例,因爲當路由切換的是,需要調起這些實例beforeRouteLeave等鉤子;那麼這些組件實例是什麼時候添加到instances上的尼?主要有兩個途徑:

  1. 組件的beforeCreate鉤子

    Vue.mixin({
        beforeCreate () {
          ...
          registerInstance(this, this)
        },
        destroyed () {
          registerInstance(this)
        }
     })
  2. vnode的鉤子函數

    ;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
      matched.instances[name] = vnode.componentInstance
    }
    
    data.hook.init = (vnode) => {
      if (vnode.data.keepAlive &&
        vnode.componentInstance &&
        vnode.componentInstance !== matched.instances[name]
      ) {
        matched.instances[name] = vnode.componentInstance
      }
    }

單純依賴第一種組件的beforeCreate鉤子,在某些場景下是無法正確的把組件的實例放到RouteRecord的instances屬性中,那麼再討論一下哪些場景會需要第二種方法。
需要prepatch鉤子的場景,當有兩個路由:

new VueRouter({
  mode: 'hash',
  routes: [
    { path: '/a', component: Foo },
    { path: '/b', component: Foo }
  ]
})

‘/a', '/b'它們的組件都是Foo,如果初始路由是'/a', 那麼虛擬dom樹上就已經有一個Foo實例生成,當路由切換到'/b'的時候,因爲virtual dom的算法,Foo實例會被重用,並不會重新創建新的實例,也就是Foo的beforeCreate鉤子是不會調起,這樣的話,Foo的實例也就沒辦法通過beforeCreate鉤子添加到'/b'的RouteRecord上。但是vnode的prepatch鉤子這時可以調起,所以可以在這裏把Foo的實例放到‘/b’的RouteRecord上。

需要init鉤子的場景:

<keep-alive>
    <router-view><router-view>
</keep-alive>

首先在router-view上套一個keep-alive組件,接着路由的定義如下:

new VueRouter({
  mode: 'hash',
  routes: [
    { path: '/a', component: Foo },
    { path: '/b', component: Other}
    { path: '/c', component: Foo }
  ]
})

當初始路由是‘/a’的時候,Foo的實例會被創建並且被keep-alive組件保存了,當切到‘/b’後,Other的實例跟Foo的實例同樣一樣情況,然後再切換路由到‘/c’的時候,由於keep-alive的組件作用,會直接重用之前‘/a’保存的Foo實例,在virtual dom對比的時候,重用的Foo實例和Other實例的虛擬dom節點完全是不同類型是無法調起prepatch鉤子,但是可以調起init鉤子。

以上就是相關的一些場景討論。其實個人感覺有些情況Vue-Router這種處理並不是很好,因爲RouteRcord相對整個Router實例是唯一的,對應的instances也是唯一,如果同一深度(不用層級)情況下,有兩個多個router-view:

<div>
    <div>
        <router-view></router-view>
    </div>
    <div>
        <router-view></router-view>
    </div>
</div>

很明顯現在它們都會指向同一個RouteRecord,但是它們會創建出不同的組件實例,但是隻有當中的一個會成功註冊到RouteRecord的instances屬性上,當切換路由的時候,另外一個組件實例是應該不會接收到相關的路由鉤子調用的,雖然這種使用場景可能幾乎沒有。

另外一個場景可能只是我們使用的時候需要注意一下,因爲我們可以改變router-view上的name屬性也可以切換router-view展示,而這種切換並不是路由切換引起的,所以組件的實例上是不會有路由鉤子調起的;另外當instances上有多個實例的時候,路由一旦切換,就算沒有在router-view展示的實例,都會調起路由的鉤子。

matchAs是在路由有alias的時候,會創建AliasRouteRecord,它的matchAs就會指向原本的路由路徑。

props這個屬性有點特別,在官方文檔上也沒有多少說明,只有在源碼有相關的例子;

  1. 它如果是對象可以在router-view創建組件實例時,把props傳給實例;
  2. 但是如果是布爾值爲true時,它就會把當前的route的params對象作爲props傳給組件實例;
  3. 如果是一個function,就會把當前route作爲參數傳入然後調起,並把返回結果當作props傳給實例。

所以我們可以很輕鬆的把props設置爲true,就可以把route的params當成props傳給組件實例。

現在分析完RouteRecord對象,接着創建的主流程,先需要創建對應的HashHistory;但是如果我們是選擇了history模式只是瀏覽器不支持回退到hash模式的話,url需要額外處理一下,例如如果history模式下,URL路徑是這樣的:

    http://www.baidu.com/a/b

在hash模式下就會被替換成

    http://www.baidu.com/#/a/b

到此Vue-Router實例構建完成。

監聽路由

在Vue-Router提供的全局mixin裏,router的init方法會在beforeCreate鉤子裏面調起,正式開始監聽路由。
router的init方法:

init(app) {
    ...
    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
      })
    })
}

這裏history模式會直接切換路由(history模式在構建HTML5History時就已經掛載了popstate監聽器),而hash模式會先設置popstate或者hashchange監聽器,才切換到當前的路由(因爲hash模式可能是history模式降級而來的,要調整url,所以延遲到這裏設置監聽器)。
到這裏可以發現,可能跟我們想象的不一樣,其實hash模式也是優先使用html5的pushstate/popstate的,並不是直接使用hash/hashchange驅動路由切換的(因爲pushstate/popstate可以方便記錄頁面滾動位置信息)

直接到最核心部分transitionTo方法,看Vue-Router如何驅動路由切換:

 transitionTo (
    location: RawLocation,
    onComplete?: Function,
    onAbort?: Function
  ) {
    const route = this.router.match(location, this.current) //1. 找到匹配的路由
    this.confirmTransition( //2. 執行切換路由的鉤子函數
      route,
      () => {
        this.updateRoute(route) //3. 更新當前路由
        onComplete && onComplete(route)
        this.ensureURL() //4. 確保當前URL跟當前路由一致

        // fire ready cbs once
        if (!this.ready) {
          this.ready = true
          this.readyCbs.forEach(cb => {
            cb(route)
          })
        }
      },
      err => {
        if (onAbort) {
          onAbort(err)
        }
        if (err && !this.ready) {
          this.ready = true
          this.readyErrorCbs.forEach(cb => {
            cb(err)
          })
        }
      }
    )
  }   

粗略來看總共4步:

  1. 先找到匹配的路由
  2. 執行切換路由的鉤子函數(beforeRouteLevave, beforeRouteUpdate等等)
  3. 更新當前路由
  4. 確保當前URL跟當前路由一致

接着分析
第1步到match方法:

function match (
    raw: RawLocation,
    currentRoute?: Route,
    redirectedFrom?: Location
  ): Route {
    const location = normalizeLocation(raw, currentRoute, false, router)
    const { name } = location

    if (name) {
      const record = nameMap[name]
      ...
      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)
  }

match方法有兩個分支,如果跳轉路由時提供name,會從nameMap直接查找對應的RouteRecord,否則就遍歷pathList找出所有的RouteRecord,逐個嘗試匹配當前的路由。
當找到匹配的RouteRecord,接着進入_createRoute方法,創建路由對象:

const route: Route = {
    name: location.name || (record && record.name),
    meta: (record && record.meta) || {},
    path: location.path || '/',
    hash: location.hash || '',
    query,
    params: location.params || {},
    fullPath: getFullPath(location, stringifyQuery),
    matched: record ? formatMatch(record) : []
  }

關鍵看matched屬性,formatMatch會從匹配的RouteRecord一直從父級往上查找,返回一個匹配的RouteRecord數組,這個數組在嵌套router-view場景,會根據嵌套的深度選擇對應的RouteRecord。

接着第2步,確認路由切換,調起各個鉤子函數,在這一步裏面你可以中止路由切換,或者改變切換的路由等
confirmTransition方法如下:

confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    const abort = err => {
     ...
    }
    if (
      isSameRoute(route, current) &&
      // in the case the route map has been dynamically appended to
      route.matched.length === current.matched.length
    ) {
      this.ensureURL()
      return abort(new NavigationDuplicated(route))
    }
    
    //匹配的路由跟當前路由對比,找出哪些RouteRecrod是需要deactivated,哪些是需要activated
    const { updated, deactivated, activated } = resolveQueue(
      this.current.matched,
      route.matched
    )
    //跟官方文檔描述一致
    const queue: Array<?NavigationGuard> = [].concat(
      // in-component leave guards
      extractLeaveGuards(deactivated),
      // global before hooks
      this.router.beforeHooks,
      // in-component update hooks
      extractUpdateHooks(updated),
      // in-config enter guards
      activated.map(m => m.beforeEnter),
      // async components
      resolveAsyncComponents(activated)
    )

    this.pending = route
    const iterator = (hook: NavigationGuard, next) => {
      if (this.pending !== route) {
        return abort()
      }
      try {
        hook(route, current, (to: any) => {
          if (to === false || isError(to)) { //如果爲false或者出錯就中止路由
            // 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 = [] //專門爲了處理 next((vm)=> {})情況
      const isValid = () => this.current === route
      // wait until async components are resolved before
      // extracting in-component enter guards
      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()
            })
          })
        }
      })
    })
  }

這裏一開始先判斷匹配的路由跟當前路由是否一致,如果一致就直接中斷了。
然後就是匹配的路由跟當前的路由對比,找出需要updated, deactivated, activated的RouteRecord對象,緊接着就是從RouteRecord的instances(之前收集的)裏面抽出組件實例的跟路由相關的鉤子函數(beforeRouteLeave等)組成一個鉤子函數隊列,這裏隊列的順序跟官網對路由導航的解析流程完全一致的。
可以看到最後會執行兩次runQueue,第一次鉤子函數隊列會先執行leave,update相關的鉤子函數,最後是加載activated的異步組件,當所有異步組件加載成功後,繼續抽取beforeRouteEnter的鉤子函數,對於enter相關的鉤子函數處理是有點不一樣的,正如文檔說的,beforeRouteEnter方法裏面是沒辦法使用組件實例的,因爲第二次runQueue時,明顯組件的都還沒有被構建出來。所以文檔也提供另外一種方法獲取組件的實例:

beforeRouterEnter(from, to, next) {
    next((vm) => {
    });
}

那麼Vue-Router是怎麼把vm傳給方法的尼,主要是在抽取enter相關鉤子的時候處理了:

function bindEnterGuard (
  guard: NavigationGuard,
  match: RouteRecord,
  key: string,
  cbs: Array<Function>,
  isValid: () => boolean
): NavigationGuard {
  return function routeEnterGuard (to, from, next) {
    return guard(to, from, cb => {
      if (typeof cb === 'function') {
        cbs.push(() => {
          // #750
          // if a router-view is wrapped with an out-in transition,
          // the instance may not have been registered at this time.
          // we will need to poll for registration until current route
          // is no longer valid.
          poll(cb, match.instances, key, isValid)
        })
      }
      next(cb)
    })
  }
}
function poll (
  cb: any, // somehow flow cannot infer this is a function
  instances: Object,
  key: string,
  isValid: () => boolean
) {
  if (
    instances[key] &&
    !instances[key]._isBeingDestroyed // do not reuse being destroyed instance
  ) {
    cb(instances[key])
  } else if (isValid()) {
    setTimeout(() => {
      poll(cb, instances, key, isValid)
    }, 16)
  }
}

當判斷next傳入的是一個function時,它就把這個function放到postEnterCbs數組上,然後在$nextTick等組件掛載上去的時候調用這個function,就能順利獲取到創建的組件實例了;但是還有一種情況需要處理的,就是存在out-in transtion的時候,組件會延時掛載,所以Vue-Router在poll方法上直接用了一個16毫秒的setTimeout去輪詢獲取組件的實例(真是簡單粗暴)。

最後一步,當所有鉤子函數毫無意外都回調完畢,就是更新當前路由,確保當前的url跟更新的路由一致了。
在一般情況下,我們代碼觸發的路由切換,當我們使用next(false)中斷,我們完全可以直接中止對URL操作,避免不一致的情況發生。
另外一種情況我們使用瀏覽器後退的時候,URL會立即改變,然後我們使用next(false)去中斷路由切換,這個時候URL就會跟當前路由不一致了,這個時候ensureURL是怎麼保證路由一致的尼,其實也很簡單:

  ensureURL (push?: boolean) {
    const current = this.current.fullPath
    if (getHash() !== current) {
      push ? pushHash(current) : replaceHash(current)
    }
  }

先判斷路由路徑是否一致,不一致的話,按照剛剛說的場景就會把當前路由重新push進去(解開多年的疑惑,哈哈)。

總結

雖然最後發現我遇到的坑,跟Vue-Router好像沒多大關係,但是閱讀源碼也是收穫良多,瞭解到文檔上很多沒有介紹到的細節;噢,對了,Vue3.0源碼也開放了,又得加班加點啃源碼了,今天先到這裏,如有錯漏,還望指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章