vue-router源码分析(HashHistory与HTML5History)

前言

”更新视图但不重新请求页面“ 是前端路由原理的核心之一,目前在浏览器环境中这一功能的实现主要有两种方式

  • 利用 URL 中的 hash(#)
  • 利用 History interface 在 HTML5 中新增的方法

下面看看在 Vue-router 中是如何通过这两种方式实现前端路由

匹配模式

创建 VueRouter 的实例对象时,mode 以构造函数参数的形式传入

实例化 VueRouter

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
export default class VueRouter {
constructor(options: RouterOptions = {}) {
// ...
// 根据 mode 采取不同的路由方式
let mode = options.mode || "hash";
// 通过supportsPushState判断浏览器是否支持 HTML5 特性
this.fallback =
mode === "history" && !supportsPushState && options.fallback !== false;

// 如果浏览器不支持,'history'模式需回滚为’hash‘模式
if (this.fallback) {
mode = "hash";
}
// 如果不在浏览器环境下运行,强制为'abstract'模式
if (!inBrowser) {
mode = "abstract";
}
this.mode = mode;

/** 根据mode确定history实际的类并实例化,之后的push replace等调的是具体history对象的方法
* @Router 实例
* @base 应用的基路径
* @fallback History 模式,但不支持 History 而被转成 Hash 模式
*/
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
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
init(app: any /* Vue component instance */) {
const history = this.history;

// 判断路由模式,并根据不同路由模式进行跳转。hashHistory需要监听hashchange、popshate两个事件,而html5History监听popstate事件
if (history instanceof HTML5History) {
history.transitionTo(history.getCurrentLocation());
} else if (history instanceof HashHistory) {
// 添加 hashchange 监听
const setupHashListener = () => {
history.setupListeners();
};
// 路由跳转
history.transitionTo(
history.getCurrentLocation(),
setupHashListener,
setupHashListener
);
}

// 该回调会在 transitionTo 中调用
// 对组件的 _route 属性进行赋值,触发组件渲染;且将apps中的组件的_route全部更新至最新的
history.listen(route => {
this.apps.forEach(app => {
app._route = route;
});
});
}

HashHistory

http://www.example.com/index.html#print

# 符号本身以及它后面的字符称为 hash,可通过 window.location.hash 属性读取

特点:

  • hash 虽然出现在 URL 中,但不会被包括在 HTTP 请求中,它是用来指导浏览器动作的,对服务端完全无用。因此,改变 hash 不会重新加载页面
  • 可以为 hash 的改变添加监听事件
    • window.addEventListener('hashchange', funcRef, false)
  • 每一次改变 hash(window.location.hash),都会在浏览器的访问历史中增加一个记录

push()

跳转的方式会判断是否需要滚动,需要,将会使用 pushState 改变路由;否则对 window 的 hash 进行直接赋值

hash 的改变会自动添加到浏览器的访问历史记录中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(
location,
route => {
pushHash(route.fullPath)
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
},
onAbort
)
}
function pushHash (path) {
if (supportsPushState) {
pushState(getUrl(path))
} else {
window.location.hash = path
}
}

实现视图更新

先上结论,从设置路由改变到视图更新的流程

$router.push() --> HashHistory.push() --> History.transitionTo() -->History.updateRoute() --> {app._route = route} --> vm.render()

我们现在看父类 History 中 transitionTo() 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
// 根据路径进行路由匹配;route :当前匹配结果
const route = this.router.match(location, this.current)
this.confirmTransition(route, () => {
this.updateRoute(route)
// ...
})
}

updateRoute (route: Route) {
this.cb && this.cb(route)
}

listen (cb: Function) {
this.cb = cb
}

当路由变化时,调用了 this.cb 方法, this.cb 方法是通过 History.listen(cb) 进行设置的。让我们看看 cb 在哪里传入

1
2
3
4
5
6
7
8
9
10
init (app: any /* Vue component instance */) {

this.apps.push(app)

history.listen(route => {
this.apps.forEach((app) => {
app._route = route
})
})
}

可以看到,在路由初始化的函数中,会传入修改app._route = route的 cb ,有什么作用呢??在 install.js 里面会全局注册一个混合,给所有 Vue实例的 beforeCreate 钩子定义了响应式的 _route属性,所以,当 _route 改变时,会自动调用 Vue 实例的 render() 方法,更新视图

replace()

如果浏览器支持 HTML5 特性,将会使用 replaceState 改变路由;否则调用 window.location.replace 方法将路由进行替换

不会把新路由添加到浏览器访问历史的栈顶,而是替换掉当前的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(
location,
route => {
replaceHash(route.fullPath)
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
},
onAbort
)
}
function replaceHash (path) {
if (supportsPushState) {
replaceState(getUrl(path))
} else {
window.location.replace(getUrl(path))
}
}

setupListeners() 监听地址栏

用户除了使用push与replace改变路由,而可以直接在浏览器地址栏中输入改变路由,所以 VueRouter 还需要能监听浏览器地址栏中路由的变化,并具有与通过代码调用相同的响应行为

在 HashHistory 中通过setupListeners方法实现

跳转时会判断是否需要滚动行为,如果需要,则监听浏览器 popstate 事件,否则监听浏览器 hashchange 事件,调用 replaceHash 函数,即在浏览器地址栏中直接输入路由相当于代码调用了 replace() 方法

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
setupListeners () {
const router = this.router
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll

if (supportsScroll) {
setupScroll()
}

// 当 hash 路由发生的变化,即页面发生了跳转时,首先取保路由是以斜杠开头的,然后触发守卫导航,最后更换新的 hash 路由
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)
}
})
}
)
}

HTML5History

History interface 是浏览器历史记录栈提供的接口,通过 back() ,forward() ,go() 等方法,我们可以读取浏览器历史记录栈的信息,进行各种跳转操作

从 HTML5 开始,History interface 提供了两个新的方法:pushState() ,replaceState() 使得我们可以对浏览器历史记录栈进行修改

1
2
3
4
5
6
7
/**
* stateObject: 当浏览器跳转到新的状态时,将触发 popState 事件,该事件将携带这个 stateObject 参数的副本
* title: 所添加记录的标题
* URL: 所添加记录的 URL
*/
window.history.pushState(stateObject, title, URL)
window.history.replaceState(stateObject, title, URL)

这两个方法有个共同的特点:当调用它们修改浏览器历史记录栈时,虽然当前 URL 改变了,但浏览器不会立刻发送请求该 URL,这就为单页应用前端路由“更新视图但不重新请求页面”提供了基础

push() 与 replace()

VueRouter 中的实现:会发现代码结构以及更新视图的逻辑与 hash 模式基本类似,区别是:将 window.location.hash window.location.replace() 改为调用 window.history.pushState() window.history.replaceState()

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
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 (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)
}

export function pushState (url?: string, replace?: boolean) {
// 保存当前页面的滚动位置
saveScrollPosition()
// try...catch the pushState call to get around Safari
// DOM Exception 18 where it limits to 100 pushState calls
const history = window.history
try {
// 判断是哪种操作动作
if (replace) {
// preserve existing history state as it could be overriden by the user
const stateCopy = extend({}, history.state)
stateCopy.key = getStateKey()
history.replaceState(stateCopy, '', url)
} else {
history.pushState({ key: setStateKey(genStateKey()) }, '', url)
}
} catch (e) {
window.location[replace ? 'replace' : 'assign'](url)
}
}
export function replaceState (url?: string) {
pushState(url, true)
}

监听地址栏

在 HTML5History 中添加对修改浏览器地址栏 URL 的监听是直接在构造函数中执行的,注意只监听 popstate 事件

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
export class HTML5History extends History {
constructor (router: Router, base: ?string) {
super(router, base)

const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll

if (supportsScroll) {
setupScroll()
}
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.
const location = getLocation(this.base)
if (this.current === START && location === initLocation) {
return
}

this.transitionTo(location, route => {
// 判断配置是否有 scrollBehavior 与 支持HTML5新特性,有就调用 handleScroll 方法进行处理
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
})
}

handleScroll 处理滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 实现滚动的两个小办法

// 1. 在main.js入口文件中写入
router.afterEach(() => {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}

// 2. 在实例router对象的时候设置
export default new Router({
mode: 'hash',
// base: '/dist/',
crollBehavior: () => ({ y: 0 }), //路由跳转后页面回到顶部
routes: []
})

滚动行为:使用前端路由,当切换到新路由时,想要页面滚到顶部,或者是保持在原先的滚动位置,就像重新加载页面那样。VueRouter 可以自定义路由切换时页面如何滚动

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
export function handleScroll(
router: Router,
to: Route,
from: Route,
isPop: boolean
) {
<!-- 等待页面渲染完才进行滚动的操作 -->
router.app.$nextTick(() => {
<!-- 初始化数据 -->
const position = getScrollPosition()
const shouldScroll = behavior.call(router, to, from, isPop ? position : null)

if (!shouldScroll) {
return
}
<!-- 判断是否是Promise,官网说支持异步 -->
if (typeof shouldScroll.then === 'function') {
shouldScroll.then(shouldScroll => {
scrollToPosition((shouldScroll: any), position)
}).catch(err => {
if (process.env.NODE_ENV !== 'production') {
assert(false, err.toString())
}
})
} else {
scrollToPosition(shouldScroll, position)
}
})
})

/**
* getElementPosition:获取元素坐标
* isValidPosition:验证坐标是否有效
* normalizePosition:格式化坐标
*/
function scrollToPosition (shouldScroll, position) {
const isObject = typeof shouldScroll === 'object'
// 对position进行初始化的操作
if (isObject && typeof shouldScroll.selector === 'string') {
const el = document.querySelector(shouldScroll.selector)
if (el) {
let offset = shouldScroll.offset && typeof shouldScroll.offset === 'object' ? shouldScroll.offset : {}
offset = normalizeOffset(offset)
position = getElementPosition(el, offset)
} else if (isValidPosition(shouldScroll)) {
position = normalizePosition(shouldScroll)
}
} else if (isObject && isValidPosition(shouldScroll)) {
position = normalizePosition(shouldScroll)
}
// 使用window.scrollTo来进行滚动处理
if (position) {
window.scrollTo(position.x, position.y)
}
}

supportsPushState 检查浏览器是否支持 HTML5 特性

HTML5History 用到了 HTML5 的特性,需要特定浏览器版本的支持,浏览器是否支持是通过变量 supportsPushState 来检查的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const supportsPushState =
inBrowser &&
(function () {
const ua = window.navigator.userAgent

if (
(ua.indexOf('Android 2.') !== -1 || ua.indexOf('Android 4.0') !== -1) &&
ua.indexOf('Mobile Safari') !== -1 &&
ua.indexOf('Chrome') === -1 &&
ua.indexOf('Windows Phone') === -1
) {
return false
}

return window.history && 'pushState' in window.history
})()

总结

hash模式与 history模式都是通过浏览器接口实现,除此之外,VueRouter 还为非浏览器环境准备了一个 abstract模式,原理:用一个数组 stack 模拟浏览器历史记录栈的功能

两种模式的比较

history 模式的优点

如果不想要很丑的 hash,我们可以用路由的 history 模式 ——官方文档

MDN 的介绍中,是这样描述的:

在某种意义上,调用 pushState() 与 设置 window.location = "#foo" 类似,二者都会在当前页面创建并激活新的历史记录。但 pushState() 具有如下几条优点:

  • pushState() 设置的新 URL 可以是与当前 URL 同源的任意 URL;而 hash 只可修改 # 后面的部分,故只可设置与当前同文档的 URL
  • pushState() 设置的新 URL 可以与当前 URL 一模一样,这样也会把记录添加到栈中;而 hash 设置的新值必须与原来不一样才会触发记录添加到栈钟
  • pushState() 通过 stateObject 可以添加任意类型的数据到记录中;而 hash 只可添加短字符串
  • pushState() 可额外设置 title 属性供后续使用

注意 pushState() 绝对不会触发 hashchange 事件,即使新的URL与旧的URL仅哈希不同也是如此。

history 模式的一个问题

对于单页面应用来说,理想的使用场景是仅在进入应用时加载 index.html,后续的网络操作通过 Ajax 完成,不会根据 URL 重新请求页面。但是如果遇到特殊情况:用户直接在地址栏中输入并按回车,浏览器重启重新加载应用等

hash 模式仅改变 hash 部分的内容,而hash 部分不会包含在 HTTP 请求中。所以在 hash 模式下遇到特殊情况不会有问题

1
http://oursite.com/#/user/id   // 如重新请求只会发送http://oursite.com/

而history 模式会将整个 URL 重新发送请求,如果后端没有配置对应 /user/id 的路由处理,则会返回 404 错误

1
http://oursite.com/user/id

官方推荐的解决办法是:在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。

同时,我们要给一个警告,因为这么做以后,你的服务器就不再返回 404 错误页面,因为对于所有路径都会返回 index.html 文件。为了避免这种情况,你应该在 Vue 应用里面覆盖所有的路由情况,然后在给出一个 404 页面

1
2
3
4
5
6
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '*', component: NotFoundComponent }
]
})

或者,如果你使用 Node.js 服务器,你可以用服务端路由匹配到来的 URL,并在没有匹配到路由的时候返回 404,以实现回退。更多详情可查阅官网