一文聊聊Vue-Router的实现原理

广告:宝塔Linux面板高效运维的服务器管理软件 点击【 https://www.bt.cn/p/uNLv1L 】立即购买

一文聊聊Vue-Router的实现原理

路由的概念相信大部分同学并不陌生,我们在用 Vue 开发过实际项目的时候都会用到 Vue-Router 这个官方插件来帮我们解决路由的问题。它的作用就是根据不同的路径映射到不同的视图。本文不再讲述路由的基础使用和API,不清楚的同学可以自行查阅官方文档Vue-router3 对应 vue2 和 vue-router4 对应 vue3。

今天我们主要是谈谈Vue-Router的实现原理,感兴趣的小伙伴可以继续往下看,大佬请止步。

本文 vue-router 版本为 3.5.3

路由

既然我们在分析路由,我们首先来说说什么是路由,什么是后端路由、什么是前端路由。

路由就是根据不同的 url 地址展示不同的内容或页面,早期路由的概念是在后端出现的,通过服务器端渲染后返回页面,随着页面越来越复杂,服务器端压力越来越大。后来ajax异步刷新的出现使得前端也可以对url进行管理,此时,前端路由就出现了。【学习视频分享:vue视频教程、web前端视频】

我们先来说说后端路由

后端路由

后端路由又可称之为服务器端路由,因为对于服务器来说,当接收到客户端发来的HTTP请求,就会根据所请求的URL,来找到相应的映射函数,然后执行该函数,并将函数的返回值发送给客户端。

对于最简单的静态资源服务器,可以认为,所有URL的映射函数就是一个文件读取操作。 对于动态资源,映射函数可能是一个数据库读取操作,也可能是进行一些数据的处理,等等。

然后根据这些读取的数据,在服务器端就使用相应的模板来对页面进行渲染后,再返回渲染完毕的HTML页面。早期的jsp就是这种模式。

前端路由

刚刚也介绍了,在前后端没有分离的时候,服务端都是直接将整个 HTML 返回,用户每次一个很小的操作都会引起页面的整个刷新(再加上之前的网速还很慢,所以用户体验可想而知)。

在90年代末的时候,微软首先实现了 ajax(Asynchronous JavaScript And XML) 这个技术,这样用户每次的操作就可以不用刷新整个页面了,用户体验就大大提升了。

虽然数据能异步获取不用每个点击都去请求整个网页,但是页面之间的跳转还是会加载整个网页,体验不是特别好,还有没有更好的方法呢?

至此异步交互体验的更高级版本 SPA单页应用 就出现了。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的。既然页面的跳转是无刷新的,也就是不再向后端请求返回 HTML页面。

页面跳转都不从后端获取新的HTML页面,那应该怎么做呢?所以就有了现在的前端路由。

可以理解为,前端路由就是将之前服务端根据 url 的不同返回不同的页面的任务交给前端来做。在这个过程中,js会实时检测url的变化,从而改变显示的内容。

前端路由优点是用户体验好,用户操作或页面跳转不会刷新页面,并且能快速展现给用户。缺点是首屏加载慢,因为需要js动态渲染展示内容。而且由于内容是js动态渲染的所以不利于SEO

下面我们正式进入Vue-Router原理分析阶段。

分析Vue-Router.install方法

我们先来看看install.js,这个方法会在Vue.use(VueRouter)的时候被调用。

// install.jsimport View from './components/view'import Link from './components/link'export let _Vueexport function install (Vue) {  // 不会重复安装  if (install.installed && _Vue === Vue) return  install.installed = true  _Vue = Vue  const isDef = v => v !== undefined  // 为router-view组件关联路由组件  const registerInstance = (vm, callVal) => {    let i = vm.$options._parentVnode    // 调用vm.$options._parentVnode.data.registerRouteInstance方法    // 而这个方法只在router-view组件中存在,router-view组件定义在(../components/view.js @71行)    // 所以,如果vm的父节点为router-view,则为router-view关联当前vm,即将当前vm做为router-view的路由组件    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {      i(vm, callVal)    }  }  Vue.mixin({    beforeCreate () {      // 这里只会进来一次,因为只有Vue根实例才会有router属性。      if (isDef(this.$options.router)) {        // 所以这里的this就是Vue根实例        this._routerRoot = this        this._router = this.$options.router        this._router.init(this)        // 将 _route 变成响应式        Vue.util.defineReactive(this, '_route', this._router.history.current)      } else {        // 子组件会进入这里,这里也是把Vue根实例保存带_routerRoot属性上        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this      }      // 为router-view组件关联路由组件      registerInstance(this, this)    },    destroyed () {      // destroyed hook触发时,取消router-view和路由组件的关联      registerInstance(this)    }  })  // 在原型上注入$router、$route属性,方便快捷访问  Object.defineProperty(Vue.prototype, '$router', {    // 上面说到每个组件的_routerRoot都是Vue根实例,所以都能访问_router    get () { return this._routerRoot._router }  })  // 每个组件访问到的$route,其实最后访问的都是Vue根实例的_route  Object.defineProperty(Vue.prototype, '$route', {    get () { return this._routerRoot._route }  })  // 注册router-view、router-link两个全局组件  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}
登录后复制

主要做了如下几件事情:

避免重复安装

为了确保 install 逻辑只执行一次,用了 install.installed 变量做已安装的标志位。

传递Vue引用减少打包体积

用一个全局的 _Vue 来接收参数 Vue,因为作为 Vue 的插件对 Vue 对象是有依赖的,但又不能去单独去 import Vue,因为那样会增加包体积,所以就通过这种方式拿到 Vue 对象。

注册全局混入

Vue-Router 安装最重要的一步就是利用 Vue.mixin,在beforeCreatedestroyed生命周期函数中注入路由逻辑。

Vue.mixin我们知道就是全局 mixin,所以也就相当于每个组件的beforeCreatedestroyed生命周期函数中都会有这些代码,并在每个组件中都会运行。

Vue.mixin({  beforeCreate () {    if (isDef(this.$options.router)) {      this._routerRoot = this      this._router = this.$options.router      this._router.init(this)      Vue.util.defineReactive(this, '_route', this._router.history.current)    } else {      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this    }    registerInstance(this, this)  },  destroyed () {    registerInstance(this)  }})
登录后复制

在这两个钩子中,this是指向当时正在调用钩子的vue实例

这两个钩子中的逻辑,在安装流程中是不会被执行的,只有在组件实例化时执行到钩子时才会被调用

先看混入的 beforeCreate 钩子函数

它先判断了this.$options.router是否存在,我们在new Vue({router})时,router才会被保存到到Vue根实例$options上,而其它Vue实例$options上是没有router的,所以if中的语句只在this === new Vue({router})时,才会被执行,由于Vue根实例只有一个,所以这个逻辑只会被执行一次。

对于根 Vue 实例而言,执行该钩子函数时定义了 this._routerRoot 表示它自身(Vue根实例);this._router 表示 VueRouter 的实例 router,它是在 new Vue 的时候传入的;

另外执行了 this._router.init() 方法初始化 router,这个逻辑在后面讲初始化的时候再介绍。

然后用 defineReactive 方法把 this._route 变成响应式对象,保证_route变化时,router-view会重新渲染,这个我们后面在router-view组件中会细讲。

我们再看下else中具体干了啥

主要是为每个组件定义_routerRoot,对于子组件而言,由于组件是树状结构,在遍历组件树的过程中,它们在执行该钩子函数的时候 this._routerRoot 始终指向的离它最近的传入了 router 对象作为配置而实例化的父实例(也就是永远等于根实例)。

所以我们可以得到,在每个vue组件都有 this._routerRoot === vue根实例this._routerRoot._router === router对象

对于 beforeCreatedestroyed 钩子函数,它们都会执行 registerInstance 方法,这个方法的作用我们也是之后会介绍。

添加$route、$router属性

接着给 Vue 原型上定义了 $router$route 2 个属性的 get 方法,这就是为什么我们可以在任何组件实例上都可以访问 this.$router 以及 this.$route

Object.defineProperty(Vue.prototype, '$router', {get () { return this._routerRoot._router }})Object.defineProperty(Vue.prototype, '$route', {get () { return this._routerRoot._route }})
登录后复制

我们可以看到,$router其实返回的是this._routerRoot._router,也就是vue根实例上的router,因此我们可以通过this.$router来使用router的各种方法。

$route其实返回的是this._routerRoot._route,其实就是this._router.history.current,也就是目前的路由对象,这个后面会细说。

注册全局组件

通过 Vue.component 方法定义了全局的 <router-link><router-view> 2 个组件,这也是为什么我们在写模板的时候可以直接使用这两个标签,它们的作用我想就不用笔者再说了吧。

钩子函数的合并策略

最后设置路由组件的beforeRouteEnterbeforeRouteLeavebeforeRouteUpdate守卫的合并策略。

总结

那么到此为止,我们分析了 Vue-Router 的安装过程,Vue 编写插件的时候通常要提供静态的 install 方法,我们通过 Vue.use(plugin) 时候,就是在执行 install 方法。Vue-Routerinstall 方法会给每一个组件注入 beforeCreatedestoryed 钩子函数,在beforeCreate 做一些私有属性定义和路由初始化工作。并注册了两个全局组件,然后设置了钩子函数合并策略。在destoryed 做了一些销毁工作。

下面我们再来看看Vue-Router的实例化。

分析init方法

前面我们提到了在 install 的时候会执行 VueRouterinit 方法( this._router.init(this) ),那么接下来我们就来看一下 init 方法做了什么。

init (app: any /* Vue component instance */) {  // ...  this.apps.push(app)  // ...  // 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    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)      }    }        // 1.setupListeners 里会对 hashchange或popstate事件进行监听    const setupListeners = routeOrError => {      history.setupListeners()      handleInitialScroll(routeOrError)    }    // 2.初始化导航    history.transitionTo(      history.getCurrentLocation(),      setupListeners,      setupListeners    )  }  // 3.路由全局监听,维护当前的route   // 当路由变化的时候修改app._route的值  // 由于_route是响应式的,所以修改后相应视图会同步更新  history.listen(route => {    this.apps.forEach(app => {      app._route = route    })  })}
登录后复制

这里主要做了如下几件事情:

设置了路由监听
const setupListeners = routeOrError => {  history.setupListeners()  handleInitialScroll(routeOrError)}
登录后复制

这里会根据当前路由模式监听hashchangepopstate事件,当事件触发的时候,会进行路由的跳转。(后面说到路由模式的时候会细说)

初始化导航
history.transitionTo(  history.getCurrentLocation(),  setupListeners,  setupListeners)
登录后复制

进入系统会进行初始化路由匹配,渲染对应的组件。因为第一次进入系统,并不会触发hashchange或者popstate事件,所以第一次需要自己手动匹配路径然后进行跳转。

路由全局监听
history.listen(route => {  this.apps.forEach(app => {    app._route = route  })})
登录后复制

当路由变化的时候修改app._route的值。由于_route是响应式的,所以修改后相应视图会同步更新。

总结

这里主要是做了一些初始化工作。根据当前路由模式监听对应的路由事件。初始化导航,根据当前的url渲染初始页面。最后切换路由的时候修改_route,由于_route是响应式的,所以修改后相应视图会同步更新。

分析VueRouter实例化

实例化就是我们new VueRouter({routes})的过程,我们来重点分析下VueRouter的构造函数。

constructor (options: RouterOptions = {}) {  // ...    // 参数初始化  this.app = null  this.apps = []  this.options = options  this.beforeHooks = []  this.resolveHooks = []  this.afterHooks = []  // 创建matcher  this.matcher = createMatcher(options.routes || [], this)  // 设置默认模式和做不支持 H5 history 的降级处理  let mode = options.mode || 'hash'  this.fallback =    mode === 'history' && !supportsPushState && options.fallback !== false  if (this.fallback) {    mode = 'hash'  }  if (!inBrowser) {    mode = 'abstract'  }  this.mode = mode  // 根据不同的 mode 实例化不同的 History 对象  switch (mode) {    case 'history':      this.history = new HTML5History(this, options.base)      break    case 'hash':      this.history = new HashHistory(this, options.base, this.fallback)      break    case 'abstract':      this.history = new AbstractHistory(this, options.base)      break    default:      if (process.env.NODE_ENV !== 'production') {        assert(false, `invalid mode: ${mode}`)      }  }}
登录后复制

这里主要做了如下几件事情:

初始化参数

我们看到在最开始有些参数的初始化,这些参数到底是什么呢?

this.app 用来保存根 Vue 实例。

this.apps 用来保存持有 $options.router 属性的 Vue 实例。

this.options 保存传入的路由配置,也就是前面说的RouterOptions

this.beforeHooksthis.resolveHooksthis.afterHooks 表示一些钩子函数。

this.fallback 表示在浏览器不支持 historyapi的情况下,根据传入的 fallback 配置参数,决定是否回退到hash模式。

this.mode 表示路由创建的模式。

创建matcher

matcher,匹配器。简单理解就是可以通过url找到我们对应的组件。这一块内容较多,这里笔者就不再详细分析了。

确定路由模式

路由模式平时都会只说两种,其实在vue-router总共实现了 hashhistoryabstract 3 种模式。

VueRouter会根据options.modeoptions.fallbacksupportsPushStateinBrowser来确定最终的路由模式。

如果没有设置mode就默认是hash模式。

确定fallback值,只有在用户设置了mode:history并且当前环境不支持pushState且用户没有主动声明不需要回退(没设置fallback值位undefined),此时this.fallback才为true,当fallbacktrue时会使用hash模式。(简单理解就是如果不支持history模式并且只要没设置fallbackfalse,就会启用hash模式)

如果最后发现处于非浏览器环境,则会强制使用abstract模式。

实例化路由模式

根据mode属性值来实例化不同的对象。VueRouter的三种路由模式,主要由下面的四个核心类实现

History

基础类位于src/history/base.js

HTML5History

用于支持pushState的浏览器src/history/html5.js

HashHistory

用于不支持pushState的浏览器src/history/hash.js

AbstractHistory

用于非浏览器环境(服务端渲染)src/history/abstract.js

HTML5HistoryHashHistoryAbstractHistory三者都是继承于基础类History

这里我们详细分析下HTML5HistoryHashHistory类。

HTML5History类

当我们使用history模式的时候会实例化HTML5History类

// src/history/html5.js...export class HTML5History extends History {  _startLocation: string  constructor (router: Router, base: ?string) {    // 调用父类构造函数初始化    super(router, base)    this._startLocation = getLocation(this.base)  }  // 设置监听,主要是监听popstate方法来自动触发transitionTo  setupListeners () {    if (this.listeners.length > 0) {      return    }    const router = this.router    const expectScroll = router.options.scrollBehavior    const supportsScroll = supportsPushState && expectScroll        // 若支持scroll,初始化scroll相关逻辑    if (supportsScroll) {      this.listeners.push(setupScroll())    }    const handleRoutingEvent = () => {      const current = this.current      // 某些浏览器,会在打开页面时触发一次popstate       // 此时如果初始路由是异步路由,就会出现`popstate`先触发,初始路由后解析完成,进而导致route未更新       // 所以需要避免      const location = getLocation(this.base)      if (this.current === START && location === this._startLocation) {        return      }            // 路由地址发生变化,则跳转,如需滚动则在跳转后处理滚动      this.transitionTo(location, route => {        if (supportsScroll) {          handleScroll(router, route, current, true)        }      })    }        // 监听popstate事件    window.addEventListener('popstate', handleRoutingEvent)    this.listeners.push(() => {      window.removeEventListener('popstate', handleRoutingEvent)    })  }  // 可以看到 history模式go方法其实是调用的window.history.go(n)  go (n: number) {    window.history.go(n)  }  // push方法会主动调用transitionTo进行跳转  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {    const { current: fromRoute } = this    this.transitionTo(location, route => {      pushState(cleanPath(this.base + route.fullPath))      handleScroll(this.router, route, fromRoute, false)      onComplete && onComplete(route)    }, onAbort)  }  // replace方法会主动调用transitionTo进行跳转  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {    const { current: fromRoute } = this    this.transitionTo(location, route => {      replaceState(cleanPath(this.base + route.fullPath))      handleScroll(this.router, route, fromRoute, false)      onComplete && onComplete(route)    }, onAbort)  }  ensureURL (push?: boolean) {    if (getLocation(this.base) !== this.current.fullPath) {      const current = cleanPath(this.base + this.current.fullPath)      push ? pushState(current) : replaceState(current)    }  }  getCurrentLocation (): string {    return getLocation(this.base)  }}export function getLocation (base: string): string {  let path = window.location.pathname  const pathLowerCase = path.toLowerCase()  const baseLowerCase = base.toLowerCase()  // base="/a" shouldn't turn path="/app" into "/a/pp"  // https://github.com/vuejs/vue-router/issues/3555  // so we ensure the trailing slash in the base  if (base && ((pathLowerCase === baseLowerCase) ||    (pathLowerCase.indexOf(cleanPath(baseLowerCase + '/')) === 0))) {    path = path.slice(base.length)  }  return (path || '/') + window.location.search + window.location.hash}
登录后复制

可以看到HTML5History类主要干了如下几件事。

继承于History类,并调用父类构造函数初始化。

实现了setupListeners方法,在该方法中检查了是否需要支持滚动行为,如果支持,则初始化滚动相关逻辑,监听了popstate事件,并在popstate触发时自动调用transitionTo方法。

实现了go、push、replace等方法,我们可以看到,history模式其实就是使用的history api

// 可以看到 history模式go方法其实是调用的window.history.go(n)go (n: number) {  window.history.go(n)}// push、replace调用的是util/push-state.js,里面实现了push和replace方法// 实现原理也是使用的history api,并且在不支持history api的情况下使用location apiexport function pushState (url?: string, replace?: boolean) {  ...  const history = window.history  try {    if (replace) {      const stateCopy = extend({}, history.state)      stateCopy.key = getStateKey()      // 调用的 history.replaceState      history.replaceState(stateCopy, '', url)    } else {      // 调用的 history.pushState      history.pushState({ key: setStateKey(genStateKey()) }, '', url)    }  } catch (e) {    window.location[replace ? 'replace' : 'assign'](url)  }}export function replaceState (url?: string) {  pushState(url, true)}
登录后复制

总结

所以history模式的原理就是在js中路由的跳转(也就是使用pushreplace方法)都是通过history apihistory.pushStatehistory.replaceState两个方法完成,通过这两个方法我们知道了路由的变化,然后根据路由映射关系来实现页面内容的更新。

对于直接点击浏览器的前进后退按钮或者js调用 this.$router.go()this.$router.forward()this.$router.back()、或者原生js方法history.back()history.go()history.forward()的,都会触发popstate事件,通过监听这个事件我们就可以知道路由发生了哪些变化然后来实现更新页面内容。

注意history.pushStatehistory.replaceState这两个方法并不会触发popstate事件。在这两个方法里面他是有手动调用transitionTo方法的。

接下来我们再来看看HashHistory类

HashHistory类

当我们使用hash模式的时候会实例化HashHistory类

Vue.mixin({  beforeCreate () {    if (isDef(this.$options.router)) {      this._routerRoot = this      this._router = this.$options.router      this._router.init(this)      Vue.util.defineReactive(this, '_route', this._router.history.current)    } else {      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this    }    registerInstance(this, this)  },  destroyed () {    registerInstance(this)  }})0
登录后复制

可以看到HashHistory类主要干了如下几件事。

继承于History类,并调用父类构造函数初始化。这里比HTML5History多了回退操作,所以,需要将history模式的url替换成hash模式,即添加上#,这个逻辑是由checkFallback实现的

实现了setupListeners方法,在该方法中检查了是否需要支持滚动行为,如果支持,则初始化滚动相关逻辑。 监听了popstate事件或hashchange事件,并在相应事件触发时,调用transitionTo方法实现跳转。

通过const eventType = supportsPushState ? 'popstate' : 'hashchange'我们可以发现就算是hash模式优先使用的还是popstate事件。

实现了go、push、replace等方法。

我们可以看到,hash模式实现的push、replace方法其实也是优先使用history里面的方法,也就是history api

Vue.mixin({  beforeCreate () {    if (isDef(this.$options.router)) {      this._routerRoot = this      this._router = this.$options.router      this._router.init(this)      Vue.util.defineReactive(this, '_route', this._router.history.current)    } else {      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this    }    registerInstance(this, this)  },  destroyed () {    registerInstance(this)  }})1
登录后复制

总结

在浏览器链接里面我们改变hash值是不会重新向后台发送请求的,也就不会刷新页面。并且每次 hash 值的变化,还会触发hashchange 这个事件。

所以hash模式的原理就是通过监听hashchange事件,通过这个事件我们就可以知道 hash 值发生了哪些变化然后根据路由映射关系来实现页面内容的更新。(这里hash值的变化不管是通过js修改的还是直接点击浏览器的前进后退按钮都会触发hashchange事件)

对于hash模式,如果是在浏览器支持history api情况下,hash模式的实现其实是和history模式一样的。只有在不支持history api情况下才会监听hashchange事件。这个我们可以在源码中看出来。

总结

总的来说就是使用 Vue.util.defineReactive 将实例的 _route 设置为响应式对象。在push, replace方法里会主动更新属性 _route。而 go,back,forward,或者通过点击浏览器前进后退的按钮则会在 hashchange 或者 popstate 的回调中更新 _route_route 的更新会触发 RoterView 的重新渲染。

对于第一次进入系统,并不会触发hashchange或者popstate事件,所以第一次需要自己手动匹配路径然后通过transitionTo方法进行跳转,然后渲染对应的视图。

(学习视频分享:web前端开发、编程基础视频)

以上就是一文聊聊Vue-Router的实现原理的详细内容,更多请关注9543建站博客其它相关文章!

广告:SSL证书一年128.66元起,点击购买~~~

9543建站博客
一个专注于网站开发、微信开发的技术类纯净博客。

标签: Vue

作者头像
admin创始人

肥猫,知名SEO博客站长,14年SEO经验。

上一篇:uniapp项目打包安装到手机卡死怎么回事
下一篇:Vue跳转不返回

发表评论

关闭广告
关闭广告