This is the second day of my participation in the August More text Challenge. For details, see: August More Text Challenge

preface

Today we are going to implement matcher, one of the key components of Vue Router.

The Vue Router’s Matcher implements some very important apis:

  • Match () calculates a new route based on the incoming route and the current route.
  • AddRoutes () can dynamically add more routing rules. Deprecated: Router.addroute () is recommended.
  • AddRoute () Adds a new route rule.
  • GetRoutes () gets a list of all active route records.

Router-link can be used to jump to a specific route interface, but how do we know which URL corresponds to the specific contents of the View? In other words, how do you associate a URL with a View? That is, establish the mapping relationship between URL and View. If we know the mapping, then we just need to update the corresponding view content when the URL changes. That’s what matcher.match does!

Let’s add a new matcher to the VueRouter class:

// New code
import { createMatcher } from './create-matcher'
import { install } from "./install";
import { HashHistory } from "./history/hash";

export default class VueRouter {
  constructor(options = {}) {
    // Get the user's incoming configuration
    this.options = options;
    // this.app represents the root Vue instance
    this.app = null;
    //this.apps holds Vue instances for all child components
    this.apps = []; 
    // New code
    this.matcher = createMatcher(options.routes || [], this);
    this.mode = options.mode || "hash";
    // Implement front-end routing in different modes
    switch (this.mode) {
      case "hash":
        this.history = new HashHistory(this, options.base);
        break;
      default:
        return new Error(`invalid mode: The ${this.mode}`); }}// New code
  match(raw, current, redirectedFrom) {
    return this.matcher.match(raw, current, redirectedFrom);
  }

  init(app) {
    this.apps.push(app);
    // Only the root Vue instance will be saved to this.app
    if (this.app) {
      return;
    }
    // Save the Vue instance
    this.app = app;

    const history = this.history;
    if (history instanceof HashHistory) {
      // Add a route event listener function
      const setupListeners = () = > {
        history.setupListeners();
      };
      // Perform route transition
      history.transitionTo(
        history.getCurrentLocation(),
        setupListeners
      );
    }
  }
}
VueRouter.install = install;
Copy the code

createMatcher

CreateMatcher is a factory function that can be called to get match, addRoutes, getRoutes, addRoutes, etc.

Create new – the matcher. Js

//src/vRouter/create-matcher.js

export function createMatcher(routes, router) {

  // The addRoutes method is used to dynamically add more routing rules. The argument must be an array that matches the routes option.
  function addRoutes(routes) {}

  // Add a new routing rule. If the routing rule has a name and one already exists with the same name, it is overridden.
  function addRoute(parentOrRoute, route) {}

  / / returns the pathMap
  function getRoutes() {}

  function match(raw, currentRoute) {}

  return {
    match,
    addRoute,
    getRoutes,
    addRoutes,
  };
}

Copy the code

createRouteMap

The createMatcher method also uses the createRouteMap function. CreateRouteMap translates the user’s route configuration into a route mapping table. Then, we can make matching rules based on the user-defined route name or path.

//src/vRouter/create-matcher.js

import { createRouteMap } from "./create-route-map";

export function createMatcher(routes, router) {
  / / new
  const { pathList, pathMap, nameMap } = createRouteMap(routes);
  // The addRoutes method is used to dynamically add more routing rules. The argument must be an array that matches the routes option.
  function addRoutes(routes) {}

  // Add a new routing rule. If the routing rule has a name and one already exists with the same name, it is overridden.
  function addRoute(parentOrRoute, route) {}

  / / returns the pathMap
  function getRoutes() {}

  function match(raw, currentRoute) {}

  return {
    match,
    addRoute,
    getRoutes,
    addRoutes,
  };
}
Copy the code

createRouteMap.js

// src/vRouter/create-route-map.js

// The vue-router source code uses the "path-to-regexp" tool to match the path.
import { pathToRegexp } from "path-to-regexp";

export function createRouteMap(
  routes, // User route configuration
  oldPathList,
  oldPathMap,
  oldNameMap,
  parentRoute
) {
  // Why use object.create (null) to create an Object instead of {}?
  // Since object.create (null) creates an Object that has no superfluous properties, it is a "clean" Object.
  // When we use for.. When I loop in, I'm only going to go through the properties that I've defined.
  const pathList = oldPathList || [];
  const pathMap = oldPathMap || Object.create(null);
  const nameMap = oldNameMap || Object.create(null);

  // Add a route record to the loop routing configuration table
  routes.forEach((route) = > {
    addRouteRecord(pathList, pathMap, nameMap, route, parentRoute);
  });

  return {
    pathList,
    pathMap,
    nameMap,
  };
}

function addRouteRecord(pathList, pathMap, nameMap, route, parent) {
  const { path, name } = route;

  const normalizedPath = normalizePath(path, parent);
  const regexPath = pathToRegexp(normalizedPath);
  const record = {
    path: normalizedPath,
    regex: regexPath,
    components: route.components || { default: route.component }, // Use default
    name,
    parent,
    meta: route.meta || {},
  };
  if (route.children) {
    route.children.forEach((child) = > {
      addRouteRecord(pathList, pathMap, nameMap, child, record);
    });
  }

  if(! pathMap[record.path]) { pathList.push(record.path); pathMap[record.path] = record; }if (name) {
    if(! nameMap[name]) { nameMap[name] = record; }}}function normalizePath(path, parent) {
  path = path.replace(/ / / $/."");
  if (path[0= = ="/") return path;
  if (parent == null) return path;
  // Remove redundant "/" when concatenating parent and child routes
  return `${parent.path}/${path}`.replace(/\/\//g."/");
}

Copy the code

match

The match method computes the incoming raw and current path currentRoute and generates a route through the createRoute method. The route object contains: Name, path, Query, params, hash, etc. In other words, a route object is matched by the current URL.

The match method in the source code also has a third parameter, redirectedFrom, which is related to redirection. In order to clarify the main logic, we will ignore it here.

Improving the Match method

// src/vRouter/create-matcher.js

import { createRoute } from "./util/route";
import { createRouteMap } from "./create-route-map";
import { normalizeLocation } from "./util/location";

export function createMatcher(routes, router) {
  // Return the user-defined route configuration mapping
  const { pathList, pathMap, nameMap } = createRouteMap(routes);

  function addRoutes(routes) {}

  function addRoute(parentOrRoute, route) {}

  function getRoutes() {}

  function match(raw, currentRoute) {
    const location = normalizeLocation(raw, currentRoute, false, router);
    const { name } = location;

    // Match the case where the route object contains "name"
    if (name) {
      const record = nameMap[name];
      if(! record) {console.warn(`Route with name '${name}' does not exist`);
        return _createRoute(null, location);
      }
      if (typeoflocation.params ! = ="object") {
        location.params = {};
      }
      return _createRoute(record, location);
    } else if (location.path) {
      // Match the route object with "path"
      location.params = {};
      for (let i = 0; i < pathList.length; i++) {
        const path = pathList[i];
        const record = pathMap[path];
        // Use the location.regex rule to match location.path or location.params
        if (matchRoute(record.regex, location.path, location.params)) {
          return_createRoute(record, location); }}}//_createRoute calls the createRoute method, which returns one of the routes
    // In addition to the route that describes Loctaion's path, query, and hash, the route matched represents all the RouterEcords matched.
    return _createRoute(null, location);
  }

  function _createRoute(record, location) {
    return createRoute(record, location, router);
  }

  return {
    match,
    addRoute,
    getRoutes,
    addRoutes,
  };
}
Copy the code

The first look at Match took a long time, because the process inside the match method was quite extensive. Beginners recommend running through the process a few more times with the source code break points to get a better understanding.

Let me analyze the internal process of Match:

  1. Call the normalizeLocation method to get a structured Location object
  2. If the location contains a name attribute, use nameMap to match the record
  3. If the location contains a path attribute, use location.regex to match the record
  4. Finally, the createRoute method is called to generate a “frozen” route object, which is the current matched object representing the location of the route.

normalizeLocation

NormalizeLocation method calculates a new location object based on raw and currentRoute: {_normalized: true, path, Query, hash,} For example: “/foo? Foo =foo&bar=bar#hello”, its path is /foo, query is {foo:foo,bar:bar}

There are several other important methods used in the normalizeLocation method:

  • The parsePath() method resolves whether the passed path has query parameters, etc., returning an object {path, query, hash}
  • The resolvePath() method resolves relative paths
  • The resolveQuery() method further processes parsedpath.query and returns a Query object
// src/vRouter/util/location.js

import { parsePath, resolvePath } from "./path";
import { resolveQuery } from "./query";
import { extend } from "./misc";

export function normalizeLocation(raw, current, append) {
  //next => {name: 'user', params: {userId: 123}}
  // <router-link :to="{ name: 'user', params: { userId: 123 }}">User</router-link>

  let next = typeof raw === "string" ? { path: raw } : raw;
  if (next._normalized) {
    return next;
  } else if (next.name) {
    // Handle raw with name
    next = extend({}, raw);
    const params = next.params;
    if (params && typeof params === "object") {
      next.params = extend({}, params);
    }
    return next;
  }

  // Return an object {path, query, hash}
  // For example: /foo? {hash: "", path: "/foo", query: "plan=private"}
  const parsedPath = parsePath(next.path || "");
  // The default base path is /
  const basePath = (current && current.path) || "/";
  //resolvePath can resolve relative path,
  const path = parsedPath.path
    ? resolvePath(parsedPath.path, basePath, append || next.append)
    : basePath;

  // The resolveQuery method further processes parsedpath. query and returns a query object
  const query = resolveQuery(parsedPath.query, next.query);

  let hash = next.hash || parsedPath.hash;
  if (hash && hash.charAt(0)! = ="#") {
    hash = ` #${hash}`;
  }

  return {
    _normalized: true,
    path,
    query,
    hash,
  };
}

Copy the code

The resolvePath() method and the parsePath() method are both commented in detail, so I won’t mention them here. In fact, the main URL is some auxiliary processing functions.

// src/vRouter/util/path.js

export function resolvePath(relative, base, append) {
  const firstChar = relative.charAt(0);
  if (firstChar === "/") {
    return relative;
  }
  if (firstChar === "?" || firstChar === "#") {
    return base + relative; // "/foo"
  }
  const stack = base.split("/"); / / / ' ' ' '
  if(! append || ! stack[stack.length -1]) {
    stack.pop(); / / / '
  }
  // Resolve the relative path
  // Set "foo" => ["foo"]
  const segments = relative.replace(/ ^ / / /."").split("/");
  for (let i = 0; i < segments.length; i++) {
    const segment = segments[i];
    if (segment === "..") {
      stack.pop();
    } else if(segment ! = =".") {
      stack.push(segment); // ["", "foo"]}}// Make sure "/" exists
  if (stack[0]! = ="") {
    stack.unshift("");
  }
  // ["", "foo"].join("/") => "/foo"
  return stack.join("/");
}

export function parsePath(path) {
  let hash = "";
  let query = "";

  const hashIndex = path.indexOf("#");
  if (hashIndex >= 0) {
    hash = path.slice(hashIndex);
    path = path.slice(0, hashIndex);
  }

  const queryIndex = path.indexOf("?");
  if (queryIndex >= 0) {
    query = path.slice(queryIndex + 1);
    path = path.slice(0, queryIndex);
  }

  return {
    path,
    query,
    hash,
  };
}

export function cleanPath(path) {
  return path.replace(/\/\//g."/");
}

Copy the code

The resolveQuery() method resolves the query parameter in the URL. For example: “? Plan =private&foo=bar” will generate a query object: {plan: private, foo: bar}

// src/vRouter/util/query.js 

export function resolveQuery(query, extraQuery = {}) {
  // Construct the query parameters in the URL using the parseQuery method
  // For example:? Plan =private&foo=bar will generate a query: {plan: private, foo: bar}
  let parsedQuery = parseQuery(query || "");
  for (const key in extraQuery) {
    const value = extraQuery[key];
    parsedQuery[key] = Array.isArray(value)
      ? value.map(castQueryParamValue)
      : castQueryParamValue(value);
  }
  return parsedQuery;
}

// After castQueryParamValue, the value type of val becomes String. For example: [1,2,3] => ["1", "2", "3"]
const castQueryParamValue = (value) = >
  value == null || typeof value === "object" ? value : String(value);

function parseQuery(query) {
  //query = "? plan=private&foo=bar"
  const res = {};

  // Remove the string beginning? # | | &
  query = query.trim().replace(/ ^ (\? # | | &) /."");

  if(! query) {return res;
  }
  //query = ["plan=private", "foo=bar"]
  query.split("&").forEach((param) = > {
    / / URL + number for space "plan = private". The split (" = ") = > (" the plan ", "private")
    const parts = param.replace(/\+/g."").split("=");
    // The first element in ["plan", "private"] pops up as the key
    const key = decodeURIComponent(parts.shift());
    // Join ["private"] into a string as val using join("=")
    const val = parts.length > 0 ? decodeURIComponent(parts.join("=")) : null;

    if (res[key] === undefined) {
      res[key] = val; //{plan: private}
    } else if (Array.isArray(res[key])) {
      // Push val if the value is an array
      res[key].push(val);
    } else{ res[key] = [res[key], val]; }});return res;
}

Copy the code

conclusion

The match() method calculates the latest route from the path string or target location object passed in by the user and the current route object. So we now have a mapping from URL –> View.

And in the normalizeLocation method we also implement the relative path resolution and path with query parameters. So we can see why the official document says that if we provide both path and params, params will be ignored.

Here are some recommended route navigation practices:

/ / string
router.push('home')

/ / object
router.push({ path: 'home' })

// The named route
router.push({ name: 'user'.params: { userId: '123' }})

// With query parameters, change to /register? plan=private
router.push({ path: 'register'.query: { plan: 'private' }})


const userId = '123'
router.push({ name: 'user'.params: { userId }}) // -> /user/123
router.push({ path: `/user/${userId}` }) // -> /user/123
// Params are not valid here
router.push({ path: '/user'.params: { userId }}) // -> /user

Copy the code

The same rules apply to the to attribute of the router-link component.

Next up

With Vue Router and Matcher implemented, we have a mapping between the routing configuration table and the view. That is, we know that each URL corresponds to the view content to be rendered. But how do we get the corresponding view updated when we change the URL? See you next time for more details.

Vue-router source code

  • Handwritten VueRouter source series 1: implement VueRouter
  • Handwritten Vue Router source series two: matcher implementation