Vue Router: how to implement a front-end routing?

Posted by inosent1 on Wed, 08 Dec 2021 01:19:23 +0100

President Liu returns again. Today, the military industry and lithium battery are catching up, which has injected their own wages and strength into China's economy

I believe that for front-end development engineers with a certain foundation, routing is no stranger. It originally originated from the server. In the server, routing describes the mapping relationship between URL and processing function.

In the Web front-end single page application SPA, routing describes the mapping relationship between URL and view. This mapping is one-way, that is, the change of URL will cause the update of view.

Compared with back-end routing, the advantage of front-end routing is that there is no need to refresh the page, which reduces the pressure on the server and improves the user experience. At present, the mainstream front-end frameworks supporting single page applications basically have supporting or third-party routing systems. Accordingly, Vue.js also provides the official front-end routing implementation Vue Router.

Vue.js 3.0 Matching Vue Router The source code is here. I suggest you put the source code before learning clone Come down. If you don't know how to use routing, I suggest you look at its official website document first.

Basic usage of routing

Let's look at the basic usage of routing through a simple example. I hope you can also use the Vue cli scaffold to create a Vue.js 3.0 project and install the 4.x version of Vue Router to run the project.

Note that in order for Vue.js to compile templates online, you need to configure vue.config.js in the root directory and set runtimeCompiler to true:
Copy code

module.exports = {
  runtimeCompiler: true
}

Then we modify the HTML template of the page and add the following code:

<div id="app">
  <h1>Hello App!</h1>
  <p>
    <router-link to="/">Go to Home</router-link>
    <router-link to="/about">Go to About</router-link>
  </p>
  <router-view></router-view>
</div>

RouterLink and RouterView are built-in components of Vue Router.

RouterLink represents the navigation component of the route. We can configure the to attribute to specify the link it jumps to. It will eventually render and generate a label on the page.

RouterView represents the view component of the route. It will render the Vue component corresponding to the path and also supports nesting.

The specific implementation of RouterLink and RouterView will be analyzed later.

After having the template, let's look at how to initialize the route:

import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
// 1. Define routing components
const Home = { template: '<div>Home</div>' }
const About = { template: '<div>About</div>' }
// 2. Define routing configuration and map a routing view component for each path
const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
]
// 3. Create a routing instance. You can specify the routing mode and pass in the routing configuration object
const router = createRouter({
  history: createWebHistory(),
  routes
})
// 4. Create app instance
const app = createApp({
})
// 5. Install the routing before mounting the page
app.use(router)
// 6. Mount page
app.mount('#app')

You can see that the route initialization process is very simple. First, you need to define a route configuration, which is mainly used to describe the mapping relationship between the path and components, that is, what route components should be rendered by RouterView under what path.

Then create a routing object instance, pass in the routing configuration object, and specify the routing mode. Vue Router currently supports three modes: hash mode, HTML5 mode and memory mode. We commonly use the first two modes.

Finally, before mounting the page, we need to install routing, so that we can access routing objects in various components and use routing's built-in components RouterLink and RouterView.

After knowing the basic usage of Vue Router, we can explore its implementation principle. Because the Vue Router source code adds up to thousands of lines, I will focus on the overall implementation process rather than the implementation details.
Implementation principle of routing

We first analyze it from the perspective of user use, starting with the creation process of routing objects.
Creation of routing objects

Vue Router provides a createRouter API through which you can create a routing object. Let's look at its implementation:

function createRouter(options) {
  // Define some auxiliary methods and variables 
  
  // ...
  
  // Create router object
  const router = {
    // current path
    currentRoute,
    addRoute,
    removeRoute,
    hasRoute,
    getRoutes,
    resolve,
    options,
    push,
    replace,
    go,
    back: () => go(-1),
    forward: () => go(1),
    beforeEach: beforeGuards.add,
    beforeResolve: beforeResolveGuards.add,
    afterEach: afterGuards.add,
    onError: errorHandlers.add,
    isReady,
    install(app) {
      // Install routing function
    }
  }
  return router
}

We have omitted most of the code and only retained the code related to the routing object. We can see that the routing object router is an object, which maintains the current path currentRoute and has many auxiliary methods.

At present, you only need to know so much. After creating the routing object, let's install it now.
Installation of routing

Vue Router is a Vue plug-in. When we execute app.use(router), we actually execute the router's install method to install the route, and pass in the app as a parameter to see its definition:

const router = {
  install(app) {
    const router = this
    // Register routing component
    app.component('RouterLink', RouterLink)
    app.component('RouterView', RouterView)
    // Global configuration definition $router and $route
    app.config.globalProperties.$router = router
    Object.defineProperty(app.config.globalProperties, '$route', {
      get: () => unref(currentRoute),
    })
    // Initialize navigation on the browser side
    if (isBrowser &&
      !started &&
      currentRoute.value === START_LOCATION_NORMALIZED) {
      // see above
      started = true
      push(routerHistory.location).catch(err => {
        warn('Unexpected error when starting the router:', err)
      })
    }
    // Path becomes responsive
    const reactiveRoute = {}
    for (let key in START_LOCATION_NORMALIZED) {
      reactiveRoute[key] = computed(() => currentRoute.value[key])
    }
    // Global injection router and reactiveRoute
    app.provide(routerKey, router)
    app.provide(routeLocationKey, reactive(reactiveRoute))
    let unmountApp = app.unmount
    installedApps.add(app)
    // When uninstalling an application, you need to do some route cleaning
    app.unmount = function () {
      installedApps.delete(app)
      if (installedApps.size < 1) {
        removeHistoryListener()
        currentRoute.value = START_LOCATION_NORMALIZED
        started = false
        ready = false
      }
      unmountApp.call(this, arguments)
    }
  }
}

During the installation of routing, we need to remember the following two things.

Global registration RouterView and RouterLink Components - this is why you can use these two components in any component after installing routing. If you use RouterView perhaps RouterLink When you receive a prompt, you can't parse it router-link and router-view,This means that you have not installed routing at all.

adopt provide Mode global injection router Object and reactiveRoute Object, where router Indicates that the user passed createRouter Create a routing object through which we can dynamically operate routing, reactiveRoute Represents a responsive path object, which maintains path related information.

So far, we have learned about the creation of routing objects and the installation of routing, but the implementation of front-end routing still needs to solve several core problems: how to manage the path and how to map the rendering of path and routing components.

Then, let's take a more detailed look and solve these two problems in turn.
Path management

The routing infrastructure is that a path corresponds to a view. When we switch paths, the corresponding views will also switch. Therefore, a very important aspect is path management.

First, we need to maintain the current path currentRoute and give it an initial value START_LOCATION_NORMALIZED, as follows:

const START_LOCATION_NORMALIZED = {
  path: '/',
  name: undefined,
  params: {},
  query: {},
  hash: '',
  fullPath: '/',
  matched: [],
  meta: {},
  redirectedFrom: undefined
}

As you can see, the path object contains a wealth of path information. I won't say more about the specific meaning. You can refer to the official documents.

The route is changed by changing the path. The route object provides many methods to change the path, such as router.push and router.replace. Their bottom layer finally completes the path switching through pushWithRedirect. Let's take a look at its implementation:

function pushWithRedirect(to, redirectedFrom) {
  const targetLocation = (pendingLocation = resolve(to))
  const from = currentRoute.value
  const data = to.state
  const force = to.force
  const replace = to.replace === true
  const toLocation = targetLocation
  toLocation.redirectedFrom = redirectedFrom
  let failure
  if (!force && isSameRouteLocation(stringifyQuery$1, from, targetLocation)) {
    failure = createRouterError(16 /* NAVIGATION_DUPLICATED */, { to: toLocation, from })
    handleScroll(from, from, true, false)
  }
  return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
    .catch((error) => {
      if (isNavigationFailure(error, 4 /* NAVIGATION_ABORTED */ |
        8 /* NAVIGATION_CANCELLED */ |
        2 /* NAVIGATION_GUARD_REDIRECT */)) {
        return error
      }
      return triggerError(error)
    })
    .then((failure) => {
      if (failure) {
        // Processing error
      }
      else {
        failure = finalizeNavigation(toLocation, from, true, replace, data)
      }
      triggerAfterEach(toLocation, from, failure)
      return failure
    })
}

I have omitted the implementation of some codes. Here we mainly look at the core idea of pushWithRedirect. First, the parameter to may be a string representing a path or a path object. Therefore, a new path object must be returned through a layer of resolve, which has a matched attribute more than the path object mentioned earlier, Its role will be introduced later.

After obtaining the new target path, execute the navigate method, which is actually a series of navigation guard functions in the process of route switching, which will be introduced later. After successful navigation, finalize navigation will be executed to complete the navigation, and the real path switching will be completed here. Let's look at its implementation:

function finalizeNavigation(toLocation, from, isPush, replace, data) {
  const error = checkCanceledNavigation(toLocation, from)
  if (error)
    return error
  const isFirstNavigation = from === START_LOCATION_NORMALIZED
  const state = !isBrowser ? {} : history.state
  if (isPush) {
    if (replace || isFirstNavigation)
      routerHistory.replace(toLocation.fullPath, assign({
        scroll: isFirstNavigation && state && state.scroll,
      }, data))
    else
      routerHistory.push(toLocation.fullPath, data)
  }
  currentRoute.value = toLocation
  handleScroll(toLocation, from, isPush, isFirstNavigation)
  markAsReady()
}

In the finalizeinavigation function here, we focus on two logics: one is to update the value of the current path currentRoute, and the other is to update the record of the browser URL by executing the routerHistory.push or routerHistory.replace method.

Whenever we switch routes, we will find that the browser URL has changed, but the page has not been refreshed. What does it do?

When we create a router object, we will create a history object. As mentioned earlier, Vue Router supports three modes. Here we focus on the history mode of HTML5:

function createWebHistory(base) {
  base = normalizeBase(base)
  const historyNavigation = useHistoryStateNavigation(base)
  const historyListeners = useHistoryListeners(base, historyNavigation.state, historyNavigation.location, historyNavigation.replace)
  function go(delta, triggerListeners = true) {
    if (!triggerListeners)
      historyListeners.pauseListeners()
    history.go(delta)
  }
  const routerHistory = assign({
    // it's overridden right after
    location: '',
    base,
    go,
    createHref: createHref.bind(null, base),
  }, historyNavigation, historyListeners)
  Object.defineProperty(routerHistory, 'location', {
    get: () => historyNavigation.location.value,
  })
  Object.defineProperty(routerHistory, 'state', {
    get: () => historyNavigation.state.value,
  })
  return routerHistory
}

For routerHistory object, it has two important functions: one is to switch paths, and the other is to monitor path changes.

Among them, path switching is mainly completed through historyNavigation, which is the return value of useHistoryStateNavigation function. Let's see its implementation:

function useHistoryStateNavigation(base) {
  const { history, location } = window
  let currentLocation = {
    value: createCurrentLocation(base, location),
  }
  let historyState = { value: history.state }
  if (!historyState.value) {
    changeLocation(currentLocation.value, {
      back: null,
      current: currentLocation.value,
      forward: null,
      position: history.length - 1,
      replaced: true,
      scroll: null,
    }, true)
  }
  function changeLocation(to, state, replace) {
    const url = createBaseLocation() +
      // preserve any existing query when base has a hash
      (base.indexOf('#') > -1 && location.search
        ? location.pathname + location.search + '#'
        : base) +
      to
    try {
      history[replace ? 'replaceState' : 'pushState'](state, '', url)
      historyState.value = state
    }
    catch (err) {
      warn('Error with push/replace State', err)
      location[replace ? 'replace' : 'assign'](url)
    }
  }
  function replace(to, data) {
    const state = assign({}, history.state, buildState(historyState.value.back,
      // keep back and forward entries but override current position
      to, historyState.value.forward, true), data, { position: historyState.value.position })
    changeLocation(to, state, true)
    currentLocation.value = to
  }
  function push(to, data) {
    const currentState = assign({},
      historyState.value, history.state, {
        forward: to,
        scroll: computeScrollPosition(),
      })
    if ( !history.state) {
      warn(`history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` +
        `history.replaceState(history.state, '', url)\n\n` +
        `You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.`)
    }
    changeLocation(currentState.current, currentState, true)
    const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data)
    changeLocation(to, state, false)
    currentLocation.value = to
  }
  return {
    location: currentLocation,
    state: historyState,
    push,
    replace
  }
}

The push and replace functions returned by this function will be added to the routerHistory object. Therefore, when we call routerHistory.push or routerHistory.replace methods, we are actually executing these two functions.

Both push and replace methods internally execute the changeLocation method. The function internally executes the history.pushState or history.replaceState method at the bottom of the browser, which will add a state to the history stack of the current browser session, so that the URL of the page can be modified without refreshing the page.

We use this method to modify the path. At this time, suppose we click the back button of the browser to return to the previous URL, which needs to restore to the previous path and update the routing view. Therefore, we also need to listen to the history change behavior and do some corresponding processing.

The monitoring of History changes is mainly done through historyListeners, which is the return value of useHistoryListeners function. Let's see its implementation:

function useHistoryListeners(base, historyState, currentLocation, replace) {
  let listeners = []
  let teardowns = []
  let pauseState = null
  const popStateHandler = ({ state, }) => {
    const to = createCurrentLocation(base, location)
    const from = currentLocation.value
    const fromState = historyState.value
    let delta = 0
    if (state) {
      currentLocation.value = to
      historyState.value = state
      if (pauseState && pauseState === from) {
        pauseState = null
        return
      }
      delta = fromState ? state.position - fromState.position : 0
    }
    else {
      replace(to)
    }
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,
        direction: delta
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      })
    })
  }
  function pauseListeners() {
    pauseState = currentLocation.value
  }
  function listen(callback) {
    listeners.push(callback)
    const teardown = () => {
      const index = listeners.indexOf(callback)
      if (index > -1)
        listeners.splice(index, 1)
    }
    teardowns.push(teardown)
    return teardown
  }
  function beforeUnloadListener() {
    const { history } = window
    if (!history.state)
      return
    history.replaceState(assign({}, history.state, { scroll: computeScrollPosition() }), '')
  }
  function destroy() {
    for (const teardown of teardowns)
      teardown()
    teardowns = []
    window.removeEventListener('popstate', popStateHandler)
    window.removeEventListener('beforeunload', beforeUnloadListener)
  }
  window.addEventListener('popstate', popStateHandler)
  window.addEventListener('beforeunload', beforeUnloadListener)
  return {
    pauseListeners,
    listen,
    destroy
  }
}

This function returns the listen method, which allows you to add some listeners to listen for hstory changes. At the same time, this method is also mounted on the routerHistory object so that it can be accessed externally.

The function also listens to the pop state event of the underlying Window of the browser. When we click the back button of the browser or execute the history.back method, the callback function popstate handler of the event will be triggered, and then traverse the listener listeners to execute each listener function.

So how did Vue Router add these listeners? It turns out that when installing the route, the initialization navigation will be executed once, the push method will be executed, and then the finalize navigation method will be executed.

At the end of finalizeinavigation, the markAsReady method will be executed. Let's look at its implementation:

function markAsReady(err) {
  if (ready)
    return
  ready = true
  setupListeners()
  readyHandlers
    .list()
    .forEach(([resolve, reject]) => (err ? reject(err) : resolve()))
  readyHandlers.reset()
}

markAsReady internally executes the setupListeners function to initialize the listener, and it is guaranteed that it will be initialized only once. Let's move on to the implementation of setupListeners:

function setupListeners() {
  removeHistoryListener = routerHistory.listen((to, _from, info) => {
    const toLocation = resolve(to)
    pendingLocation = toLocation
    const from = currentRoute.value
    if (isBrowser) {
      saveScrollPosition(getScrollKey(from.fullPath, info.delta), computeScrollPosition())
    }
    navigate(toLocation, from)
      .catch((error) => {
        if (isNavigationFailure(error, 4 /* NAVIGATION_ABORTED */ | 8 /* NAVIGATION_CANCELLED */)) {
          return error
        }
        if (isNavigationFailure(error, 2 /* NAVIGATION_GUARD_REDIRECT */)) {
          if (info.delta)
            routerHistory.go(-info.delta, false)
          pushWithRedirect(error.to, toLocation
          ).catch(noop)
          // avoid the then branch
          return Promise.reject()
        }
        if (info.delta)
          routerHistory.go(-info.delta, false)
        return triggerError(error)
      })
      .then((failure) => {
        failure =
          failure ||
          finalizeNavigation(
            toLocation, from, false)
        if (failure && info.delta)
          routerHistory.go(-info.delta, false)
        triggerAfterEach(toLocation, from, failure)
      })
      .catch(noop)
  })
}

The listener function also executes the navigate method and a series of navigation guard functions in the process of route switching. After the navigation is successful, it executes finalize navigation to complete the navigation and complete the real path switching. This ensures that the user can restore to the previous path and update the routing view after clicking the browser Back button.

So far, we have completed path management, maintained and recorded the current path in memory through currentRoute, and realized path switching and history change monitoring through the browser's underlying API.

Topics: Javascript Front-end Vue.js Interview