Skip to content

VueRouter核心原理及实现

前言

本文主要介绍 Vue Router 的核心原理以及如何实现一个简易版本的 Vue Router(history 和 hash 模式)。

前置知识

hashhistory 模式区别

  • 表现形式上:hash 模式 url music.163.com/#/playlist?… , 带有 # 号。
  • 原理区别:hash 模式基于锚点,以及 onhashchange 事件。 history 模式是基于 HTML5 中的 history 模式。history.pushState、replaceState 在 IE10 以后才支持,存在兼容性问题。push 会向服务器发送请求,使用 pushState 不会发送请求,但是会在浏览器端产生历史记录,形成客户端路由。

history 模式使用

  • 该模式需要服务器端的支持。
  • 单页应用中,服务端不存在 www.testurl.com/login,地址会返回 404,提示找不到页面。
  • history 模式下前后端工作过程:history 模式下,刷新页面会向服务器进行网络请求,后端处理 history 模式,需要将默认的 html 文件返回给前端,前端获取到文件后再根据路由自行处理。

Vue Router 使用方式

比较常规的使用方式,避免文章篇幅过长,这里就不一一赘述了。基础步骤如下:

  1. Vue 2.6.11 模板项目中, 安装依赖:
js
npm install vue-router
  1. src 目录下创建 router 文件夹,并新建一个 index.js,内容如下:
JavaScript
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../components/Home";
import ComponentA from "../components/ComponentA";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    redirect: "/home",
    component: Home
  },
  {
    path: "/componentA",
    name: "ComponentA",
    component: ComponentA
  },
];

const router = new VueRouter({
  mode: "history",
  routes
});

export default router;
  1. main.js 中引入,在 Vue 实例化时,作为 Options 参数传递给 Vue 的构造函数。
JavaScript
import Vue from "vue";
import App from "./App.vue";
import router from "./router/index";

Vue.config.productionTip = false;

const vm = new Vue({
  router,
  render: (h) => h(App)
}).$mount("#app");

console.log("vm: ", vm);

router 实例对象

Vue 实例化后,router 对象作用在何处? 我们在 main.js 中 Vue 实例化后的位置添加一行打印,看看 Vue 实例化后的对象上有什么。

image

vm.$options.router 中,我们看到了许多熟悉的对象属性与官方文档上提到的编程式导航方法。

实现 history 模式

CustomRouter

这里我们给自己的 Router 类命名为 CustomRouter,定义如下:

JavaScript
export default class CustomRouter {
  static install(Vue) {}

  constructor(options) {}

  init() {}

  createRouteMap() {}

  initComponents(Vue) {}

  initEvent() {}
}

CustomRouter 类中的各个函数作用依次如下:

  • install 函数,Vue 插件需要对外暴露的 install 方法,关于 Vue 插件开发,可以参考官方文档:cn.vuejs.org/v2/guide/pl…
  • constructor 函数:构造函数。
  • init 函数:全局初始化。
  • createRouteMap:创建路由配置与对应组件的映射,属性值是对应的组件。
  • initComponents:初始化 Router-ViewRouter-Link 组件。
  • initEvent: 监听浏览器 historypopstate 事件,当页面 URL 变化时,将会触发监听事件。

constructor

JavaScript
constructor(options) {
  this.options = options;
  this.routeMap = {};
  this.data = _Vue.observable({
    current: "home"
  });
}

构造函数中的对象成员如下:

  • options: options 中存储的是路由相关的配置。

image (1)

  • routeMap: 路由 pathcomponent 的映射,是一个对象,对象属性值为 path,对象值为 component,在 createRouteMap 函数中进行初始化。
  • data:通过 2.6.x 版本后提供的 observable api,将对象转换成响应式。

initEvent 函数

监听 popstate 事件,并更新当前的 current。

JavaScript
initEvent() {
  window.addEventListener('popstate', () => {
    this.data.current = window.location.pathname;
  })
}

HashHistory 源码中 setupListeners 函数

JavaScript
 class HashHistory extends History {
     // 注册路由事件
     setupListeners () {
        if (this.listeners.length > 0) {
          return
        }
    
        const router = this.router
        const expectScroll = router.options.scrollBehavior
        const supportsScroll = supportsPushState && expectScroll
        /
        if (supportsScroll) {
          this.listeners.push(setupScroll())
        }
    
        const handleRoutingEvent = () => {
          const current = this.current
          if (!ensureSlash()) {
            return
          }
          // 开启路由动画情况下 
          this.transitionTo(getHash(), route => {
            if (supportsScroll) {
              handleScroll(this.router, route, current, true)
            }
            if (!supportsPushState) {
              replaceHash(route.fullPath)
            }
          })
        }
        // 判断 事件类型 
        const eventType = supportsPushState ? 'popstate' : 'hashchange'
        // 添加路由监听事件
        window.addEventListener(
          eventType,
          handleRoutingEvent
        )
        this.listeners.push(() => {
          window.removeEventListener(eventType, handleRoutingEvent)
        })
      }
  }

initComponent

JavaScript
 initComponents(Vue) {
  /**
   * Router-Link 组件
   */Vue.component("router-link", {
    props: {
      to: String
    },
    render(h) {
      return h(
        "a",
        {
          // dom 对象属性attrs: {
            href: this.to
          },
          on: {
            click: this.clickHandler
          }
        },
        [this.$slots.default]
      );
    },
    methods: {
      clickHandler(e) {
        window.history.pushState({}, "", this.to);
        this.$router.data.current = this.to;
        e.preventDefault();
      }
    }
  });

  /**
   * Router-View 组件
   */const self = this;
  Vue.component("router-view", {
    render(h) {
      const component = self.routeMap[self.data.current];
      return h(component);
    }
  })
}

this.data.current 变化时,所有依赖于该变量的组件(router-view)都会重新渲染,这也是为什么要使用 Vue.observable 的原因。

Router-Link 组件源码

JavaScript
/* @flow */

import { createRoute, isSameRoute, isIncludedRoute } from '../util/route'
import { extend } from '../util/misc'
import { normalizeLocation } from '../util/location'
import { warn } from '../util/warn'

// work around weird flow bug
const toTypes: Array<Function> = [String, Object]
const eventTypes: Array<Function> = [String, Array]

const noop = () => {}

let warnedCustomSlot
let warnedTagProp
let warnedEventProp

export default {
  name: 'RouterLink',
  props: {
    to: {
      type: toTypes,
      required: true
    },
    tag: {
      type: String,
      default: 'a'
    },
    custom: Boolean,
    exact: Boolean,
    exactPath: Boolean,
    append: Boolean,
    replace: Boolean,
    activeClass: String,
    exactActiveClass: String,
    ariaCurrentValue: {
      type: String,
      default: 'page'
    },
    event: {
      type: eventTypes,
      default: 'click'
    }
  },
  render (h: Function) {
    const router = this.$router
    const current = this.$route
    const { location, route, href } = router.resolve(
      this.to,
      current,
      this.append
    )

    const classes = {}
    const globalActiveClass = router.options.linkActiveClass
    const globalExactActiveClass = router.options.linkExactActiveClass
    // Support global empty active class
    const activeClassFallback =
      globalActiveClass == null ? 'router-link-active' : globalActiveClass
    const exactActiveClassFallback =
      globalExactActiveClass == null
        ? 'router-link-exact-active'
        : globalExactActiveClass
    const activeClass =
      this.activeClass == null ? activeClassFallback : this.activeClass
    const exactActiveClass =
      this.exactActiveClass == null
        ? exactActiveClassFallback
        : this.exactActiveClass

    const compareTarget = route.redirectedFrom
      ? createRoute(null, normalizeLocation(route.redirectedFrom), null, router)
      : route

    classes[exactActiveClass] = isSameRoute(current, compareTarget, this.exactPath)
    classes[activeClass] = this.exact || this.exactPath
      ? classes[exactActiveClass]
      : isIncludedRoute(current, compareTarget)

    const ariaCurrentValue = classes[exactActiveClass] ? this.ariaCurrentValue : null

    const handler = e => {
      if (guardEvent(e)) {
        if (this.replace) {
          router.replace(location, noop)
        } else {
          router.push(location, noop)
        }
      }
    }

    const on = { click: guardEvent }
    if (Array.isArray(this.event)) {
      this.event.forEach(e => {
        on[e] = handler
      })
    } else {
      on[this.event] = handler
    }

    const data: any = { class: classes }

    const scopedSlot =
      !this.$scopedSlots.$hasNormal &&
      this.$scopedSlots.default &&
      this.$scopedSlots.default({
        href,
        route,
        navigate: handler,
        isActive: classes[activeClass],
        isExactActive: classes[exactActiveClass]
      })

    if (scopedSlot) {
      if (process.env.NODE_ENV !== 'production' && !this.custom) {
        !warnedCustomSlot && warn(false, 'In Vue Router 4, the v-slot API will by default wrap its content with an <a> element. Use the custom prop to remove this warning:\n<router-link v-slot="{ navigate, href }" custom></router-link>\n')
        warnedCustomSlot = true
      }
      if (scopedSlot.length === 1) {
        return scopedSlot[0]
      } else if (scopedSlot.length > 1 || !scopedSlot.length) {
        if (process.env.NODE_ENV !== 'production') {
          warn(
            false,
            `<router-link> with to="${
              this.to
            }" is trying to use a scoped slot but it didn't provide exactly one child. Wrapping the content with a span element.`
          )
        }
        return scopedSlot.length === 0 ? h() : h('span', {}, scopedSlot)
      }
    }

    if (process.env.NODE_ENV !== 'production') {
      if ('tag' in this.$options.propsData && !warnedTagProp) {
        warn(
          false,
          `<router-link>'s tag prop is deprecated and has been removed in Vue Router 4. Use the v-slot API to remove this warning: https://next.router.vuejs.org/guide/migration/#removal-of-event-and-tag-props-in-router-link.`
        )
        warnedTagProp = true
      }
      if ('event' in this.$options.propsData && !warnedEventProp) {
        warn(
          false,
          `<router-link>'s event prop is deprecated and has been removed in Vue Router 4. Use the v-slot API to remove this warning: https://next.router.vuejs.org/guide/migration/#removal-of-event-and-tag-props-in-router-link.`
        )
        warnedEventProp = true
      }
    }

    if (this.tag === 'a') {
      data.on = on
      data.attrs = { href, 'aria-current': ariaCurrentValue }
    } else {
      // find the first <a> child and apply listener and href
      const a = findAnchor(this.$slots.default)
      if (a) {
        // in case the <a> is a static node
        a.isStatic = false
        const aData = (a.data = extend({}, a.data))
        aData.on = aData.on || {}
        // transform existing events in both objects into arrays so we can push later
        for (const event in aData.on) {
          const handler = aData.on[event]
          if (event in on) {
            aData.on[event] = Array.isArray(handler) ? handler : [handler]
          }
        }
        // append new listeners for router-link
        for (const event in on) {
          if (event in aData.on) {
            // on[event] is always a function
            aData.on[event].push(on[event])
          } else {
            aData.on[event] = handler
          }
        }

        const aAttrs = (a.data.attrs = extend({}, a.data.attrs))
        aAttrs.href = href
        aAttrs['aria-current'] = ariaCurrentValue
      } else {
        // doesn't have <a> child, apply listener to self
        data.on = on
      }
    }

    return h(this.tag, data, this.$slots.default)
  }
}

export function guardEvent (e: any) {
  // don't redirect with control keys
  if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
  // don't redirect when preventDefault called
  if (e.defaultPrevented) return
  // don't redirect on right click
  if (e.button !== undefined && e.button !== 0) return
  // don't redirect if `target="_blank"`
  if (e.currentTarget && e.currentTarget.getAttribute) {
    const target = e.currentTarget.getAttribute('target')
    if (/\b_blank\b/i.test(target)) return
  }
  // this may be a Weex event which doesn't have this method
  if (e.preventDefault) {
    e.preventDefault()
  }
  return true
}

function findAnchor (children) {
  if (children) {
    let child
    for (let i = 0; i < children.length; i++) {
      child = children[i]
      if (child.tag === 'a') {
        return child
      }
      if (child.children && (child = findAnchor(child.children))) {
        return child
      }
    }
  }
}

Route-view 源码

JavaScript
import { warn } from '../util/warn'
import { extend } from '../util/misc'
import { handleRouteEntered } from '../util/route'

export default {
  name: 'RouterView',
  functional: true,
  props: {
    name: {
      type: String,
      default: 'default'
    }
  },
  render (_, { props, children, parent, data }) {
    // used by devtools to display a router-view badge
    data.routerView = true

    // directly use parent context's createElement() function
    // so that components rendered by router-view can resolve named slots
    const h = parent.$createElement
    const name = props.name
    const route = parent.$route
    const cache = parent._routerViewCache || (parent._routerViewCache = {})

    // determine current view depth, also check to see if the tree
    // has been toggled inactive but kept-alive.
    let depth = 0
    let inactive = false
    while (parent && parent._routerRoot !== parent) {
      const vnodeData = parent.$vnode ? parent.$vnode.data : {}
      if (vnodeData.routerView) {
        depth++
      }
      if (vnodeData.keepAlive && parent._directInactive && parent._inactive) {
        inactive = true
      }
      parent = parent.$parent
    }
    data.routerViewDepth = depth

    // render previous view if the tree is inactive and kept-alive
    if (inactive) {
      const cachedData = cache[name]
      const cachedComponent = cachedData && cachedData.component
      if (cachedComponent) {
        // #2301
        // pass props
        if (cachedData.configProps) {
          fillPropsinData(cachedComponent, data, cachedData.route, cachedData.configProps)
        }
        return h(cachedComponent, data, children)
      } else {
        // render previous empty view
        return h()
      }
    }

    const matched = route.matched[depth]
    const component = matched && matched.components[name]

    // render empty node if no matched route or no config component
    if (!matched || !component) {
      cache[name] = null
      return h()
    }

    // cache component
    cache[name] = { component }

    // attach instance registration hook
    // this will be called in the instance's injected lifecycle hooks
    data.registerRouteInstance = (vm, val) => {
      // val could be undefined for unregistration
      const current = matched.instances[name]
      if (
        (val && current !== vm) ||
        (!val && current === vm)
      ) {
        matched.instances[name] = val
      }
    }

    // also register instance in prepatch hook
    // in case the same component instance is reused across different routes
    ;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
      matched.instances[name] = vnode.componentInstance
    }

    // register instance in init hook
    // in case kept-alive component be actived when routes changed
    data.hook.init = (vnode) => {
      if (vnode.data.keepAlive &&
        vnode.componentInstance &&
        vnode.componentInstance !== matched.instances[name]
      ) {
        matched.instances[name] = vnode.componentInstance
      }

      // if the route transition has already been confirmed then we weren't
      // able to call the cbs during confirmation as the component was not
      // registered yet, so we call it here.
      handleRouteEntered(route)
    }

    const configProps = matched.props && matched.props[name]
    // save route and configProps in cache
    if (configProps) {
      extend(cache[name], {
        route,
        configProps
      })
      fillPropsinData(component, data, route, configProps)
    }

    return h(component, data, children)
  }
}

function fillPropsinData (component, data, route, configProps) {
  // resolve props
  let propsToPass = data.props = resolveProps(route, configProps)
  if (propsToPass) {
    // clone to prevent mutation
    propsToPass = data.props = extend({}, propsToPass)
    // pass non-declared props as attrs
    const attrs = data.attrs = data.attrs || {}
    for (const key in propsToPass) {
      if (!component.props || !(key in component.props)) {
        attrs[key] = propsToPass[key]
        delete propsToPass[key]
      }
    }
  }
}

function resolveProps (route, config) {
  switch (typeof config) {
    case 'undefined':
      return
    case 'object':
      return config
    case 'function':
      return config(route)
    case 'boolean':
      return config ? route.params : undefined
    default:
      if (process.env.NODE_ENV !== 'production') {
        warn(
          false,
          `props in "${route.path}" is a ${typeof config}, ` +
          `expecting an object, function or boolean.`
        )
      }
  }
}

init

分别初始化 routeMapRouter-ViewRouter-Link 组件和 popstate 事件监听器。

JavaScript
init() {
  this.createRouteMap();
  this.initComponents();
  this.initEvent();
}

vue-router源码中 init 函数

JavaScript
class VueRouter {
 init (app: any /* Vue component instance */) {
    process.env.NODE_ENV !== 'production' &&
      assert(
        install.installed,
        `not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
          `before creating root instance.`
      )

    this.apps.push(app)

    // set up app destroyed handler
    // https://github.com/vuejs/vue-router/issues/2639
    app.$once('hook:destroyed', () => {
      // clean out app from this.apps array once destroyed
      const index = this.apps.indexOf(app)
      if (index > -1) this.apps.splice(index, 1)
      // ensure we still have a main app or null if no apps
      // we do not release the router so it can be reused
      if (this.app === app) this.app = this.apps[0] || null

      if (!this.app) this.history.teardown()
    })

    // main app previously initialized
    // return as we don't need to set up new history listener
    if (this.app) {
      return
    }

    this.app = app

    const history = this.history
    
    // 判断  history 是否是  HTML5History 或者 HashHistory 实例
    if (history instanceof HTML5History || history instanceof HashHistory) {
      // 初始化滚动
      const handleInitialScroll = routeOrError => {
        const from = history.current
        const expectScroll = this.options.scrollBehavior
        const supportsScroll = supportsPushState && expectScroll

        if (supportsScroll && 'fullPath' in routeOrError) {
          handleScroll(this, routeOrError, from, false)
        }
      }
      
      //  注册相应的监听事件
      const setupListeners = routeOrError => {
        history.setupListeners()
        handleInitialScroll(routeOrError)
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupListeners,
        setupListeners
      )
    }

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

install

JavaScript
static install(Vue) {
  if (CustomRouter.install.installed) {
    return;
  }

  CustomRouter.install.installed = true;

  _Vue = Vue;

  _Vue.mixin({
    beforeCreate: function () {
      if (this.$options.router) {
        _Vue.prototype.$router = this.$options.router;
        this.$options.router.init();
      }
    }
  });
}

vue-router源码中 install 函数

JavaScript
import View from './components/view'
import Link from './components/link'

export let _Vue

export function install (Vue) {
   // 判断是否已经注册过
  if (install.installed && _Vue === Vue) return
  install.installed = true

  _Vue = Vue

  const isDef = v => v !== undefined

  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }

  Vue.mixin({
    beforeCreate () {
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        this._router = this.$options.router
        //  执行init 事件 初始化Router
        this._router.init(this)
        // 将_route 变成响应式
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      registerInstance(this, this)
    },
    destroyed () {
      registerInstance(this)
    }
  })

// 在 prototype 添加 $router 属性 每个组件可访问
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

// 在 prototype 添加 $route 属性 每个组件可访问
  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })

// 注册 RouterView  RouterLink  组件 
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)

  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

实现 hash 模式

hash 模式整体代码实现上和 history 基本一致,它监听的是 loadhashchange 事件,并在 router-link 组件点击时,设置 window.locationhash 形式。差异代码如下:

JavaScript
Vue.component("router-link", {
  props: {
    to: String
  },
  render(h) {
    return h(
      "a",
      {
        attrs: {
          href: "#" + this.to
        },
        on: {
          click: this.clickHandler
        }
      },
      [this.$slots.default]
    );
  },
  methods: {
    clickHandler(e) {
      window.location.hash = "#" + this.to;
      this.$router.data.current = this.to;
      e.preventDefault();
    }
  }
});

a 标签的 href 修改为了 hash 形式,clickHandler 点击事件处理中,window.location.hash 会被赋值为对应路由的 hash 形式。

hashchangeload 事件监听

JavaScript
onHashChange() {
  if (!window.location.hash) {
    window.location.hash = "#/";
  }
  this.data.current = window.location.hash.substr(1);
}

initEvent() {
  window.addEventListener("load", this.onHashChange.bind(this));
  window.addEventListener("hashchange", this.onHashChange.bind(this));
}

监听 hashchangeload 事件。

代码示例

https://codesandbox.io/s/build-vue-router-q7nk8p

QQ20230813-080510-HD

QQ20230813-081721-HD

参考

前端知识体系 · wcrane