vue-router原理剖析

Vue Router 是 Vue.js 官方的路由管理器,它和 Vue.js 的核心深度集成,让构建单页面应用变得易如反掌。本文主要通过模拟vue-router案例,来深入探讨下vue-router原理的实现过程

路由是什么?

在前后端分离之前,一般提到的路由都是后端路由,路由通过一个请求,然后分发到指定的路径,匹配对应的处理程序,它的作用就是分发请求,把对应的请求分发到对应的位置

前端路由与后端路由

后端路由

后端路由可以理解为服务器将浏览器请求的url解析之后映射成对应的函数,这个函数会根据资源类型的不同进行不同的操作,如果是静态资源,那么就进行文件读取,如果是动态数据,那么就会通过数据库进行一些增删查改的操作

优点:利于SEO且安全性较高;
缺点:代码耦合度高,加大了服务器压力,且http请求受限于网络环境,影响用户体验

前端路由

随着前端单页应用(SPA)的兴起,前端页面完全变成了组件化,不同的页面就是不同的组件,页面的切换就是组件的切换;页面切换的时候不需要再通过http请求,直接通过JS解析url地址,然后找到对应的组件进行渲染,如果需要服务端内容,可以通过发送ajax请求获取

前端路由与后端路由最大的不同就是不需要再经过服务器,直接在浏览器下通过JS解析页面之后就可以拿到相应的页面

优点:组件切换不需要发送http请求,切换跳转快,用户体验好
缺点:前进后退会重新发送请求,没有合理的利用缓存且不利于SEO

路由模式

vue-router 提供了三种运行模式:

  • hash: 使用 URL hash 值来作路由。默认模式。
  • history: 依赖 HTML5 History API 和服务器配置。查看 HTML5 History 模式。
  • abstract: 支持所有 JavaScript 运行环境,如 Node.js 服务器端。

hash 模式

hash模式是vue-router的默认路由模式,它是基于锚点,以及onhashchange事件,通过锚点的值作为路由地址,当地址发生变化后,触发onhashchange事件,即根据路径决定页面中呈现的内容。

  • hash模式的标志是在域名之后带有一个#,#后面内容作为路由地址,可以通过?携带参数,如果只改变#后面的内容,浏览器不会向服务器请求地址,会将地址记录到访问历史中
1
http://localhost:8080/#/home?id=1
  • 使用window.location.hash获取到当前url的hash,通过hashchange方法可以监听url中hash的变化
1
window.addEventListener("hashchange", function(){}, false)
  • hash模式同样支持浏览器的前进后退操作,因为当hash模式的url改变后,这个url会存入浏览器的历史记录,所以可以通过浏览器完成前进后退

history模式

  • history模式是另一种前端路由模式,它基于HTML5的History API,通过window.location.pathname获取到当前url的路由地址
  • 当调用 history.push() 时,路径会发生变化,要向服务器发生请求
  • history模式下,通过history.pushState()history.replaceState()方法可以修改url地址(只更改浏览器地址栏地址,并将地址记录到历史记录中,不向服务器发送请求),可以结合popstate方法监听url中路由的变化,也就是说,可以使用 pushState() 实现客户端路由,但是需要在IE10以后使用
  • history模式的特点是实现更加方便,可读性更强,同时因为没有了#,url也更加美观

它的劣势也比较明显,当用户刷新或直接输入地址时会向服务器发送一个请求,所以history模式需要服务端进行支持,将路由都重定向到根路由

History 模式演示

vue-cli自带web服务器配置好对history支持,为了演示之前的问题,通过node或者Nginx服务器。
使用vue-cli创建的vue项目默认是hash模式,可以通过mode字段更改模式

1
2
3
4
5
6
const router =new vueRouter({
mode:'history',
// 设置路由基地址
base:process.env.BASE_URL,
routes
})
  • History 需要服务器的支持
  • 单页应用中,服务端不存在 http://www.testurl.com/login 这样的地址,会返回找不到该页面
  • 在服务端应该除了静态资源外都返回单页应用的 index.html

node

项目目录结构

目录

  1. 使用history模式没有服务器支持的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
// 02-backend/app.js
const path = require('path')
// 导入 express 基于node的web开发框架 npm
const express = require('express')

const app = express()
// 处理静态资源的中间件,网站根目录 ../web
app.use(express.static(path.join(__dirname, '../web')))

// 开启服务器,端口是 3000
app.listen(3000, () => {
console.log('服务器开启,端口:3000')
})

运行node app.js启动服务,在浏览器运行http://localhost:3000就可看到具体项目,如下图所示

web
F5整体刷新后,浏览器发送请求,但是node服务器中没有出来/about地址,node服务器输出一个默认404页面

web-error

  1. 在node中配置history模式
1
npm install connect-history-api-fallback
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 02-backend/app.js
const path = require('path')
// 导入处理 history 模式的模块 npm
const history = require('connect-history-api-fallback')
// 导入 express 基于node的web开发框架 npm
const express = require('express')

const app = express()
// 注册处理 history 模式的中间件
app.use(history()) //处理vue-router的history模式
// 处理静态资源的中间件,网站根目录 ../web
app.use(express.static(path.join(__dirname, '../web')))

// 开启服务器,端口是 3000
app.listen(3000, () => {
console.log('服务器开启,端口:3000')
})

当开启服务器支持history模式的时候,F5刷新后浏览器发送http://localhost:3000/about请求,服务器接收到请求后,判断请求的路径是否有对应的页面,没有会把当前单页应用默认的index.html(根目录下面(打包文件中的index.html))返回给浏览器,浏览器接收到页面后会判断当前路由地址,在客户端解析路由地址对应的组件,加载对应内容并渲染到浏览器中。

nginx

  • 从官网下载 nginx 的压缩包
  • 把压缩包解压到 c 盘根目录,c:\nginx-1.18.0 文件夹,注意:目录不能有中文
  • 打开命令行,切换到目录 c:\nginx-1.18.0

    web

  • 启动 nginx,默认端口是80,可以在配置文件nginx.conf中修改

1
$ start nginx.exe
  • 重启
1
$ nginx.exe -s reload
  • 停止
1
$ nginx.exe -s stop

启动服务后在浏览器中可以看到项目,nginx没有处理vue-router的history模式,当刷新浏览器时候请求/video时服务器不存在对应的页面从而返回404页面,如下图所示

web-error

解决:在nginx.conf配置try_files

web-error

Try_files:试着访问当前浏览器请求路径所对应的文件
$uri:当前请求的路径,会找这个路径对应的文件,找到直接返回,没有找到继续从$uri/index.html,没有找到,返回单页应用的(根路径下)的index.html

abstract模式

abstract模式是使用一个不依赖于浏览器的浏览历史虚拟管理后端。

根据平台差异可以看出,在 Weex 环境中只支持使用 abstract 模式。 不过,vue-router 自身会对环境做校验,如果发现没有浏览器的 API,vue-router 会自动强制进入 abstract 模式,所以 在使用 vue-router 时只要不写 mode 配置即可,默认会在浏览器环境中使用 hash 模式,在移动端原生环境中使用 abstract 模式。 (当然,你也可以明确指定在所有情况下都使用 abstract 模式)

VueRouter 实现原理

vue-router是前端路由,当路径切换的时候在浏览器端判断当前路径,并加载当前路径对应的组件

vue的前置知识

在模拟 Vue-Router 的过程中,我们需要简单了解一些相关的知识

插件 Vue.use()

Vue.use()方法用于插件安装,通过它可以将一些功能或API入侵到Vue内部,它接收一个参数,如果这个参数是对象,那么Vue.use()会执行这个install方法,如果接收到的参数是一个函数,那么这个函数会作为install方法被执行

install方法在执行的时候也会接收到一个参数,这个参数就是当前Vue的实例

通过接收到的Vue实例,可以定义一些全局方法或属性,也可以通过prototype对Vue的实例方法进行扩展

1
2
3
4
5
6
7
class vueRouter {
constructor(){
}
}
vueRouter.install = function(Vue) {

}

Vue.use() 会自动阻止多次注册相同插件,届时即使多次调用也只会注册一次该插件。

混入 Vue.mixin()

混入(mixin),用来分发 Vue 组件中的可复用功能。一个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被“混合”进入该组件本身的选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义一个混入对象
var myMixin = {
created: function () {
this.hello()
},
methods: {
hello: function () {
console.log('hello from mixin!')
}
}
}

// 定义一个使用混入对象的组件
var Component = Vue.extend({
mixins: [myMixin]
})

var component = new Component() // => "hello from mixin!"

全局混入

混入也可以进行全局注册。使用时格外小心!一旦使用全局混入,它将影响每一个之后创建的Vue实例。使用恰当时,这可以用来为自定义选项注入处理逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 为自定义的选项 'myOption' 注入一个处理器。
Vue.mixin({
created: function () {
var myOption = this.$options.myOption
if (myOption) {
console.log(myOption)
}
}
})

new Vue({
myOption: 'hello!'
})
// => "hello!"

vue.observable(object)

创建响应式对象,创建出的对象可以直接用在渲染函数或者计算属性上面,并且会在发生变更时触发相应的更新。也可以作为最小化的跨组件状态存储器,用于简单的场景:

1
2
3
4
5
6
7
8
9
const state = Vue.observable({ count: 0 })

const Demo = {
render(h) {
return h('button', {
on: { click: () => { state.count++ }}
}, `count is: ${state.count}`)
}
}

在 Vue 2.x 中,被传入的对象会直接被 Vue.observable 变更,所以如这里展示的,它和被返回的对象是同一个对象。
在 Vue 3.x 中,则会返回一个可响应的代理,而对源对象直接进行变更仍然是不可响应的。
因此,为了向前兼容,我们推荐始终操作使用 Vue.observable 返回的对象,而不是传入源对象。

插槽 slot

元素作为承载分发内容的出口。插槽内可以包含任何模板代码,包括 HTML、其他组件等

1
2
3
4
5
6
7
8
Vue.component('alert-box', {
template: `
<div class="demo-alert-box">
<strong>Error!</strong>
<slot></slot>
</div>
`
})

render函数

字符串模板的代替方案,允许你发挥 JavaScript 最大的编程能力。该渲染函数接收一个createElement方法(h)作为第一个参数用来创建虚拟DOM。
如果组件是一个函数组件,渲染函数还会接收一个额外的context参数,为没有实例的函数组件提供上下文信息。
Vue 选项中的 render 函数若存在,则 Vue 构造函数不会从 template 选项或通过 el 选项指定的挂载元素中提取出的 HTML 模板编译渲染函数。

运行时和完整版的vue

  • 运行版 Vue

vue-cli创建项目默认是运行时版本,不支持 template 模板,需要打包的时候提前编译,把template编译成render函数,使用render函数创建虚拟DOM,并把它渲染到视图

使用render函数,替代template模板,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default class VueRouter {
initComponents (Vue) {
Vue.component('router-link', {
props: {
to: String
},
// template:'<a :href="to"><slot></slot></a>'
// h 函数,创建虚拟 DOM
render (h) {
return h('a',{
attrs: {
href:this.to
},
// 注册点击事件
on:{
click:this.clickHandler
}
},[this.$slots.default])
}
})
}
}

在运行时版本单文件组件template为何可以正常工作?
在打包时候将单文件组件template编译成render函数,即预编译

  • 完整版 Vue

包含运行时和编译器,体积比运行时版大10K左右,编译器作用就是程序运行的时候把模板转换成render函数。

在vue.config.js中,开启使用包含运行时编译器的Vue核心版本,代码如下:

1
2
3
module.exports = {
runtimeCompiler: true // 默认 false
}

vue-router使用步骤

  1. 在router/index.js中注册路由插件,创建router对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import Vue from 'vue';
import vueRouter from '../vueRouter';

import Home from '@views/home';
// 1.注册路由插件
Vue.use(vueRouter);
// 路由规则
const routes=[{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/About',
name: 'About',
component: ()=>import(/* webpackChunkName:'About' */ '@views/about'),
}]
// 2 创建router对象
const router =new vueRouter({
routes
})
  • 在main.js中注册router对象
1
2
3
4
5
6
7
8
9
10
11
12
13
import Vue from 'vue'
import App from './App.vue'
import router from './router'

import './style.less'
Vue.config.productionTip = false
new Vue({
// 3.注册router对象
// 传入router作用:vue实例中注入$route(路由规则)和$router(路由对象)
router,
render: h => h(App)
// $mount 把虚拟DOM转换为真实DOM渲染到浏览器
}).$mount('#app')
  • 在App.vue中创建链接和路由组件占位
1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div id="app" >
<div id="nav">
<!-- 5.创建链接 -->
<router-link to='/'>Home</router-link>
<span> | </span>
<router-link to='/About'>About</router-link>
</div>
<!-- 4.创建路由组件占位 -->
<router-view/>
</div>
</template>

vueRouter模拟实现

history模式为例

实现思路

类图

install()

install() 方法是 VueRouter 类中的静态方法,当使用 Vue.use(fun | obj) 注册插件时,会调用 install() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* install方法:实现vue插件机制
* @param {[Object]} vue [Vue构造器]
* @param {[Object]} options [可选的选项对象]
*/
static install (vue, options) {
// 1.判断当前插件是否已经被安装
if(VueRouter.install.installed){
return
}
// 插件已经被安装
VueRouter.install.installed = true
// 2.把vue构造函数记录到全局变量,因为在VueRouter实例方法中要使用vue构造函数,比如创建router-link和router-view组件时候使用vue.component
_vue = vue
// 3.把创建vue实例时候传入的router对象注入到vue实例上
// 在插件中给所有的vue实例混入一个选项
_vue.mixin({
// 所有的组件都会执行混入的beforeCreate,需要判断只需要在vue实例中执行一次,组件中不需要执行
beforeCreate(){
// 只有vue的$option选项中才有router这个属性,组件的选项中是没有的(this指向当前是vue实例)
if(this.$options.router){
// console.log(this.$options.router); //VueRouter  this:vue
_vue.prototype.$router = this.$options.router
this.$options.router.init()
}
}
})
}
constructor()

VueRouter 类的构造函数,接收一个 Options 选项,它的的返回值是一个 VueRouter 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 构造函数,返回vueRouter对象
* @param {Object} options [记录构造函数中传入的对象(new VueRouter(routes:[{}])路由规则routes)]
* @param {Object} routeMap [是一个对象,用来记录路由地址和组件的对应关系,将来会把路由规则解析到routeMap中]
* @param {Object} data [存储当前的路由地址,当路由变化时,需要加载对应的组件,因此,需要设置成一个响应式的对象]
*/
constructor (options) {
// 记录构造函数中传入的选项(new vueRouter(routes:[{}]))
this.options = options
// 当options中传入的 routes(路由规则) 解析出来以后,会将其存储到routeMap(键:路由地址 值:地址所对应的路由组件)对象中,以便在router-view组件中,可以根据路由地址在routeMap中找到对应的组件,并将其渲染到浏览器中
this.routeMap = {}
// 响应式对象,使用 Vue.observable() 创建 当路由地址发生改变后对应的组件要自动更新
this.data=_vue.observable({
// 记录当前的路由地址,默认 '/'
current: '/'
})
}
createRouteMap()

初始化routeMap属性,把构造函数中选项的 routes(路由规则),转换成键值对的形式,存储到 routeMap对象中。

1
2
3
4
5
6
createRouteMap(){
// 遍历所有的路由规则,把路由规则解析成键值对的形式,存储到routeMap(键:路由地址 值:地址所对应的路由组件)中
this.options.routes.forEach(route => {
this.routeMap[route.path] = route.component
})
}
initComponents()

用来创建router-link和router-view组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
initComponents(vue){
// router-link组件,最终以a标签形式渲染到浏览器中
vue.component('router-link',{
props:{
to: String
},
// template:'<a :href="to"><slot></slot></a>' // 完整版vue可以使用,vue-cli创建默认是运行时,如果使用可以配置vue.config.js https://cli.vuejs.org/zh/config/#runtimecompiler
/**
* 渲染函数
* @param {function} h [创建虚拟DOM,可以将标签和组件转换为虚拟DOM 参数:(选择器(标签名),创建DOM属性(标签属性),标签内容(生成元素中的子元素数组形式))]
*/
render(h) {
// render函数中调用h函数并将结果返回
// this.$slots.default 获取默认插槽
return h('a',{
attrs: {
href:this.to
},
// 注册点击事件
on:{
click:this.clickHandler
}
},[this.$slots.default])
},
methods:{
clickHandler(e){
// console.log(this); //VueComponent 
// 改变浏览器的地址栏,但不向服务器发送请求,只在客户端进行操作
/**
* pushState() 仅仅只改变浏览器地址栏中的地址,不会像服务器发送请求
* @param data 触发popstate事件,传给 popstate 的事件对象
* @param title 网页的标题
* @param url? 地址
*/
history.pushState({}, '', this.to)
// 将当前的路径记录到 data.current 中
// data响应式对象,当值改变时,自动加载对应的组件,进行渲染视图
// console.log(this.$router); //VueRouter 
this.$router.data.current = this.to
// 阻止默认事件(点击超链接不向服务器发送请求)
e.preventDefault()
}
}
})
// router-view 组件
// console.log(this); //VueRouter 
let vm = this
vue.component('router-view',{
render(h){
// console.log(this); //proxy
// 通过当前路由地址,在routeMap中找到对应组件
const component = vm.routeMap[vm.data.current]
// h 函数,直接把一个组件转换成虚拟DOM并返回
return h(component)
}
})
}
initEvent()

initEvent(),注册 popstate 事件,当历史发生变化时,进行触发。即点击浏览器的前进后退按钮时,触发 popstate 事件。

1
2
3
4
5
initEvent(){
window.addEventListener('popstate',() => {
this.data.current = window.location.pathname
})
}
init()

用来初始化调用其他函数,在install()调用

1
2
3
4
5
init(){
this.createRouteMap()
this.initComponents(_vue)
this.initEvent()
}

hash 模式下,使用window.location.hash获取到当前url的hash,通过hashchange方法可以监听url中hash的变化,其余方面与 history 模式类似,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
let _vue = null
export default class VueRouter {
constructor (options) {
this.options = options
this.routeMap = {}
// 当模式为 hash,初始进入时,进行拼接
window.location.hash = window.location.hash ? window.location.hash : '#/'
this.data=_vue.observable({
// 记录当前的路由地址
current: window.location.hash
})
}
static install (vue, options) {
if(VueRouter.install.installed){
return
}
VueRouter.install.installed = true
_vue = vue
_vue.mixin({
beforeCreate(){
if(this.$options.router){
_vue.prototype.$router = this.$options.router
this.$options.router.init()
}
}
})
}
init(){
this.createRouteMap()
this.initComponents(_vue)
this.initEvent()
}
createRouteMap(){
this.options.routes.forEach(route => {
this.routeMap[`#${route.path}`] = route.component
})
}
initComponents(vue){
let vm = this
// router-link组件
vue.component('router-link',{
props:{
to: String
},
render(h) {
return h('a',{
attrs: {
href:`#`,
name:this.to
},
// 注册点击事件
on:{
click:this.clickHandler
}
},[this.$slots.default])
},
methods:{
clickHandler(e){
window.location.hash = `#${this.to}`
e.preventDefault()
}
}
})
// router-view 组件
vue.component('router-view',{
render(h){
const component = vm.routeMap[vm.data.current]
return h(component)
}
})
}
// 注册 popstate 事件
initEvent(){
window.addEventListener('hashchange',() => {
this.data.current = window.location.hash
})
}
}

案例代码详见:vueRoter-custom

参考

vue-router原理到最佳实践
vuejs/vue-router