vue-router原理-<router-link>、<router-view>

189 阅读8分钟

核心原理

1、什么是前端路由

在Web前端单页面应用SPA(Single Page Application)中,路由描述的是URL与UI之间的映射关系,这种映射关系是单向的,即URL变化引起UI更新(无需刷新页面)。

2、如何实现前端路由

要实现前端路由,需要解决两个核心:

  • 如何改变URL却不引起页面刷新
  • 如何检测URL变化

下面分别使用hashhistory两种实现方式回答上面的两个核心问题。

hash实现

hash是URL中hash(#)及后面的那部分,常用作锚点在页面内进行导航,改变URL中hash部分不会引起页面刷新,通过hashchange事件监听URL的变化,改变URL的方式只有以下几种:

  • 通过浏览器前进、后退改变URL
  • 通过<a>标签改变URL
  • 通过window.location改变URL

history实现

history提供pushStatereplaceState两个方法,这两个方法改变URL的path部分不会引起页面刷新,history提供类似hashchange事件的popstate事件,但popstate事件有些不同:

  • 通过浏览器前进、后退改变URL时会触发popstate事件
  • 通过pushState、replaceState、<a>标签改变URL不会触发popstate事件(可以通过拦截pushState、replaceState的调用和<a>标签的点击事件来检测URL变化)
  • 通过js调用history的back、go、forward方法可触发该事件

所以监听URL变化可以实现,只是没有hash方式那么方便。

原生js实现前端路由

1、基于hash实现

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>hash</title>
</head>
<body>
  <ul>
    <!-- 定义路由 -->
    <li><a href="#/home">home</a></li>
    <li><a href="#/about">about</a></li>
  </ul>
  <!-- 渲染路由对应的UI -->
  <div id="routerView"></div>
</body>
<script>
  let routerView = document.getElementById('routerView')
  window.addEventListener('hashchange', () => {
    let hash = location.hash
    routerView.innerHTML = hash
  })

  window.addEventListener('DOMContentLoaded', () => {
    if (!location.hash) { // 如果不存在hash,重定向到#/
      location.hash = '/'
    } else {
      let hash = location.hash
      routerView.innerHTML = hash
    }
  })
</script>
</html>

可以看到,我们通过a标签的href属性来改变URL的hash值(触发浏览器的前进、后退按钮也可以,或者在控制台输入window.location赋值来改变hash)。

监听hashchange事件,触发事件就可以改变routerView的内容(在Vue中,改变的是router-view组件中的内容)。

为什么又监听了DOMContentLoaded事件?因为首次加载完页面不会触发hashchange

2.基于history实现

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>history</title>
</head>
<body>
  <ul>
    <!-- 定义路由 -->
    <li><a href="/home">home</a></li>
    <li><a href="/about">about</a></li>
  </ul>
  <!-- 渲染路由对应的UI -->
  <div id="routerView"></div>
</body>
<script>
  let routerView = document.getElementById('routerView')

  function onLoad () {
    routerView.innerHTML = location.pathname

    let arrLink = document.querySelectorAll('a[href]')
    arrLink.forEach(el => el.addEventListener('click', function (e) {
      e.preventDefault()
      console.log(el.getAttribute('href'))
      history.pushState(null, '', el.getAttribute('href'))
      routerView.innerHTML = location.pathname
    }))
  }

  window.addEventListener('DOMContentLoaded', onLoad)
  window.addEventListener('popstate', () => {
    routerView.innerHTML = location.pathname
  })
</script>
</html>

通过a标签的href属性来改变URL的path值(触发浏览器的前进、后退按钮也可以,或者在控制台输入history.gohistory.backhistory.forward赋值来触发popstate事件),需要注意的是,a标签点击改变path值时,默认会触发页面的跳转,所以需要拦截<a>标签点击事件默认行为,使用pushState修改URL并手动更新UI,从而实现点击链接更新URL和UI。

监听popstate事件,触发则更新UI

hash模式也可以使用history.gohistory.backhistory.forward来触发hashchange,不管什么模式,浏览器都会有一个栈来保存记录。

基于Vue实现vue-router

首先,我们创建一个Vue的项目,主要看一下App.vue、About.vue、Home.vue、router/index.js,代码如下:
App.vue

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view/>
  </div>
</template>

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

Home.vue

<template>
  <div class="home">
    <h1>这里是Home组件</h1>
  </div>
</template>

About.vue

<template>
  <div class="about">
    <h1>这是About组件</h1>
  </div>
</template>

启动项目,运行成功后展示如下:

image.png

现在我们决定创建自己的VueRouter,于是创建myVueRouter.js文件,目录如下:

image.png 再将VueRouter引入改成myVueRouter.js

import Vue from 'vue'
import VueRouter from './myVueRouter'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

剖析vue-router本质

首先抛出个问题,Vue项目是怎么引入vue-router

  • 通过npm安装vue-router,再通过import VueRouter from 'vue-router'引入
  • 使用new VueRouter({...})创建router对象
  • 通过Vue.use(VueRouter)使用

Vue.use()的原则就是执行对象的install方法,所以可以大胆试想一下myVueRouter.js的框架如下:

//myVueRouter.js
class VueRouter{

}
VueRouter.install = function () {
    
}

export default VueRouter

分析Vue.use

Vue.use(plugin);
(1)参数

{ Object | Function } plugin

(2)用法
安装 Vue.js 插件。如果插件是一个对象,必须提供 install 方法。如果插件是一个函数,它会被作为 install 方法。install 方法调用时,会将 Vue 作为参数传入。
该方法需要在调用 new Vue() 之前被调用。
当 install 方法被同一个插件多次调用,插件将只会被安装一次。
(3)作用
注册插件,此时只需要调用install方法并将Vue作为参数传入即可。
(4)实现

export function initUse(Vue: GlobalAPI) {
  Vue.use = function (plugin: Function | any) {
    const installedPlugins =
      this._installedPlugins || (this._installedPlugins = [])
    if (installedPlugins.indexOf(plugin) > -1) {
      return this
    }

    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)
    if (isFunction(plugin.install)) {
      plugin.install.apply(plugin, args)
    } else if (isFunction(plugin)) {
      plugin.apply(null, args)
    }
    installedPlugins.push(plugin)
    return this
  }
}

源码解读:
1、在Vue.js上新增一个use方法,接收一个参数plugin
2、首先判断插件是否注册,注册过则返回
3、toArray方法就是将类数组转成真正的数组。将除第一个参数之外的所有参数转成数组赋值给args,因为install方法被调用时,会将 Vue 作为参数传入
4、由于plugin参数支持对象和函数类型,所以通过isFunction判断plugin.install和plugin哪个是函数,即可知用户使用哪种方式注册的插件,然后执行用户编写的插件并将args作为参数传入
5、最后将插件添加到installedPlugins中,保证相同的插件不会反复被注册。(ps:为什么插件不会被重新加载!!!总算是明白了💔
上面用法中讲到,需要把Vue作为install的第一个参数,所以我们可以把Vue保存起来:

//myVueRouter.js
let vuePlugin = null
class VueRouter{

}
VueRouter.install = function (plugin) {
  vuePlugin = plugin
}

export default VueRouter

然后再通过传进来的Vue创建两个组件router-linkrouter-view

//myVueRouter.js
let Vue = null
class VueRouter{

}
VueRouter.install = function (plugin) {
  Vue = plugin
  console.log(plugin)

  Vue.component('router-link', {
    render(h) {
      return h('a', {}, '首页')
    }
  })

  Vue.component('router-view', {
    render(h) {
      return h('h1', {}, '首页视图')
    }
  })
}

export default VueRouter

运行项目,结果如下:

image.png

完善install方法

install一般是给Vue实例添加东西的,在这里就是给组件添加$router$route每个组件添加的$router是同一个,$route也是同一个
接下来看main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

从代码中可以发现,只有根组件有router值,而其他组件还没有,所以我们需要让其他组件也拥有这个router。因此我们可以这样完善install:

//myVueRouter.js
let Vue = null
class VueRouter{

}
VueRouter.install = function (plugin) {
  Vue = plugin
  
  Vue.mixin({
    beforeCreate() {
      if (this.$options && this.$options.router) { // 如果是根组件
        this._root = this // 把当前实例挂载在_root上
        this._router = this.$options.router
      } else { // 如果是子组件
        this._root = this.$parent && this.$parent._root
      }
      Object.defineProperty(this, '$router', {
        get() {
          return this._root._router
        }
      })
    }
  })

  Vue.component('router-link', {
    render(h) {
      return h('a', {}, '首页')
    }
  })

  Vue.component('router-view', {
    render(h) {
      return h('h1', {}, '首页视图')
    }
  })
}

export default VueRouter

源码解读:
1、install方法调用时,会将 Vue 作为参数传入。
2、mixin的作用是将内容混入到 Vue 的初始参数 options 中。
3、为什么是beforeCreate而不是created呢?因为组件beforeCreate生命周期也会使用this.$router
4、如果是根组件,将我们传入的router和_root挂到根组件实例上;如果是子组件,就将_root根组件挂载到子组件。(此处是引用的复制,改变了内部指针的指向)因此每个组件都拥有了同一个_root根组件挂载在它自身。

为什么当前组件是子组件,就可以直接从父组件拿到_root根组件呢?(父组件和子组件的执行顺序

父beforeCreate-> 父created -> 父beforeMounte -> 子beforeCreate ->子create ->子beforeMount ->子 mounted -> 父mounted

可以看到,在执行子组件的beforeCreate的时候,父组件已经执行完beforeCreate了,所以父组件已经有了_root。
然后我们通过

Object.defineProperty(this, '$router', {
    get() {
      return this._root._router
    }
})

$router挂载到组件的实例上。其实这也是一种代理思想,我们获取组件的$router,其实返回的是根组件的_root.router。到这里 install 还没完全实现,因为$route还没实现,接着来~

完善VueRouter类

我们先看看创建VueRouter实例时,传了什么

import Vue from 'vue'
import VueRouter from './myVueRouter'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

可以看到,传入了一个routes的数组、mode路由模式、base,因此我们可以先这样实现VueRouter:

class VueRouter{
  constructor (options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
  }
}

初始化2个属性,但是我们直接处理routers是十分不方便的,所以我们要先转换成key:value的格式。

class VueRouter{
  constructor (options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
    this.routesMap = this.createMap(this.routes)
  }

  createMap (routes) {
    return routes.reduce((pre, current) => {
      pre[current.path] = current.component
      return pre
    }, {})
  }
}

通过createMap我们将

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  }
]

转变成

image.png

路由中需要存放当前路径,来表示当前的路径状态,为了方便管理,可以用一个对象来表示:

let Vue = null
class HistoryRoute {
  constructor() {
    this.current = null
  }
}
class VueRouter{
  constructor (options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
    this.routesMap = this.createMap(this.routes)
    this.history = new HistoryRoute()
  }

  createMap (routes) {
    return routes.reduce((pre, current) => {
      pre[current.path] = current.component
      return pre
    }, {})
  }
}

但是现在我们发现这个current为null,所以我们需要初始化,判断是hash还是history模式,然后将当前路径的值保存到current中。

let Vue = null
class HistoryRoute {
  constructor() {
    this.current = null
  }
}
class VueRouter{
  constructor (options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
    this.routesMap = this.createMap(this.routes)
    this.history = new HistoryRoute()
    this.init()
  }

  createMap (routes) {
    return routes.reduce((pre, current) => {
      pre[current.path] = current.component
      return pre
    }, {})
  }

  init () {
    if (this.mode === 'hash') {
      // 先判断打开有没有hash值,没有的话跳转到#/
      location.hash ? '' : location.hash = '/'
      window.addEventListener('load', () => {
        this.history.current = location.hash.slice(1)
      })
      window.addEventListener('hashchange', () => {
        this.history.current = location.hash.slice(1)
      })
    } else {
      location.pathname ? '' : location.pathname = '/'
      window.addEventListener('load', () => { // 页面加载完毕
        this.history.current = location.pathname
      })
      window.addEventListener('popstate', () => {
        this.history.current = location.pathname
      })
    }
  }
}

完善$route

目前为止,我们已经可以获取当前路径,可以着手实现$route

VueRouter.install = function (plugin) {
  Vue = plugin
  
  Vue.mixin({
    beforeCreate() {
      if (this.$options && this.$options.router) { // 如果是根组件
        this._root = this // 把当前实例挂载在_root上
        this._router = this.$options.router
      } else { // 如果是子组件
        this._root = this.$parent && this.$parent._root
      }
      Object.defineProperty(this, '$router', {
        get() {
          return this._root._router
        }
      })
      Object.defineProperty(this, '$route', {
        get() {
          return this._root._router.history.current
        }
      })
    }
  })

  Vue.component('router-link', {
    render(h) {
      return h('a', {}, '首页')
    }
  })

  Vue.component('router-view', {
    render(h) {
      return h('h1', {}, '首页视图')
    }
  })
}

完善router-view组件

现在我们可以根据当前路径从路由表中获取对应的组件进行渲染

Vue.component('router-view', {
    render(h) {
      let current = this._root._router.history.current
      let routesMap = this._root._router.routesMap
      return h(routesMap[current])
    }
  })

render函数中的this指向的是一个Proxy代理对象,代理Vue组件。所以我们可以获取路由表,然后把获得的组件放在h()里进行渲染。
现在已经实现了router-view组件的渲染,但是还存在一个问题,改变路径的时候,视图没有重新渲染,所以需要将_router.history进行响应式化。

Vue.mixin({
    beforeCreate() {
      if (this.$options && this.$options.router) { // 如果是根组件
        this._root = this // 把当前实例挂载在_root上
        this._router = this.$options.router
        Vue.util.defineReactive(this, 'cui', this._router.history)
      } else { // 如果是子组件
        this._root = this.$parent && this.$parent._root
      }
      Object.defineProperty(this, '$router', {
        get() {
          return this._root._router
        }
      })
      Object.defineProperty(this, '$route', {
        get() {
          return this._root._router.history.current
        }
      })
    }
  })

这里使用了Vue提供的API:defineReactive,使得this._router.history对象得到监听。 因此首次渲染router-view组件的时候,获取到this._router.history这个对象,会把组件的依赖watcher收集到对应的dep收集器中,每次this._router.history改变时,dep会通知router-view组件依赖的watcher进行update(),从而使router-view重新渲染。(Vue响应式原理
现在我们测试一下,看看改变url上的值,router-view是否会重新渲染

image.png

path修改为/about

image.png

实现了当前路径的监听~。

完善router-link组件

我们先看看router-link是怎么使用的

<div id="nav">
  <router-link to="/">Home</router-link>
  <router-link to="/about">About</router-link>
</div>

通过父组件的to参数传递路径进去,因此我们可以这样实现:

Vue.component('router-link', {
    props: {
      to: String
    },
    render(h) {
      let mode = this._root._router.mode
      let to = mode === 'hash' ? '#' + this.to : this.to
      return h('a', {attrs:{href:to}}, this.$slots.default)
    }
  })

我们把router-link渲染成a标签,点击可以切换url上的路径,从而实现视图的重新渲染。
打开页面测试的时候,发现history模式下,点击router-link组件切换页面的时候发现页面整体进行了刷新,显然这不符合我们的预期,所以需要对a标签进行处理:

Vue.component('router-link', {
    props: {
      to: String
    },
    render(h) {
      let mode = this._root._router.mode
      let to = mode === 'hash' ? '#' + this.to : this.to
      return h('a', {
        attrs:{href:to},
        on: {
          click: (e) => {
            if (mode !== 'hash') {
              e.preventDefault()
              this._root._router.history.current = to
              window.history.pushState(null, '', to)
            }
          }
        }
      }, this.$slots.default)
    }
  })

重写a标签的点击事件,阻止事件冒泡,然后手动修改path的路径。
完整的VueRouter代码:

let Vue = null
class HistoryRoute {
  constructor() {
    this.current = null
  }
}
class VueRouter{
  constructor (options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
    this.routesMap = this.createMap(this.routes)
    console.log(this.routesMap)
    this.history = new HistoryRoute()
    this.init()
  }

  createMap (routes) {
    return routes.reduce((pre, current) => {
      pre[current.path] = current.component
      return pre
    }, {})
  }

  init () {
    if (this.mode === 'hash') {
      // 先判断打开有没有hash值,没有的话跳转到#/
      location.hash ? '' : location.hash = '/'
      window.addEventListener('load', () => {
        this.history.current = location.hash.slice(1)
      })
      window.addEventListener('hashchange', () => {
        this.history.current = location.hash.slice(1)
      })
    } else {
      location.pathname ? '' : location.pathname = '/'
      window.addEventListener('load', () => { // 页面加载完毕
        this.history.current = location.pathname
      })
      window.addEventListener('popstate', () => {
        this.history.current = location.pathname
      })
    }
  }
}
VueRouter.install = function (plugin) {
  Vue = plugin
  
  Vue.mixin({
    beforeCreate() {
      if (this.$options && this.$options.router) { // 如果是根组件
        this._root = this // 把当前实例挂载在_root上
        this._router = this.$options.router
        Vue.util.defineReactive(this, '', this._router.history)
      } else { // 如果是子组件
        this._root = this.$parent && this.$parent._root
      }
      Object.defineProperty(this, '$router', {
        get() {
          return this._root._router
        }
      })
      Object.defineProperty(this, '$route', {
        get() {
          return this._root._router.history.current
        }
      })
    }
  })

  Vue.component('router-link', {
    props: {
      to: String
    },
    render(h) {
      let mode = this._root._router.mode
      let to = mode === 'hash' ? '#' + this.to : this.to
      return h('a', {
        attrs:{href:to},
        on: {
          click: (e) => {
            if (mode !== 'hash') {
              e.preventDefault()
              this._root._router.history.current = to
              window.history.pushState(null, '', to)
            }
          }
        }
      }, this.$slots.default)
    }
  })

  Vue.component('router-view', {
    render(h) {
      let current = this._root._router.history.current
      let routesMap = this._root._router.routesMap
      return h(routesMap[current])
    }
  })
}

export default VueRouter

ok,完美搞定!!!😄😄😄