vue-router实现原理分享

这是很久之前的我们业务组内部的一个分享,一直没有更新发布了。乘此再复习一遍

vue-router 是什么

首先我们需要知道vue-router是什么,它是干什么的?

这里的路由并不是指我们平时所说的硬件路由器,这里的路由就是SPA(单页应用)的路径管理器。 换句话说,vue-router就是WebApp的链接路径管理系统。

路由是根据不同的url地址展示不同的内容或者页面。前端路由就是把不同路由对应不同的内容或者页面的任务交给前端来做,之前是通过服务端根据url的不同返回不同的页面实现的。

vue-router是Vue.js官方的路由插件,它和vue.js是深度集成的,适合用于构建单页面应用。

那与传统的页面跳转有什么区别呢?

  1. vue的单页面应用是基于路由和组件的,路由用于设定访问路径,并将路径和组件映射起来。
  2. 传统的页面应用,是用一些超链接来实现页面切换和跳转的。

在vue-router单页面应用中,则是路径之间的切换,也就是组件的切换。路由模块的本质 就是建立起url和页面之间的映射关系。

vue-router 产生的时代背景

随着 ajax 的流行,异步数据请求交互运行在不刷新浏览器的情况下进行。而异步交互体验的更高级版本就是 SPA —— 单页应用。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的,为了实现单页应用,所以就有了前端路由。

为什么要使用vue-router

至于我们为啥不能用a标签,这是因为用Vue做的都是单页面应用(当你的项目准备打包时,运行 npm run build时,就会生成dist文件夹,这里面只有静态资源和一个index.html页面,在没有后端服务的支持下,浏览器无法找到对应的url路径的),所以你写的标签是不起作用的,你必须使用vue-router来进行管理。

Vue Router 包含的功能有:

  • 嵌套的路由/视图表
  • 模块化的、基于组件的路由配置
  • 路由参数、查询、通配符
  • 基于 Vue.js 过渡系统的视图过渡效果
  • 细粒度的导航控制
  • 带有自动激活的 CSS class 的链接
  • HTML5 历史模式或 hash 模式,在 IE9 中自动降级
  • 自定义的滚动条行为

如何使用vue-router

不做讲解,都用过无数遍了。看vue-router文档 https://router.vuejs.org/zh/

vue-router实现原理

SPA(single page application):单一页面应用程序,只有一个完整的页面;它在加载页面时,不会加载整个页面,而是只更新某个指定的容器中内容。

vue-router 使用 path-to-regexp 作为路径匹配引擎,用来匹配path和params

单页面应用(SPA)的核心之一是:

  1. 更新视图而不重新请求页面
  2. vue-router在实现单页面前端路由时,提供了三种方式:Hash模式和History模式;根据mode参数以及运行环境决定采用哪一种方式。

1. hash 模式

vue-router 默认 hash 模式 —— 使用 URL 的 hash 来模拟一个完整的 URL,于是当 URL 改变时,页面不会重新加载。hash(#)是URL 的锚点,代表的是网页中的一个位置,单单改变#后的部分,浏览器只会滚动到相应位置,不会重新加载网页,也就是说hash 出现在 URL 中,但不会被包含在 http 请求中,对后端完全没有影响,因此改变 hash 不会重新加载页面;同时每一次改变#后的部分,都会在浏览器的访问历史中增加一个记录,使用”后退”按钮,就可以回到上一个位置;所以说Hash模式通过锚点值的改变,根据不同的值,渲染指定DOM位置的不同数据。hash 模式的原理是 onhashchange 事件(监测hash值变化),可以在 window 对象上监听这个事件。

在 2014 年之前,大家是通过 hash 来实现路由,url hash 就是类似于:

1
http://www.renrenche.com/#/page1

后来,因HTML5的发布,又出现了一个onpopstate事件,其可以代替onhashchange使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window.addEventListener(
supportsPushState ? 'popstate' : 'hashchange',
() => {
const current = this.current
if (!ensureSlash()) {
return
}
this.transitionTo(getHash(), route => {
if (supportsScroll) {
handleScroll(this.router, route, current, true)
}
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
}
)

2. history 模式

由于hash模式会在url中自带#,如果不想要很丑的hash,我们可以用vue-router的 history 模式。

14年后,因为HTML5标准发布。html5 history interface 中新增的了 pushState() 和 replaceState()方法这两个方法应用于浏览器记录栈,在当前已有的 back、forward、go基础之上,它们提供了对历史记录修改的功能。只是当它们执行修改时,虽然改变了当前的URL,但浏览器不会立即向后端发送请求。同时还有popstate事件。通过这些就能用另一种方式来实现前端路由了,但原理都是跟hash实现相同的。

当你使用 history 模式时,URL就像正常的url,单页路由的url就不会多出一个#,例如 http://shanyishanmei.com/user/id 变得更加美观!但因为没有#号,所以当用户刷新页面之类的操作时,浏览器还是会给服务器发送请求。为了避免出现这种情况,所以这个实现需要服务器的支持,需要把所有路由都重定向到根页面。因为我们的应用是个单页客户端应用,如果后台没有正确的配置,当用户在浏览器直接访问http://shanyishanmei.com/user/id2就会返回404,这就不对了。所以呢,你要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个index.html页面,这个页面就是你 app 依赖的页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window.addEventListener('popstate', e => {
const current = this.current

// Avoiding first `popstate` event dispatched in some browsers but first
// history route not updated since async guard at the same time.
// 避免在某些浏览器中触发第一个“popstate”事件,但同时由于异步保护而未更新第一个历史路由。
const location = getLocation(this.base)
if (this.current === START && location === initLocation) {
return
}

this.transitionTo(location, route => {
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
})

3. abstract 模式

通过一个数组和一个数字变量来模拟浏览器的history的。支持所有 JavaScript 运行环境,如 Node.js 服务器端。如果发现没有浏览器的 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
43
44
45
46
47
48
constructor (router: Router, base: ?string) {
super(router, base)
this.stack = []
this.index = -1
}

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(
location,
route => {
this.stack = this.stack.slice(0, this.index + 1).concat(route)
this.index++
onComplete && onComplete(route)
},
onAbort
)
}

replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(
location,
route => {
this.stack = this.stack.slice(0, this.index).concat(route)
onComplete && onComplete(route)
},
onAbort
)
}

go (n: number) {
const targetIndex = this.index + n
if (targetIndex < 0 || targetIndex >= this.stack.length) {
return
}
const route = this.stack[targetIndex]
this.confirmTransition(
route,
() => {
this.index = targetIndex
this.updateRoute(route)
},
err => {
if (isExtendedError(NavigationDuplicated, err)) {
this.index = targetIndex
}
}
)
}

popstate介绍

当活动历史记录条目更改时,将触发popstate事件。如果被激活的历史记录条目是通过对history.pushState()的调用创建的,或者受到对history.replaceState()的调用的影响,popstate事件的state属性包含历史条目的状态对象的副本。

需要注意的是调用history.pushState()或history.replaceState()不会触发popstate事件。只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或者在Javascript代码中调用history.back())

不同的浏览器在加载页面时处理popstate事件的形式存在差异。页面加载时Chrome和Safari通常会触发(emit )popstate事件,但Firefox则不会

根据popstate介绍以及从vue-router源码,当我们手动改变url的hash的时候或者window.location.hash = ‘xxx’,history.go(-1), history.back(),history.forward() 的时候才会触发onpopstate或者onhashchange事件,进而运行回调transitionTo方法。而window.history.replaceState和window.history.pushState,仅会改变历史记录条目,无法触发onpopstate,所以目前hash和history模式下,$router的push,replace方法都是直接调用transitionTo方法来更新视图而后再用history.replaceState和history.pushState改变;历史记录和url

vue-router源码分析

install.js 分析

  1. 首先会对重复安装进行过滤
  2. 全局混入beforeCreate和destroyed 生命钩子,为每个Vue实例设置 _routerRoot属性,并为跟实例设置_router属性
  3. 调用Vue中定义的defineReactive对_route进行劫持,其实是执行的依赖收集的过程,执行_route的get就会对当前的组件进行依赖收集,如果对_route进行重新赋值触发setter就会使收集的组件重新渲染,这里也是路由重新渲染的核心所在
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) { // 设置根路由-根组件实例
this._routerRoot = this
this._router = this.$options.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)
}
})
  1. 为Vue原型对象定义$router和$route属性,并对两个属性进行了劫持,使我们可以直接通过Vue对象实例访问到
  2. 全局注册了Routerview和RouterLink两个组件,所以我们才可以在任何地方使用这两个组件,这两个组件的内容我们稍后分析
1
2
3
4
5
6
7
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})

Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})

RouterView

是无状态(没有 data ) 和无实例 (没有 this 上下文)的函数式组件。用一个简单的render函数返回虚拟节点使他们更容易渲染。

渲染的组件还可以内嵌,根据嵌套路径,渲染嵌套组件。

类似一个占位插槽,并不会渲染本身DOM模板,而是根据自身所在嵌套层级以及的render函数的第二个参数作为参数,匹配当前路由($route === $router.history.current)的嵌套层级matched,然后再用已匹配的matched的components中找到和RouterView name相同的组件。并渲染对应匹配到的组件,否则渲染空组件

相比是一个普通的非抽象组件,通过router的resolve方法解析自身的to属性参数,并调用$router.push或者replace方法进行路由跳转。

index.js 入口分析

  1. 声明Router类,以及原型方法,实例属性等
    例如一下常用的方法
1
push,replace,go,back,forward, addRoutes等

全局路由守卫

1
beforeEach,beforeResolve,afterEach

  1. 路由模式的判断,以及不同模式采用不同的策略
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
let mode = options.mode || 'hash'
// 通过 supportsPushState 判断浏览器是否支持'history'模式
// 如果设置的是'history'但是如果浏览器不支持的话,'history'模式会退回到'hash'模式
// fallback 是当浏览器不支持 history.pushState 控制路由是否应该回退到 hash 模式。默认值为 true。
this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
if (this.fallback) {
mode = 'hash'
}
// 不在浏览器内部的话,就会变成'abstract'模式
if (!inBrowser) {
mode = 'abstract'
}
this.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}`)
}
}

总体流分析

  1. 安装 vue-router 插件(参考 install.js分析)
  2. new Router 实例
  3. 根实例创建之前,执行init方法,初始化路由
  4. 执行transitionTo方法,同时hash模式下对浏览器hashChange事件进行了监听,执行history.listen方法,将对_route重新赋值的函数赋给History实例的callback,当路由改变时对_route进行重新赋值从而触发组件更新
  5. transitionTo方法根据传入的路径从我们定义的所有路由中匹配到对应路由,然后执行confirmTransition
  6. confirmTransition首先会有重复路由的判断,如果进入相同的路由,直接调用abort回调函数,函数退出,不会执行后面的各组件的钩子函数,这也是为什么我们重复进入相同路由不会触发组建的重新渲染也不会触发路由的各种钩子函数,
    如果判断不是相同路由,就会执行各组件的钩子函数(高阶函数太多,没看懂)。导航守卫执行顺序
1
2
3
4
5
6
7
8
9
10
11
12
1.导航被触发。
2.在失活的组件里调用离开守卫。
3.调用全局的 beforeEach 守卫。
4.在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
5.在路由配置里调用 beforeEnter。
6.解析异步路由组件。
7.在被激活的组件里调用 beforeRouteEnter。
8.调用全局的 beforeResolve 守卫 (2.5+)。
9.导航被确认。
10.调用全局的 afterEach 钩子。
11.触发 DOM 更新。
12.用创建好的实例调用 beforeRouteEnter 守卫中传给 next 的回调函数。
  1. 按顺序执行好导航守卫后,就会执行传入的成功的回调函数,从而对_route进行赋值,触发setter,从而使组件重新渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// confirmTransition 的成功回调
function () {
this.updateRoute(route)
onComplete && onComplete(route)
this.ensureURL()

// fire ready cbs once
if (!this.ready) {
this.ready = true
this.readyCbs.forEach(cb => {
cb(route)
})
}
}

// 更新 router
updateRoute (route: Route) {
const prev = this.current
this.current = route
this.cb && this.cb(route)
this.router.afterHooks.forEach(hook => { // 触发全局afterEach守卫
hook && hook(route, prev)
})
}
  1. this.$router.push, this.$router.replace, router-link,都会执行transitionTo,history.go()等,又会触发popstate或者hashchange而后在执行transitionTo
  2. 里面细节很多,就不一一说了

总体流程图
image

vue-router的优缺点

优点:

  1. 良好的交互体验,用户不需要刷新页面,页面显示流畅;
  2. 良好的前后端工作分离模式,减轻服务器压力,
  3. 完全的前端组件化,便于修改和调整

缺点:

  1. 首次加载大量资源,要在一个页面上为用户提供产品的所有功能,在这个页面加载的时候,首先要加载大量的静态资源,这个加载时间相对比较长;
  2. 不利于 SEO,单页页面,数据在前端渲染,就意味着没有SEO,或者需要使用变通的方案。

个人对vue-router 目前遇到的问题

confirmTransition 这个高阶函数一层套一层,绕不过来具体看源码’src/history/base.js’

异步组件的加载原理

本文首发于个人技术博客 https://liliuzhu.gitee.io/blog

坚持技术分享,您的支持将鼓励我继续努力!
0%