vue-router源码:html5.js

vue-router源码解析系列。这是第八篇。本篇介绍源码中的html5.js,它其实比较简单,是vue-routermode:history模式下的History子类实现,Histor类是路由跳转的核心类,在上上篇博客中已有详细的解析。本系列解析的是官方git库中3.1.6的版本源码。

源码链接:html5.js。源码里面用的是typescript,但是不影响阅读。

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
// 声明HTML5History 继承自History
export class HTML5History extends History {
constructor (router: Router, base: ?string) {
// 调用父类
super(router, base)

// 与scroll相关的可以暂时不关注
// 以后的博客会专门来分析vue-router对滚动行为的处理
// expectScroll supportsScroll setupScroll handleScroll这些都是跟滚动行为有关的
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll

if (supportsScroll) {
// 初始化滚动行为的逻辑
setupScroll()
}

// 保存初始的访问地址 getLocation返回的是一个字符串,代表当前访问地址
const initLocation = getLocation(this.base)

// 监听popstate 目的有两个
// 1:处理路由跳转
// 2:处理滚动行为
window.addEventListener('popstate', e => {
const current = this.current

// 参照mdn对popstate事件的描述 部分浏览器在重新打开一个页面时可能会触发popstate事件
// 那这个popstate事件触发就会与history的初始路由跳转可能会发生不一致
// 尤其是初始路由如果有异步组件,那就会出现popstate事件比初始路由完成时机要更早触发
// 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 => {
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
})
}

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

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
// transtionTo完成了路由跳转之后会执行到这里
// 而且这里执行的时机是位于this.updateRoute之后的
// 调用pushState修改浏览器历史记录
pushState(cleanPath(this.base + route.fullPath))
// 处理滚动行为
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
}, onAbort)
}

// 这个跟push差不多
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)
}

ensureURL (push?: boolean) {
if (getLocation(this.base) !== this.current.fullPath) {
const current = cleanPath(this.base + this.current.fullPath)
push ? pushState(current) : replaceState(current)
}
}

getCurrentLocation (): string {
return getLocation(this.base)
}
}

export function getLocation (base: string): string {
let path = decodeURI(window.location.pathname)
if (base && path.indexOf(base) === 0) {
path = path.slice(base.length)
}
return (path || '/') + window.location.search + window.location.hash
}

其中pushStatereplaceState是在其它源码中定义的:

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
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 {
// stateKey也是跟滚动行为有关的,都会在后面的博客来解析
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)
}

从上面这段代码可以看到pushState本质上就是在利用history pushstateapi来处理浏览器的地址修改和浏览器历史记录的修改,只不过做了一些额外的处理,来应对safari bug和与滚动行为相关的功能。

ensureURL的作用

base.js中,一共有三处ensureURL的调用:
一处是在confirmTransition的成功回调中,位于updateRoute之后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
this.confirmTransition(
route,
() => {//成功
this.updateRoute(route)
onComplete && onComplete(route)
this.ensureURL() // 这个!

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

// 省略

一处是检测到相同路由跳转的时候:

1
2
3
4
5
6
7
8
if (
isSameRoute(route, current) &&
// in the case the route map has been dynamically appended to
route.matched.length === current.matched.length
) {
this.ensureURL()
return abort(new NavigationDuplicated(route))
}

第三处是在iterator函数中调用hook的时候:

1
2
3
4
5
6
hook(route, current, (to: any) => {
if (to === false || isError(to)) {
// next(false) -> abort navigation, ensure current URL
this.ensureURL(true)
abort(to)
}

为什么在这几个场景里面需要调用ensureURL呢,观看ensureURL的源码可以看到它是从window.location中提取地址与this.current.fullPath比较,不同的话,就以this.current.fullPath去覆盖window.location,有这个必要性吗?

先来看第三处为什么有这个必要,第三处的调用ensureURL传入了true,代表执行pushState的逻辑。什么场景下会触发这样的情况呢?假设你有一个beforeRouteLeave的钩子:

1
2
3
beforeRouteLeave(to, from, next) {
next(false)
}

这个钩子将会阻止浏览器离开当前页面。那么浏览器离开当前页面的方式有几种?其实就是两种,一是通过this.pushthis.replace这两个途径主动前往其他页面;二是通过浏览器的前进或后退(用户通过浏览器交互或者是window.history.go)。第一种离开方式,最终对浏览器历史记录的修改,是受代码控制的,并且是在路由完成之后才修改,所以beforeRouteLeave中的next(false)执行时,浏览器历史记录并未发生变化,this.updateRoute也没有执行,所以this.ensureURL(true)等于没做什么事情;但是第二种离开方式,就不一样了,是浏览器的行为先触发,也就是说历史记录是先被浏览器修改了,然后再借助popstate事件通知vue-router,那么当beforeRouteLeave触发时,浏览器历史记录已经被修改了,此时next(false)中断了路由,如果不做this.ensureURL(true)的处理,就会导致浏览器的历史记录,与vue-router中的路由页面不一致,而借助this.ensureURL(true)可以在这种情况,自动把浏览器回退的那条记录添加回来,就保证浏览器记录与vue-routerhook控制的一致性。

那么前两个ensureURL调用有什么作用呢?我目前理解的是为了保证浏览器访问地址与this.current.fullPath的一致性,因为在路由过程中,不排除有其它js代码他通过history.replaceState修改了浏览器地址,导致浏览器访问地址出现与this.current.fullPath不一致的情况,从正常角度来说,如果一个app内只有vue-router在使用history api,那前两处ensureURL的调用就是多余的,但是加了也没什么关系,因为它大部分情况下,都不会执行,有这个if逻辑在呢:

1
2
3
4
if (getLocation(this.base) !== this.current.fullPath) {
const current = cleanPath(this.base + this.current.fullPath)
push ? pushState(current) : replaceState(current)
}

ensureURL的作用就如ensure的含义一样,是为了保证浏览器访问地址与vue-router内的状态保持一致,所在vue-route在3处意味着路由终止的位置都有加入这个调用。

特殊的isSameRoute场景

有一个场景,一定会触发isSameRoute。先push3条,/detail/1, /detail/2, /detail/3,此时浏览器访问位于/detail/3,然后点击2次后退按钮,此时浏览器访问位于/detail/1,然后在这个页面内触发replace('/detail/2'),那么此时的浏览器历史记录就变为了:/detail/2, /detail/2, /detail/3,并且浏览器访问位于第1条/detail/2,此时在页面内如果触发push('/detail/2),就会触发same route