Analysis of Vue router source code

Posted by drisate on Wed, 30 Oct 2019 20:00:58 +0100

start

First of all, it was broken up. After changing jobs, I finally had some leisure time. Suddenly, I realized that I hadn't written a blog for more than a year. After thinking about this time, I didn't seem to have much new technology accumulation. I felt so ashamed that I could not confuse myself.
OK, let's get into the topic right away. Today's main character is Vue router. As a member of Vue's family, I'm sure I can't be more familiar with it, but I haven't read the source code. I don't know much about some places. Finally, I met a pit in the recent project (if I don't meet a pit, I won't go to see the source code, ha ha ha). So I spent some time. Time to learn the source code.

Route

Routing is indispensable in the development of an app. In theory, in the development of an app, the first step is to define each route: which pages need authentication to open, when to redirect the specified page, how to transfer data when the page is switched, etc. It can be imagined that the larger the application, the more important the routing will be. Routing can be said to be the skeleton of the whole application. However, the routing function of web is relatively weak compared with the development of native application. If the control is not good, there will be some inexplicable jumps and interactions.

Building Router

Directly enter the VueRouter construction process:

  1. Call createMatcher method to create route map
  2. The default is hash mode routing
  3. If history mode is selected, but the browser does not support history, the fallback option will be set to true, and the fallback option will affect the url processing in hash mode, which will be analyzed later.
  4. Select the corresponding history (hash, history, abstract) according to the current mode

Let's first look at the createMatcher method:

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

It mainly creates the pathlist through the createRouteMap method. The pathMap and nameMap are routercore objects. Vue router generates a RouteRecord object for each routing configuration item, and then returns the match and addRoutes methods.
Here, the match method returns the matching RouteRecord object through the current route path, while addRoutes can dynamically add new route configuration information, just like the official document description.

See the createRouteMap method again:

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--
    }
  }
  ...
}

Traversing all the routing configuration information, creating RouteRecord objects and adding them to pathMap,nameMap, and finally adjusting the location of wildcards in pathList, so finally unable to find the matching route accurately, and return the last wildcard's RouteRecord.

What kind of object is RouteRecord?

const record: RouteRecord = {
    path,
    regex,
    components,
    instances, //Component instance created by route
    name,
    parent, //Parent RouteRecord
    matchAs, //alias
    redirect,
    beforeEnter,
    meta,
    props, //It will be analyzed later
  }

In this way, a lot of properties of router core are consistent with our initial configured routing properties.

The key attribute of RouteRecord is that the parent will point to the parent RouteRecord object. In the nested router view scenario, when we find the matching RouteRecord, we can find the parent RouteRecord and directly match it to the same depth router view.

The instances property saves the component instances created by the route. When the route is switched, the hooks such as beforeroutleave need to be called. When are these component instances added to the instances? There are two main approaches:

  1. Component's beforeCreate hook

    Vue.mixin({
        beforeCreate () {
          ...
          registerInstance(this, this)
        },
        destroyed () {
          registerInstance(this)
        }
     })
  2. Hook function of 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
      }
    }

In some scenarios, the beforeCreate hook that only relies on the first component can't put the component instance into the instances attribute of RouteRecord correctly. Then discuss which scenarios need the second method.
In the scenario where the prepatch hook is needed, there are two routes:

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

The components of '/ a', '/b' are all Foo. If the initial route is' / a ', then a Foo instance has been generated on the virtual dom tree. When the route is switched to' / b ', because of the algorithm of virtual dom, the Foo instance will be reused and will not re create a new instance, that is, the beforeCreate hook of Foo will not be activated. In this way, the Foo instance will not be able to pass the befo. The recreate hook is added to the RouteRecord of '/ b'. But the v prepatch hook of vnode can be adjusted at this time, so you can put the Foo instance on the RouteRecord of '/ b' here.

Scenarios requiring init hooks:

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

First, set a keep alive component on the router view, and then define the route as follows:

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

When the initial route is' / a ', the Foo instance will be created and saved by the keep alive component. When the route is switched to' / b ', the Other instance will be the same as the Foo instance, and then the route will be switched to' / c '. Because of the function of the keep alive component, the Foo instance saved by' / a 'will be reused directly. When comparing the virtual dom, the reused Foo instance will be used. The virtual dom nodes of example and Other instance are totally different types, so the prepatch hook cannot be called, but the init hook can be called.

The above are some related scenarios. In fact, in some cases, Vue Router processing is not very good, because RouteRcord is unique to the whole Router instance, and the corresponding instances are also unique. If there are two or more Router views at the same depth (without hierarchy):

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

Obviously now they all point to the same RouteRecord, but they will create different component instances, but only one of them will be successfully registered on the instances property of RouteRecord. When routing is switched, the other component instance should not receive the call from the relevant routing hook, although there may be almost no such use scenario.

Another scenario may only be that we need to pay attention to when using it, because we can change the name attribute on the router view and switch the router view display. This kind of switch is not caused by the route switch, so there will be no route hook on the instance of the component. In addition, when there are multiple instances on the instances, once the route is switched, even if there is no route hook. In the instances shown in router view, the routing hooks will be set up.

When there is alias in the route, an alias route record will be created, and its matches will point to the original route path.

props is a bit special. There is not much explanation in the official documents, only in the source code.

  1. If it is an object, it can pass props to the instance when the router view creates the component instance.
  2. But if the Boolean value is true, it will pass the params object of the current route to the component instance as props.
  3. If it is a function, the current route will be passed in as a parameter and then transferred to the instance as a props.

So we can easily set props to true, and then we can pass the params of route to the component instance as props.

Now, after analyzing the RouteRecord object and creating the main process, you need to create the corresponding hash history first. However, if we choose the history mode but the browser does not support fallback to the hash mode, the URL needs to be processed additionally. For example, if the URL path in the history mode is as follows:

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

In hash mode, it will be replaced with

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

The construction of this Vue router instance is complete.

Listening routing

In the global mixin provided by Vue router, the init method of router will be called in the beforeCreate hook to officially start monitoring the route.
init method of router:

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
      })
    })
}

Here, the history mode will directly switch the route (the history mode has attached the popstate listener when building the HTML5History), and the hash mode will first set the popstate or hashchange listener before switching to the current route (because the hash mode may be descended from the history mode, and the url needs to be adjusted, so the listener will be set here later).
It can be found here that it may be different from what we think. In fact, the hash mode also gives priority to the pushstate/popstate of html5, which is not directly driven by the hash/hashchange (because pushstate/popstate can easily record the page scrolling position information).

Go directly to the core part of the transitionTo method to see how Vue router drives route switching:

 transitionTo (
    location: RawLocation,
    onComplete?: Function,
    onAbort?: Function
  ) {
    const route = this.router.match(location, this.current) //1. Find the matching route
    this.confirmTransition( //2. Execute the hook function to switch routes
      route,
      () => {
        this.updateRoute(route) //3. Update the current route
        onComplete && onComplete(route)
        this.ensureURL() //4. Ensure that the current URL is consistent with the current route

        // 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)
          })
        }
      }
    )
  }   

Roughly speaking, there are four steps:

  1. Find the matching route first
  2. Perform hook functions to switch routes (before route levave, before route update, etc.)
  3. Update current route
  4. Ensure that the current URL is consistent with the current route

Then analyze
Step 1 to match method:

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)
  }

The match method has two branches. If you provide name when you jump the route, you will directly find the corresponding RouteRecord from the nameMap. Otherwise, you will traverse the pathList to find all routerecords and try to match the current route one by one.
When a matching RouteRecord is found, enter the "createRoute" method to create a route object:

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) : []
  }

The key is the matched attribute. formatMatch will search from the matching RouteRecord all the way up from the parent to return a matching RouteRecord array. In the nested RouteRecord view scenario, the corresponding RouteRecord will be selected according to the nesting depth.

Next step 2, confirm the route switch and call up the hook functions. In this step, you can stop the route switch or change the route of the switch.
The confirmTransition method is as follows:

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))
    }
    
    //Compare the matching routes with the current ones to find out which routes need to be deactivated and which need to be activated.
    const { updated, deactivated, activated } = resolveQueue(
      this.current.matched,
      route.matched
    )
    //Consistent with the official document description
    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)) { //Abort route if false or error
            // 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 = [] //Specifically for next ((VM) = > {}) cases
      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()
            })
          })
        }
      })
    })
  }

At the beginning of this process, we will judge whether the matching route is consistent with the current route. If it is consistent, it will be interrupted directly.
Then compare the matching route with the current route, find out the RouteRecord objects that need to be updated, deactivated, and activated, and then extract the route related hook functions (beforeroutleave, etc.) of the component instances from the instances of RouteRecord to form a hook function queue. The queue order here is the solution of route navigation on the official website. The analysis process is completely consistent.
It can be seen that runQueue will be executed twice in the end. For the first time, the queue of hook function will first execute the hook function related to leave and update, and finally load the asynchronous component of activated. After all asynchronous components are loaded successfully, continue to extract the hook function of beforeRouteEnter, which is a little different for the processing of hook function related to enter. As the document says, beforeRouteEnter There is no way to use component instances in the method, because when runQueue is run for the second time, it is obvious that none of the components have been built. Therefore, the document also provides another method to obtain an instance of a component:

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

How does Vue router transfer vm to the method? It mainly deals with the following when extracting the relevant hooks of the 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)
  }
}

When it is judged that a function is passed in from next, it will put this function on the postEnterCbs array, and then call this function when the $nextTick and other components are mounted, and the created component instance will be obtained smoothly; however, there is another situation that needs to be handled, that is, when there is out in transition, the component will delay mounting, so Vue router is in the poll side. It uses a 16 millisecond setTimeout to poll for the instance of the component.

The last step is to update the current route and make sure that the current url is consistent with the updated route when all hook functions call back without exception.
In general, when we use next(false) interrupt for routing switch triggered by our code, we can stop URL operation directly to avoid inconsistency.
In another case, when we use the browser to back up, the URL will change immediately, and then we use next(false) to interrupt the route switching. At this time, the URL will be inconsistent with the current route. How to ensure the route consistency of the secureurl at this time is actually very simple:

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

First, judge whether the route is consistent. If it is inconsistent, the current route will be re push ed according to the scenario just mentioned (to solve the doubts for many years, ha ha).

summary

Although it seems that the hole I encountered at last has nothing to do with Vue router, but reading the source code is also a lot of gains, and I learned a lot of details that were not introduced in the document; Oh, by the way, the source code of Vue3.0 is also open, and I have to work overtime to nibble at the source code. I'll go here first today, if there are any mistakes or omissions, please correct them.

Topics: Javascript Vue Attribute html5