1. 程式人生 > 其它 >VueRouter 原始碼記錄 - 搞懂 router hooks 和 RouterView 更新的底層邏輯

VueRouter 原始碼記錄 - 搞懂 router hooks 和 RouterView 更新的底層邏輯

結構

起因是接手了一個第三方團隊的 Vue 專案,但是它的路由 active/expand 狀態渲染的實現居然是靠 watch $route,然後再寫入 sessionStroage,最後再在頁面元件中需獲取 sessionStroage 的 route 來實現。最後出線了 route 更新了,元件也更新了,但是路由的 active 狀態不對。

我一看這種程式碼,哪裡要得,要重寫。選單明明要支援自動 active 和自動 展開/收起 子選單才對,但是這個bug又比較有趣,於是順路去把原始碼讀了一遍,之前也讀過,但今時不同往日,溫故知新。

主要類及方法:

  • VueRouter
    • history
      • HTML5History

        BaseHistory

      • HashHistory

        BaseHistory

      • AbstractHistory

        BaseHistory

BaseHistory 定義了一部分介面,也實現了一些方法:

  • BaseHistory
    • properties
      • router: Router
      • base: string
      • current: Route
      • pending: ?Route
      • cb: (r: Route) => void
      • ready: boolean
      • readyCbs: Array
      • readyErrorCbs: Array
      • errorCbs: Array
      • listeners: Array
      • cleanupListeners: Function
      • setupListeners: Function
    • Interface(implemented by sub-classes)
      • go: (n: number) => void
      • push: (loc: RawLocation, onComplete?: Function, onAbort?: Function) => void
      • replace: (loc: RawLocation, onComplete?: Function, onAbort?: Function) => void
      • ensureURL: (push?: boolean) => void
      • getCurrentLocation: () => string
      • setupListeners: Function
    • Implementation
      • onReady()
      • onError(errorCb)
      • transitionTo(location: Location, onComplete?: Function, onAbort?: Function)
      • confirmTransition(route: Route, onComplete?: Function, onAbort?: Function)
      • runQueue(queue, iterator, () ⇒ {})
      • updateRoute(route: Route)
      • teardown()

HashHistory 繼承自 BaseHistory,實現了和重寫了一部分方法:

  • class HashHistory extends History
    • constructor

      例項化父類 super,同時執行 checkFallbackensureSlash 方法

    • setupListeners: Function

      • 設定事件監聽
        • this.listeners 中新增滾動監聽函式 setupScroll ,用以監聽 popstate 事件並以 getStateKey 為 key 儲存滾動位置 saveScrollPosition
        • 在 window 攔截 supportsPushState ? 'popstate' : 'hashchange' 事件,並執行 handleRoutingEvent 函式
        • handleRoutingEvent 函式,執行 this.transitionTo(getHash, router => {}) 函式。
        • 而在 transitionTo 函式內部執行 handleScroll 用以滾動到已儲存到 scrollPosition: { x: number, y: number }. 且如果不支援 supportsPushState 就執行 replaceHash(route.fullPath) 函式走 hashchange.
      • 然後在 this.listeners 中新增 removeEventListener 登出事件監聽
    • push: (location: RawLocation, onComplete?: Function, onAbort?: Function)

      內部實現就是呼叫 this.transitionTo 函式,在完成之後 replaceHash, handleScroll, 執行 onComplete 回撥函式

    • go(n: number) { window.history.go(n) }

      直接執行 window.history.go 走瀏覽器 history 的原生行為。

    • ensureURL (push?: boolean)

      確保瀏覽器的 url 與 router 行為一致,所有內部做了判斷 this.current.fullPath !== gethash()push ? pushHash(current) : replaceHash(current),就是 history.pushState({ key: getStateKey() }, '', url)history.replaceState({ key: getStateKey() }, '', url)

    • getCurrentLocation()

      返回當前位置,內部直接返回了 getHash()

看到核心的函式是 transitionTo,所以去看看 BaseHistory 的 transitionTo 實現。

  • 第一步是通過 VueRouter 的 match 取到在 路由登錄檔 中的 route 定義this.router.match(location, this.current)

  • 而 VueRouter 的 match 方法則是呼叫內部的 matcher.match 方法,this.matcher.match(raw, current, redirectedFrom)

    • 而 matcher 則是 VueRouter 的例項屬性,由工廠函式 createMatcher(options.routes || [], this) 建立
    • createMatcher 函式內部的定義及實現:
      • createRouteMap 函式

        createRouteMap (
            routes: Array<RouteConfig>,
            oldPathList?: Array<string>,
            oldPathMap?: Dictionary<RouteRecord>,
            oldNameMap?: Dictionary<RouteRecord>
        ): {
            pathList: Array<string>,
            pathMap: Dictionary<RouteRecord>,
            nameMap: Dictionary<RouteRecord>
        }
        

        接收 routes 返回各種對映,pathList, pahtMap, nameMap。這些對映都是為了支援通過 pathMap or nameMap 快速找到 route 宣告。

      • 所以需要迴圈一下 routes 陣列 routes.forEach(route => { addRouteRecord(pathList, pathMap, nameMap, route) })

      • addRouteRecord 函式生成 RouteRecord 資料結構,遞迴 route.children,並記錄到 pathList, pathMap,nameMap 中。最終 RouteRecord 物件的資料結構:

        record = {
        			// 組裝後的路徑
              path: normalizedPath,
        			// 通過 pathToRegexpOptions 選項將路由的 normalizedPath 編譯成正則表示式
              regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
        			// 路由元件
              components: route.components || { default: route.component },
        			// 例項快取,用於 keepalive
              instances: {},
        			// 路由名稱
              name: name,
        			// 理由 parent 引用,即 parentRoute
              parent: parent,
        			// 用於 alias 匹配的 matchAs
              matchAs: matchAs,
        			// 重定向
              redirect: route.redirect,
        			// route 聲名處的 beforeEnter hook
              beforeEnter: route.beforeEnter,
              meta: route.meta || {},
              props:
                route.props == null
                  ? {}
                  : route.components
                    ? route.props
                    : { default: route.props }
            }
        
  • 最終返回的是 return *createRoute(record, location, redirectedFrom),而在 _*createRoute 方法內部包含了對 redirect, alias 的操作,最終返回是 return createRoute(record, location, redirectedFrom, router), 即最終的經過處理後的 Route 物件

    function createRoute (
        record,
        location,
        redirectedFrom,
        router
      ) {
        var stringifyQuery = router && router.options.stringifyQuery;
    
        var query = location.query || {};
        try {
          query = clone(query);
        } catch (e) {}
    
        var route = {
          name: location.name || (record && record.name),
          meta: (record && record.meta) || {},
          path: location.path || '/',
          hash: location.hash || '',
          query: query,
          params: location.params || {},
          fullPath: getFullPath(location, stringifyQuery),
          matched: record ? formatMatch(record) : []
        };
        if (redirectedFrom) {
          route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery);
        }
        return Object.freeze(route)
      }
    
    • formatMatch 方法中則包含了所有匹配的 RouteRecord[] 路由記錄:

      function formatMatch (record) {
          var res = [];
          while (record) {
            res.unshift(record);
            record = record.parent;
          }
          return res
        }
      
  • 所以這裡拿到的 route 就是 hook 中的 to, 因此 prev = this.current 就是 from.

  • 而後函式執行了 confirmTransition (route: Route, onComplete: Function, onAbort?: Function) 方法,同時在 onComplete 和 onAbort 回撥中做了許多攔截。

所以往下先看完 confirmTransition 函式的實現,再來看 transitionTo 中的回撥。

  • 設定 route 為 pending 狀態

  • 宣告 abort 函式處理錯誤,並在函式內部出發 errorCbs forEach(error) 回撥

  • 判斷 current(即 from) 和 route(即 to)是否是 isSameRoute(route, current) 相同路由,同時兩者 matched 的 length 長度一致,且 route.matched[lastRouteIndex] === current.matched[lastCurrentIndex] ,則中止並丟擲重複導航錯誤

  • 執行 resolveQueue 找到分別 updated, deactivated, activated 的 matched 元件

    function resolveQueue (
        current,
        next
      ) {
        var i;
        var max = Math.max(current.length, next.length);
        for (i = 0; i < max; i++) {
          if (current[i] !== next[i]) {
            break
          }
        }
        return {
          updated: next.slice(0, i),
          activated: next.slice(i),
          deactivated: current.slice(i)
        }
      }
    
  • 宣告 queue: Array<?NavigationGuard> ,目的是分別取到 updated, deactivated, activated 的對應功能 hooks 函式,並迭代執行。而這就對應著 VueRouter 提供的各種 hooks 實現,router hooks & component route hooks.

    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)
        )
    
  • 迭代執行函式的宣告 iterator = (hook: NavigationGuard, next) => {} ,說明每次呼叫 hook 該函式都會被執行

    • 判斷了如果 this.pending !== route 則丟擲 abort(createNavigationCancelledError(current, route)) 錯誤
    • 接著執行傳入的引數 hook(route, current, (to) => {}) 函式
    • 第三個引數是個函式,接收引數 to ,這裡的 to 的值有多種型別:
      • 一種是上一個 hook 執行完成後返回的 boolean 值,如果是為 false 則意味著中止導航,並丟擲 abort(createNavigationAbortedError(current, route)) 錯誤
      • 第二個 else if 就是 to 是個 error
      • 另一種可能就是真正的 to: Route 即下一個路由。
    (to: any) => {
              if (to === false) {
                // next(false) -> abort navigation, ensure current URL
                this.ensureURL(true)
                abort(createNavigationAbortedError(current, route))
              } else if (isError(to)) {
                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(createNavigationRedirectedError(current, route))
                if (typeof to === 'object' && to.replace) {
                  this.replace(to)
                } else {
                  this.push(to)
                }
              } else {
                // confirm transition and pass on the value
                next(to)
              }
            })
    
  • 最後是runQueue 函式,序列化(序列)執行迭代函式。先看下 runQueue 函式本身的實現:

    • 三個引數,分別是 queue 佇列, fn 函式(可以理解為 next), cb 執行完成後的回撥
    • 關鍵是這個還需要支援 async 非同步執行,比如應用初始化的時候需要先非同步拉取使用者是否登入再決定渲染頁面或者重定向登入頁。
    // 事實上這個函式非常的經常,甚至在面試中也高頻率出現
    // 現在回看,年初面阿里菜鳥時有道題目完全可以用這個實現
    export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
      // step by step ~
    	const step = index => {
    		// index 大於等於 queue.length 表示 queue 已執行完成,執行 cb
        if (index >= queue.length) {
          cb()
        } else {
    			// 判斷 queue[index] 是否有有效值
          if (queue[index]) {
    				// 給 fn 函式傳入引數
    				// 第一個引數為當前項 queue[index]
    				// 第二個函式即是進入 next step,就是 () => step(index + 1)
            fn(queue[index], () => {
              step(index + 1)
            })
          } else {
    				// queue[index] 不存在有效值,則直接繼續進入下一步: step(index + 1)
            step(index + 1)
          }
        }
      }
      step(0)
    }
    
  • 然後此處的 runQueue 分別執行了兩次,第一次是 run beforeHook queue,在 beforeHook queue 執行完成後,第二次 run 的是合併完成後的 enterGuards.concat(resolveHooks).。resolveHooks 是新 API?在文件中找到了 Global Resolve Guards 如下:

    You can register a global guard with router.beforeResolve. This is similar to router.beforeEach, with the difference that resolve guards will be called right before the navigation is confirmed, after all in-component guards and async route components are resolved.

    它解釋了該 hook 是在所有元件內守衛和非同步路由元件被解析之後呼叫。

    runQueue(queue, iterator, () => {
          // wait until async components are resolved before
          // extracting in-component enter guards
          const enterGuards = extractEnterGuards(activated)
          const queue = enterGuards.concat(this.router.resolveHooks)
    
          runQueue(queue, iterator, () => {
            if (this.pending !== route) {
              return abort(createNavigationCancelledError(current, route))
            }
            this.pending = null
            onComplete(route)
            if (this.router.app) {
              this.router.app.$nextTick(() => {
                handleRouteEntered(route)
              })
            }
          })
        })
    
  • The Full Navigation Resolution Flow

    https://router.vuejs.org/guide/advanced/navigation-guards.html#in-component-guards

    1. Navigation triggered.
    2. CallbeforeRouteLeaveguards in deactivated components.
    3. Call globalbeforeEachguards.
    4. CallbeforeRouteUpdateguards in reused components.
    5. CallbeforeEnterin route configs.
    6. Resolve async route components.
    7. CallbeforeRouteEnterin activated components.
    8. Call globalbeforeResolveguards.
    9. Navigation confirmed.
    10. Call globalafterEachhooks.
    11. DOM updates triggered.
    12. Call callbacks passed tonextinbeforeRouteEnterguards with instantiated instances.

核心型別

  • Location
  • RouteRecord

核心功能

  • 滾動監聽和記錄 - scrollPostion
  • 監聽瀏覽器 history - 'hashchange' | 'popState'
  • 路由監聽和渲染 - 'history.current ⇒ _route ⇒ $route' | '_routerRoot ⇒ $router' | RouterLink | RouterView
  • 哨兵鉤子函式(router & component NavigationGuard hooks)

路由的狀態

有點 this.history.current 變化,維護的幾個地方需要變化:

  • 路由 _route 和 $route 被更新
    • 對應 route.matched 的所有元件將被 resolved 然後 render
  • 瀏覽器位址列 path 被更新
  1. VueRouter
    1. this.$router/this.$route
    2. RouterView/RouterLink
  2. Window.history
    1. pushState/replaceState(getUrl(path)) - 當支援 supportsPushState 時
    2. window.location.hash = path

生命週期

當使用 Vue.use(VueRouter) 時候,VueRouter 為 Vue 注入了全域性 mixin,用於給例項寫入 routerroute 相關屬性。

	var registerInstance = function (vm, callVal) {
      var i = vm.$options._parentVnode;
      if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
        i(vm, callVal);
      }
    }

	Vue.mixin({
      beforeCreate: function beforeCreate () {
				// 呼叫 nwe Vue({ router }) 時傳入 router 屬性的 Vue 例項
				// 通常是我們的 RootApp, 就是 ./main.js 下宣告 Vue 入口例項
        if (isDef(this.$options.router)) {
					// 掛載 _routerRoot 屬性
          this._routerRoot = this;
					// 掛載 this._routerRoot._router 屬性 => 見下方宣告 this.$router
          this._router = this.$options.router;
					// 執行 router.init 初始化方法,監聽瀏覽器 history
          this._router.init(this);
					// 掛載 this._routerRoot._route 屬性 => 見下方宣告 this.$route
					// 這裡使用了 defineReactive 定義 reactive 狀態
          Vue.util.defineReactive(this, '_route', this._router.history.current);
        } else {
					// 未傳入 router 例項的 Vue 初始化
          this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
        `

				// 註冊例項,通常是 RouterView 才會用到這裡?
				// 這個方法主要用於記錄 route.matched[depth].instances[name] = 
        registerInstance(this, this);
      },
      destroyed: function destroyed () {
        registerInstance(this);
      }
    })

	// 宣告全域性屬性 $router 時返回 _routerRoot._router
	Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })
	// 宣告全域性屬性 $route 時返回 _routerRoot._route
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })

在執行 init 的時賦值了 router.app 例項屬性, transitionTo current location 並 setupListeners 。這裡有個很重要的是添加了 listener 更新 apps 陣列中每個 app 例項的 _route 屬性:

history.listen(route => {
      this.apps.forEach(app => {
        app._route = route
      })
    })

history.listen 對應著 BaseHistory.listen 方法:

listen (cb: Function) {
    this.cb = cb
  }

this.cb 方法又僅在每次的 updateRoute 時候呼叫:

updateRoute (route: Route) {
    this.current = route
    this.cb && this.cb(route)
  }

updateRoute 是在每次confirmTransition 之後被執行:

this.confirmTransition(
      route,
      () => {
        this.updateRoute(route)
        onComplete && onComplete(route)
				// ...
      },
      err => {
        // ...
      }
    )

所以,在每次 路由 發生變化之後,$route 屬性對應的 _route 被更新。所以在 install.js 中的 beforeCreate 鉤子中例項訪問的是 init 之後最新的 history.current。而後每次,則訪問的是直接被賦值的 app._route = route

然後因為之前使用 Vue.util.defineReactive_route 屬性添加了 reactive 特性,所以在重新賦值的時候觸發了 dep.notify(),通知所有對 $routesubs: Watcher[].

呼叫對應組建的 _update 方法 rerender 重新渲染,更新整體檢視。