我们先看一下vueRouter
这一个类的定义
export default class VueRouter {
mode: string; // 传入的字符串参数,指示history类别
history: HashHistory | HTML5History | AbstractHistory; // 实际起作用的对象属性,必须是以上三个类的枚举
fallback: boolean; // 如浏览器不支持,'history'模式需回滚为'hash'模式
constructor (options: RouterOptions = {}) {
let mode = options.mode || 'hash' // 默认为'hash'模式
this.fallback = mode === 'history' && !supportsPushState // 通过supportsPushState判断浏览器是否支持'history'模式
if (this.fallback) {
mode = 'hash'
}
if (!inBrowser) {
mode = 'abstract' // 不在浏览器环境下运行需强制为'abstract'模式
}
this.mode = 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}`)
}
}
}
// vueRouter的初始化
init (app: any /* Vue component instance */) {
const history = this.history
// 根据history的类别执行相应的初始化操作和监听
if (history instanceof HTML5History) {
history.transitionTo(history.getCurrentLocation()) // history模式下对各种地址变化($router.push()、用户点击元素或直接修改地址栏引起地址栏变化)的处理
} else if (history instanceof HashHistory) {
const setupHashListener = () => { // hash模式下非$router.push()方法对地址栏变化的监听处理
history.setupListeners()
}
history.transitionTo(
history.getCurrentLocation(),
setupHashListener,
setupHashListener
)
}
history.listen(route => {
this.apps.forEach((app) => {
app._route = route // 这里的app是vue实例,_route被vue实例劫持监听了,因此可以通过改变_route的值,进而通知有关wathcer进行组件的更新渲染
})
})
}
// VueRouter类暴露的以下方法实际是调用具体history对象的方法
// 就是$router.push()和$router.replace()两种编程式修改路由方法
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.push(location, onComplete, onAbort)
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.history.replace(location, onComplete, onAbort)
}
}
可以看出:
作为参数传入的字符串属性mode
只是一个标记,用来指示实际起作用的对象属性history
的实现类,两者对应关系如下:
mode | history |
---|---|
history | HTML5History |
hash | HashHistory |
abstract | AbstractHistory |
在初始化对应的history
之前,会对mode
做一些校验:若浏览器不支持HTML5History
方式(通过supportsPushState
变量判断),则mode
强制设为hash
;若不是在浏览器环境下运行,则mode
强制设为abstract
VueRouter
类中的replace()
, push()
等方法只是一个代理,实际是调用的具体history
对象的对应方法,在init()
方法中初始化时,也是根据history
对象具体的类别执行不同操作
在浏览器环境下的两种方式,分别就是在HTML5History
,HashHistory
两个类中实现的。他们都定义在src/history
文件夹下,继承自同目录下base.js
文件中定义的History
类。History
中定义的是公用和基础的方法,直接看会一头雾水,我们先从HTML5History
,HashHistory
两个类中看着亲切的push(), replace()
方法的说起
hash('#')
符号的本来作用是加在URL
中指示网页中的位置:
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
获取的值不同了)都会在浏览器的访问历史中增加一个记录
hashchange
只有在当前同一页面仅仅修改hash
值时才会触发,跳转到其他页面等情况不会触发hashchange
事件
利用hash
的以上特点,就可以来实现前端路由“更新视图但不重新请求页面”的功能了
有两种情况,一是通过$router.push()/replace()
,二是用户通过<router-link>
标签点击或者直接修改地址栏等其他情况
我们来看HashHistory
中的push()
方法:(不要和$router.push()
弄混了,$router.push()
其实就是调用了HashHistory.push()
)
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(location, route => {
pushHash(route.fullPath)
onComplete && onComplete(route)
}, onAbort)
}
function pushHash (path) {
window.location.hash = path
}
transitionTo()
方法是父类中定义的是用来处理路由变化中的基础逻辑的,可以看到,push()
方法最主要的是对window
的hash
进行了直接赋值:
window.location.hash = route.fullPath
此时hash
的改变会自动添加到浏览器的访问历史记录中。
那么视图的更新是怎么实现的呢,我们来看父类History
中transitionTo()
方法的这么一段:
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const route = this.router.match(location, this.current)
this.confirmTransition(route, () => { // 确认路由变化后,调用回调函数,执行回调函数中的updateRoute
this.updateRoute(route)
...
})
}
updateRoute (route: Route) { // 调用cb改变vue实例中的_route属性,进而触发相应watcher更新组件渲染
this.cb && this.cb(route)
}
listen (cb: Function) {
this.cb = cb
}
可以看到,当路由变化时,调用了History
中的this.cb
方法,而this.cb
方法是通过History.listen(cb)
进行设置的。回到VueRouter
类定义中,找到了在init()
方法中对其进行了设置:
init (app: any /* Vue component instance */) {
this.apps.push(app)
history.listen(route => {
this.apps.forEach((app) => {
app._route = route //改变vue实例中的_route属性,进而触发相应watcher更新组件渲染
})
})
}
根据注释,app
为Vue
组件实例,但我们知道Vue
作为渐进式的前端框架,本身的组件定义中应该是没有有关路由内置属性_route
,如果组件中要有这个属性,应该是在插件加载的地方,即VueRouter
的install()
方法中混合入Vue
对象的,查看install.js
源码,有如下一段:
export function install (Vue) {
Vue.mixin({
beforeCreate () {
if (isDef(this.$options.router)) {
this._router = this.$options.router
this._router.init(this)
Vue.util.defineReactive(this, '_route', this._router.history.current)
}
registerInstance(this, this)
},
})
}
通过Vue.mixin()
方法,全局注册一个混合,影响注册之后所有创建的每个Vue
实例,该混合在beforeCreate
钩子中通过Vue.util.defineReactive()
定义了响应式的_route
属性。所谓响应式属性,即当_route
值改变时,会自动调用有关函数,这里的函数是Vue
实例的render()
方法,进而更新视图
总结一下,从设置路由改变到视图更新的流程如下:
new VueRouter
,根据mode
创建history
对象,赋给VueRouter
的history
属性vue
实例初始化VueRouter
实例,进而调用VueRouter
的init()
方法,完成history
类的初始化、初始页面地址的存储、监听非$router.push()/replace()
的情况(后面会讲到)、vue
实例对_route
属性的绑定监听$router.push()/replace()
仅仅改变hash
值时(关于这里文末会补充),会调用HashHistory
的push
或replace
方法,接着调用history
类的transitionTo()
,接着是transitionTo()
中确定route
变更后的回调函数updateRoute(route)
,updateRoute(route)
即执行回调函数更改vue
实例_route
属性值,进而触发相关watcher
执行进行视图更新,最后执行HashHistory
的push
或replace
方法中的history
类的transitionTo()
的回调函数,主要是执行pushHash(route.fullPath)
进而修改地址栏的hash
值($route.push()/replace()
修改地址栏就是在这一步修改的)replace()
方法与push()
方法不同之处在于,它并不是将新路由添加到浏览器访问历史的栈顶,而是替换掉当前的路由:
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(location, route => {
replaceHash(route.fullPath)
onComplete && onComplete(route)
}, onAbort)
}
function replaceHash (path) {
const i = window.location.href.indexOf('#')
window.location.replace(
window.location.href.slice(0, i >= 0 ? i : 0) + '#' + path
)
}
可以看出,它与push()
的实现结构上基本相似,不同点在于它不是直接对window.location.hash
进行赋值,而是调用window.location.replace
方法将路由进行替换
前面有说到非$router.push()/replace()
的情况,其实就是通过<a>
或用户直接修改地址栏等情况,注意<router-link>
这种情况,看一下官方的解释:
当你点击
<router-link>
时,这个方法会在内部调用,所以说,点击<router-link :to="...">
等同于调用router.push(...)
在HashHistory
中这一功能通过setupListeners
实现:
setupListeners () {
window.addEventListener('hashchange', () => {
if (!ensureSlash()) {
return
}
this.transitionTo(getHash(), route => {
replaceHash(route.fullPath)
})
})
}
该方法设置监听了浏览器事件hashchange
,调用的函数为replaceHash
,即在浏览器地址栏中直接输入路由等相当于代码调用了replace()
方法
这一步是在VueRouter.init()
中实现的
这两个方法有个共同的特点:当调用他们修改浏览器历史记录栈后,虽然当前URL
改变了,但浏览器不会向该URL
发送请求(the browser won’t attempt to load this URL after a call to pushState()),这就为单页应用前端路由“更新视图但不重新请求页面”提供了基础
特征:
pushState/replaceState
不同源页面会报错(无论目的网址接不接受跨域访问,只要不同源一律报错,不会跳转过去目的网址,地址和页面均不变)特征:
pushState()和replaceState()
不会触发hash
值之间的历史记录之间跳转可以触发),当转到不同页面时就直接跳转去目的页面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)
}
// src/util/push-state.js
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) {
history.replaceState({ key: _key }, '', url)
} else {
_key = genKey()
history.pushState({ key: _key }, '', url)
}
} catch (e) {
window.location[replace ? 'replace' : 'assign'](url)
}
}
export function replaceState (url?: string) {
pushState(url, true)
}
代码结构以及更新视图的逻辑与hash
模式基本类似,只不过将对window.location.hash
直接进行赋值和window.location.replace()
改为了调用history.pushState()
和history.replaceState()
方法
在HTML5History
中添加对修改浏览器地址栏URL
的监听是直接在构造函数中执行的:
constructor (router: Router, base: ?string) {
window.addEventListener('popstate', e => { // 注意是利用popstate而不是hashchange
const current = this.current
this.transitionTo(getLocation(this.base), route => {
if (expectScroll) {
handleScroll(router, route, current, true)
}
})
})
}
当然了HTML5History
用到了HTML5
的新特性,是需要特定浏览器版本的支持的,前文已经知道,浏览器是否支持是通过变量supportsPushState
来检查的:
// src/util/push-state.js
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
})()
除此之外vue-router
还为非浏览器环境准备了一个abstract
模式,其原理为用一个数组stack
模拟出浏览器历史记录栈的功能。当然,以上只是一些核心逻辑,为保证系统的鲁棒性源码中还有大量的辅助逻辑,也很值得学习
在一般的需求场景中,hash
模式与history
模式是差不多的,但几乎所有的文章都推荐使用history模式,理由竟然是:"#"
符号太丑…0_0 "
如果不想要很丑的 hash
,我们可以用路由的 history
模式 ——官方文档
当然,严谨的我们肯定不应该用颜值评价技术的好坏。根据MDN
的介绍,调用history.pushState()
相比于直接修改hash
主要有以下优势:
pushState
设置的新URL
可以是与当前URL
同源的任意URL
;而hash
只可修改#
后面的部分,故只可设置与当前同文档的URL
pushState
设置的新URL
可以与当前URL
一模一样,这样也会把记录添加到栈中;而hash
设置的新值必须与原来不一样才会触发记录添加到栈中pushState
通过stateObject
可以添加任意类型的数据到记录中;而hash
只可添加短字符串pushState
可额外设置title属性供后续使用我们知道对于单页应用来讲,理想的使用场景是仅在进入应用时加载index.html
,后续在的网络操作通过Ajax
完成,不会根据URL
重新请求页面,但是难免遇到特殊情况,比如用户直接在地址栏中输入并回车,浏览器重启重新加载应用等。
hash
模式仅改变hash
部分的内容,而hash
部分是不会包含在HTTP
请求中的:
http://oursite.com/#/user/id` // 如重新请求只会发送http://oursite.com/
故在hash模式下遇到根据URL请求页面的情况不会有问题。
而history
模式则会将URL
修改得就和正常请求后端的URL
一样http://oursite.com/user/id
在此情况下重新向后端发送请求,如后端没有配置对应/user/id
的路由处理,则会返回404
错误。官方推荐的解决办法是在服务端增加一个覆盖所有情况的候选资源:如果 URL
匹配不到任何静态资源,则应该返回同一个index.html
页面,这个页面就是你 app
依赖的页面。同时这么做以后,服务器就不再返回404
错误页面,因为对于所有路径都会返回index.html
文件。为了避免这种情况,在Vue
应用里面覆盖所有的路由情况(当然是仅限于同一页面),然后再给出一个 404
页面。或者,如果是用 Node.js
作后台,可以使用服务端的路由来匹配URL
,当没有匹配到路由的时候返回404
,从而实现 fallback
route.fullPath
,会把路径route.fullPath
拼在当前域名后面(可以去看看前文是怎么实现的),因此还是会正常通过对应模式去寻找路由hash
值,则会触发hashchange
或popState
,根据路由情况处理