目录

  1. 路由简介
    1. 后端路由简介
    2. 前端路由
    3. 前端路由的主要功能
      1. 用户
      2. 开发者
    4. 1. hash 模式
    5. 2. history 模式
  2. VueRouter
    1. 使用方法
      1. 路由传参
        1. 使用router的name属性也就是params来传递参数
        2. 使用query来传递参数
        3. 使用vue里的标签来传递参数
    2. 核心
      1. 路由模式
        1. Hash模式
        2. History模式
        3. abstract模式
    3. 实现原理
      1. 实现
        1. 思路整理
        2. 数据驱动
        3. hash 和 history

转载: vue-router实现原理
前端路由简介以及vue-router实现原理
前端路由的两种模式: hash 模式和 history 模式

路由简介

后端路由简介

  路由这个概念最先是后端出现的。在以前用模板引擎开发页面时,经常会看到这样

http://www.xxx.com/login

大致流程可以看成这样:
  浏览器发出请求
  服务器监听到80端口(或443)有请求过来,并解析url路径
  根据服务器的路由配置,返回相应信息(可以是 html 字串,也可以是 json 数据,图片等)
  浏览器根据数据包的 Content-Type 来决定如何解析数据

  简单来说路由就是用来跟后端服务器进行交互的一种方式,通过不同的路径,来请求不同的资源,请求不同的页面是路由的其中一种功能。

前端路由

前端路由的主要功能

用户
  1. 记录当前页面的状态(保存或分享当前页的url,再次打开该url时,网页还是保存(分享)时的状态);
  2. 可以使用浏览器的前进后退功能(如点击后退按钮,可以使页面回到使用ajax更新页面之前的状态,url也回到之前的状态);
开发者
  1. 改变url且不让浏览器向服务器发出请求;
  2. 监测 url 的变化;
  3. 截获 url 地址,并解析出需要的信息来匹配路由规则。

1. hash 模式

  随着 ajax 的流行,异步数据请求交互运行在不刷新浏览器的情况下进行。而异步交互体验的更高级版本就是 SPA —— 单页应用。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的,为了实现单页应用,所以就有了前端路由。
  类似于服务端路由,前端路由实现起来其实也很简单,就是匹配不同的 url 路径,进行解析,然后动态的渲染出区域 html 内容。但是这样存在一个问题,就是 url 每次变化的时候,都会造成页面的刷新。那解决问题的思路便是在改变 url 的情况下,保证页面的不刷新。在 2014 年之前,大家是通过 hash 来实现路由,url hash 就是类似于:

http://www.xxx.com/#/login

  这种#。后面hash值的变化,并不会导致浏览器向服务器发出请求,浏览器不发出请求,也就不会刷新页面。另外每次 hash 值的变化,还会触发hashchange 这个事件,通过这个事件我们就可以知道 hash 值发生了哪些变化。然后我们便可以监听 hashchange 来实现更新页面部分内容的操作:

1
2
3
4
5
function matchAndUpdate () {
// todo 匹配 hash 做 dom 更新操作
}

window.addEventListener('hashchange', matchAndUpdate)

使用到的api:

1
2
3
4
5
6
7
window.location.hash = 'qq' // 设置 url 的 hash,会在当前url后加上 '#qq'

var hash = window.location.hash // '#qq'

window.addEventListener('hashchange', function(){
// 监听hash变化,点击浏览器的前进后退会触发
})

  vue-router默认hash模式,使用 URL 的 hash 来模拟一个完整的 URL,于是当 URL 改变时,页面不会重新加载。

2. history 模式

  14年后,因为 HTML5 准发布。多了两个 APIpushStatereplaceState,通过这两个 API 可以改变 url 地址且不会发送请求。同时还有 popstate 事件。通过这些就能用另一种方式来实现前端路由了,但原理都是跟 hash 实现相同的。用了 HTML5 的实现,单页路由的 url 就不会多出一个#,变得更加美观。但因为没有 # 号,所以当用户刷新页面之类的操作时,浏览器还是会给服务器发送请求为了避免出现这种情况,所以这个实现需要服务器的支持,需要把所有路由都重定向到根页面。
  已经有 hash 模式了,而且 hash 能兼容到IE8, history 只能兼容到 IE10,为什么还要搞个 history 呢?
首先,hash 本来是拿来做页面定位的,如果拿来做路由的话,原来的锚点功能就不能用了。其次,hash 的传参是基于 url 的,如果要传递复杂的数据,会有体积的限制,而 history 模式不仅可以在url里放参数,还可以将数据存放在一个特定的对象中

1
2
3
4
5
function matchAndUpdate () {
// todo 匹配路径 做 dom 更新操作
}

window.addEventListener('popstate', matchAndUpdate)

相关API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window.history.pushState(state, title, url) 
// state:需要保存的数据,这个数据在触发popstate事件时,可以在event.state里获取
// title:标题,基本没用,一般传 null
// url:设定新的历史记录的 url。新的 url 与当前 url 的 origin 必须是一樣的,否则会抛出错误。url可以是绝对路径,也可以是相对路径。
//如 当前url是 https://www.baidu.com/a/,执行history.pushState(null, null, './qq/'),则变成 https://www.baidu.com/a/qq/,
//执行history.pushState(null, null, '/qq/'),则变成 https://www.baidu.com/qq/

window.history.replaceState(state, title, url)
// 与 pushState 基本相同,但她是修改当前历史记录,而 pushState 是创建新的历史记录

window.addEventListener("popstate", function() {
// 监听浏览器前进后退事件,pushState 与 replaceState 方法不会触发
});

window.history.back() // 后退
window.history.forward() // 前进
window.history.go(1) // 前进一步,-2为后退两步,window.history.lengthk可以查看当前历史堆栈中页面的数量

  history 模式改变 url 的方式会导致浏览器向服务器发送请求,这不是我们想看到的,我们需要在服务器端做处理:如果匹配不到任何静态资源,则应该始终返回同一个 html 页面

VueRouter

使用方法

路由传参
使用router的name属性也就是params来传递参数

这个方法有一个bug就是当你传参过去的时候,再次刷新页面时参数就会丢失。解决方法下边会说到。

  step:1,首先需要在router/index.js里边配置每个页面的路径,name属性,看例子:

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
import Vue from 'vue'
import Router from 'vue-router'
const _import = require('./_import_' + process.env.NODE_ENV)
Vue.use(Router)
export const constantRouterMap = [{
path: '/login/:userId/:id',
name:'Message', //就是要在路由配置里边配置这个属性,用来知道你要跳转到那个页面的名字
/***
* 如果想做到页面刷新,参数不丢失,就必须在path后面加上这个参数
* 但是这样做的话就会导致参数显示在url的后面,(在这一点上)跟query没什么区别了。
* 多个参数也可以一直往后边追加
*/
component: _import('login/index'),
hidden: true
},
{
path: '',
component: Layout,
redirect: 'dashboard',
icon: 'dashboard',
hidden: true,
noDropDown: true,
children: [{
path: 'dashboard',
name: '首页',
component: _import('main/index'),
meta: {
title: 'dashboard',
icon: 'dashboard',
noCache: true
}
}]
}
]

export default new Router({
routes: constantRouterMap
})

  step:2,在传值页面的写法:
//用这种方法传参,必须这么些,不能写path,否则你在取参数的时候this.$router.params.userId就是undefined.这是因为,params只能用name来引入路由,

1
2
3
4
5
6
this.$router.push({
name:"'Message'",//这个name就是你刚刚配置在router里边的name
params:{
userId:"10011"
}
})

  step:3,在取值页面的写法:
切记,再取参数的时候一定是this.route 不是 this.router,切记。

1
this.$route.params.userId

使用query来传递参数

  step:1,在传值页面的写法:

1
2
3
4
5
6
this.$router.push({
path:"/login",//这个path就是你在router/index.js里边配置的路径
query:{
userId:"10011"
}
})

  step:2,在取值页面的写法:

1
2
3
4
5
第一种:
this.$router.currentRoute.query.userId
第二种:
这种方法再取参数的时候一定是this.$route 不是 this.$router,切记。
this.$route.query.userId

使用vue里的标签来传递参数

  step:1,在传值页面的写法:

1
2
3
<router-link target="_blank"
:to="{path:'/login',query:{userId: "33333"}}">
</router-link>

  step:2,在取值页面的写法:同第二种。
其实,router-link也可以使用name的方法传参
  同样,这种方法也需要在router/index.js里边配置每个页面的路径,name属性
name:'Message', //就是要在路由配置里边配置这个属性,用来知道你要跳转到那个页面的名字
<router-link :to="{name:''Message'',params:{userId:'1234'}}">Hi页面1</router-link>
取参方法:
this.$route.params.userId

核心

更新视图但不重新请求页面
  vue-router实现单页面路由跳转,提供了三种方式:hash模式、history模式、abstract模式,根据mode参数来决定采用哪一种方式。

路由模式

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

Hash模式

  hash即浏览器url中#后面的内容,包含#。hash是URL中的锚点,代表的是网页中的一个位置,单单改变#后的部分,浏览器只会加载相应位置的内容,不会重新加载页面。
也就是说
  1. 即#是用来指导浏览器动作的,对服务器端完全无用,HTTP请求中,不包含#。
  2.每一次改变#后的部分,都会在浏览器的访问历史中增加一个记录,使用”后退”按钮,就可以回到上一个位置。

所以说Hash模式通过锚点值的改变,根据不同的值,渲染指定DOM位置的不同数据。

History模式

  HTML5 History API提供了一种功能,能让开发人员在不刷新整个页面的情况下修改站点的URL,就是利用 history.pushState API 来完成 URL 跳转而无须重新加载页面;
  由于hash模式会在url中自带#,如果不想要很丑的 hash,我们可以用路由的 history 模式,只需要在配置路由规则时,加入”mode: ‘history‘“,这种模式充分利用 history.pushState API 来完成 URL 跳转而无须重新加载页面。
  有时,history模式下也会出问题:
eg:
hash模式下:xxx.com/#/id=5 请求地址为 xxx.com,没有问题。
history模式下:xxx.com/id=5 请求地址为 xxx.com/id=5,如果后端没有对应的路由处理,就会返回404错误;
  为了应对这种情况,需要后台配置支持:
在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。

abstract模式

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

实现原理

我们来看一下vue-router是如何定义的:

1
2
3
4
5
6
7
8
9
10
11
12
import VueRouter from 'vue-router'
Vue.use(VueRouter)

const router = new VueRouter({
mode: 'history',
routes: [...]
})

new Vue({
router
...
})

  可以看出来vue-router是通过 Vue.use的方法被注入进 Vue 实例中,在使用的时候我们需要全局用到 vue-router的router-view和router-link组件,以及this.$router/$route这样的实例对象。那么是如何实现这些操作的呢?下面我会分几个章节详细的带你进入vue-router的世界。

1
2
3
4
5
vue-router 实现 -- install
vue-router 实现 -- new VueRouter(options)
vue-router 实现 -- HashHistory
vue-router 实现 -- HTML5History
vue-router 实现 -- 路由变更监听

实现

  经过上面的阐述,相信您已经对前端路由以及vue-router有了一些大致的了解。那么这里我们为了贯彻无解肥,我们来手把手撸一个下面这样的数据驱动的 router:

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
new Router({
id: 'router-view', // 容器视图
mode: 'hash', // 模式
routes: [
{
path: '/',
name: 'home',
component: '<div>Home</div>',
beforeEnter: (next) => {
console.log('before enter home')
next()
},
afterEnter: (next) => {
console.log('enter home')
next()
},
beforeLeave: (next) => {
console.log('start leave home')
next()
}
},
{
path: '/bar',
name: 'bar',
component: '<div>Bar</div>',
beforeEnter: (next) => {
console.log('before enter bar')
next()
},
afterEnter: (next) => {
console.log('enter bar')
next()
},
beforeLeave: (next) => {
console.log('start leave bar')
next()
}
},
{
path: '/foo',
name: 'foo',
component: '<div>Foo</div>'
}
]
})

思路整理

  首先是数据驱动,所以我们可以通过一个route对象来表述当前路由状态,比如:

1
2
3
4
5
6
7
8
current = {
path: '/', // 路径
query: {}, // query
params: {}, // params
name: '', // 路由名
fullPath: '/', // 完整路径
route: {} // 记录当前路由属性
}

  current.route内存放当前路由的配置信息,所以我们只需要监听current.route的变化来动态render页面便可。
接着我么需要监听不同的路由变化,做相应的处理。以及实现hash和history模式。

数据驱动

  这里我们延用vue数据驱动模型,实现一个简单的数据劫持,并更新视图。首先定义我们的observer

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
class Observer {
constructor (value) {
this.walk(value)
}

walk (obj) {
Object.keys(obj).forEach((key) => {
// 如果是对象,则递归调用walk,保证每个属性都可以被defineReactive
if (typeof obj[key] === 'object') {
this.walk(obj[key])
}
defineReactive(obj, key, obj[key])
})
}
}

function defineReactive(obj, key, value) {
let dep = new Dep()
Object.defineProperty(obj, key, {
get: () => {
if (Dep.target) {
// 依赖收集
dep.add()
}
return value
},
set: (newValue) => {
value = newValue
// 通知更新,对应的更新视图
dep.notify()
}
})
}

export function observer(value) {
return new Observer(value)
}
再接着,我们需要定义Dep和Watcher:

export class Dep {
constructor () {
this.deppend = []
}
add () {
// 收集watcher
this.deppend.push(Dep.target)
}
notify () {
this.deppend.forEach((target) => {
// 调用watcher的更新函数
target.update()
})
}
}

Dep.target = null

export function setTarget (target) {
Dep.target = target
}

export function cleanTarget() {
Dep.target = null
}

// Watcher
export class Watcher {
constructor (vm, expression, callback) {
this.vm = vm
this.callbacks = []
this.expression = expression
this.callbacks.push(callback)
this.value = this.getVal()

}
getVal () {
setTarget(this)
// 触发 get 方法,完成对 watcher 的收集
let val = this.vm
this.expression.split('.').forEach((key) => {
val = val[key]
})
cleanTarget()
return val
}

// 更新动作
update () {
this.callbacks.forEach((cb) => {
cb()
})
}
}

  到这里我们实现了一个简单的订阅-发布器,所以我们需要对current.route做数据劫持。一旦current.route更新,我们可以及时的更新当前页面:

// 响应式数据劫持
observer(this.current)

// 对 current.route 对象进行依赖收集,变化时通过 render 来更新
new Watcher(this.current, 'route', this.render.bind(this))
恩….到这里,我们似乎已经完成了一个简单的响应式数据更新。其实render也就是动态的为页面指定区域渲染对应内容,这里只做一个简化版的render:

1
2
3
4
5
6
render() {
let i
if ((i = this.history.current) && (i = i.route) && (i = i.component)) {
document.getElementById(this.container).innerHTML = i
}
}

hash 和 history

  接下来是hash和history模式的实现,这里我们可以沿用vue-router的思想,建立不同的处理模型便可。来看一下我实现的核心代码:
this.history = this.mode === 'history' ? new HTML5History(this) : new HashHistory(this)
当页面变化时,我们只需要监听hashchange和popstate事件,做路由转换transitionTo:

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
  /**
* 路由转换
* @param target 目标路径
* @param cb 成功后的回调
*/
transitionTo(target, cb) {
// 通过对比传入的 routes 获取匹配到的 targetRoute 对象
const targetRoute = match(target, this.router.routes)
this.confirmTransition(targetRoute, () => {
// 这里会触发视图更新
this.current.route = targetRoute
this.current.name = targetRoute.name
this.current.path = targetRoute.path
this.current.query = targetRoute.query || getQuery()
this.current.fullPath = getFullPath(this.current)
cb && cb()
})
}

/**
* 确认跳转
* @param route
* @param cb
*/
confirmTransition (route, cb) {
// 钩子函数执行队列
let queue = [].concat(
this.router.beforeEach,
this.current.route.beforeLeave,
route.beforeEnter,
route.afterEnter
)

// 通过 step 调度执行
let i = -1
const step = () => {
i ++
if (i > queue.length) {
cb()
} else if (queue[i]) {
queue[i](step)
} else {
step()
}

}
step(i)
}
}

  这样我们一方面通过this.current.route = targetRoute达到了对之前劫持数据的更新,来达到视图更新。另一方面我们又通过任务队列的调度,实现了基本的钩子函数beforeEach、beforeLeave、beforeEnter、afterEnter
到这里其实也就差不多了,接下来我们顺带着实现几个API吧:

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
/**
* 跳转,添加历史记录
* @param location
* @example this.push({name: 'home'})
* @example this.push('/')
*/
push (location) {
const targetRoute = match(location, this.router.routes)

this.transitionTo(targetRoute, () => {
changeUrl(this.router.base, this.current.fullPath)
})
}

/**
* 跳转,添加历史记录
* @param location
* @example this.replaceState({name: 'home'})
* @example this.replaceState('/')
*/
replaceState(location) {
const targetRoute = match(location, this.router.routes)

this.transitionTo(targetRoute, () => {
changeUrl(this.router.base, this.current.fullPath, true)
})
}

go (n) {
window.history.go(n)
}

function changeUrl(path, replace) {
const href = window.location.href
const i = href.indexOf('#')
const base = i >= 0 ? href.slice(0, i) : href
if (replace) {
window.history.replaceState({}, '', `${base}#/${path}`)
} else {
window.history.pushState({}, '', `${base}#/${path}`)
}
}

到这里也就基本上结束了