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 走浏览器 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. Call beforeRouteLeave guards in deactivated components.
    3. Call global beforeEach guards.
    4. Call beforeRouteUpdate guards in reused components.
    5. Call beforeEnter in route configs.
    6. Resolve async route components.
    7. Call beforeRouteEnter in activated components.
    8. Call global beforeResolve guards.
    9. Navigation confirmed.
    10. Call global afterEach hooks.
    11. DOM updates triggered.
    12. Call callbacks passed to next in beforeRouteEnter guards 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 重新渲染,更新整体视图。

posted @ 2021-07-29 22:02  月光宝盒造梦师  阅读(122)  评论(0编辑  收藏  举报