# 路由切换
# 三种路由模式
Vue-Router 为我们提供了三种路由模式:hash、history 和 abstract。其中 abstract 是用于非浏览器环境中的,很少被使用,我们不讨论此模式。
hash 路由的特点在于路径中存在 #,这种形式在前端又被成为锚点。值得注意的是,此模式下向服务器请求时,URL 中 # 后的内容不会被带到服务端,所以 hash 路由不支持服务端渲染。此外,此模式下的 URL 很丑(因为有 #,官方文档直言很丑 😂 ,不是我说的),所以一般情况下我们会优先使用 history 模式。
history 路由中不存在 #,且请求时完整的路径可以被带到后端,支持服务端渲染。下面我们来看看 Vue-Router 是如何区分这三种模式的:
let mode = options.mode || 'hash'
this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
if (this.fallback) {
mode = 'hash'
}
if (!inBrowser) {
mode = 'abstract'
}
this.mode = mode
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}`)
}
}
- 首先判断用户是否指定路由模式,若未指定,则默认使用 hash 模式
- 若为 history 模式,如果浏览器不支持 history,则降级为 hash 模式;若此时不处于浏览器环境中,则降级为 abstract 模式
- 最后分情况,根据不同的路由模式,创建不同的 history 对象。
# $route 和 $router
关于 history 对象,我们可以简单理解为,管理路由、挂载了路由信息的一个对象。不同模式下创建的路由对象不同,但差异不大,主要差异在于路由跳转时如何修改路径参数。
由于源码中此处代码较为冗长,本节讲解 history 对象采用的代码来自于 mini-vue-router3 中。
history 对象定义于 history 文件夹下,其中 base 文件夹中定义的 history 类是所有模式的 history 对象的父类,其中定义了一系列公共方法:
class history {
constructor(router) {
this.router = router
this.current = createRoute(null, '/')
}
transitionTo(location, callback) {
let record = this.router.match(location)
let route = createRoute(record, location)
let queue = this.router.beforeHooks
// 跳转的路径和匹配结果一致
if (location === this.current.path && route.matched.length === this.current.matched.length) {
// 拦截重复跳转
return
}
// 执行前置路由守卫
runQueue(this, queue, this.current, route, () => {
// 每次跳转,都要更新 current
this.current = route
if (callback) callback()
// 更新 _route 的值
this.cb && this.cb(route)
let afterQueue = this.router.afterHooks
// 执行后置路由守卫
runQueue(this, afterQueue, this.current, route, () => { })
})
}
listen(cb) {
this.cb = cb
}
}
其中比较重要的函数是 transitionTo,所有的跳转逻辑均需要经过此函数。我们后面会详细讲解。
值得一提的是 history 上挂载的两个属性:router 和 current,十分重要。其实他们就是我们常用的 $router 和 $route。因为我们使用 defineproperty 做了代理:
Object.defineProperty(Vue.prototype, '$router', {
get() {
// 代理一层,不通过 this._routerRoot._router 来访问路由,而是通过 this.$router 的便捷方式
return this._routerRoot && this._routerRoot._router
}
})
Object.defineProperty(Vue.prototype, '$route', {
get() {
// 代理一层,不通过 this._routerRoot._route 来访问路由,而是通过 this.$route 的便捷方式
return this._routerRoot && this._routerRoot._route
}
})
this._routerRoot._route 其实就是为 current 添加了响应式之后的结果:
// 将 current 对象定义为响应式,并挂载到根实例上,可以通过 this._routerRoot._route 拿到
Vue.util.defineReactive(this, '_route', this._router.history.current)
$router 和 $route 的区别:
- $router 是全局路由实例,主要挂载了路由相关的一些方法,比如路由跳转方法和路由守卫钩子等
- $route 叫做路由信息对象,包含当前路由的一些基本信息,比如name、meta、path、hash、query、params 等等。
# 路由的跳转
路由的跳转,主要是调用了 push 方法:
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
// $flow-disable-line
if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
return new Promise((resolve, reject) => {
this.history.push(location, resolve, reject)
})
} else {
this.history.push(location, onComplete, onAbort)
}
}
此函数接收1-3个参数,onComplete 为跳转完成的回调,onAbort 为跳转失败的回调。若没有传入 onComplete 和 onAbort, 且浏览器不支持 promise, 则直接调用 this.history.push(location, onComplete, onAbort),否则返回一个 Promise 对象,这个 Promise 在导航成功时被解析,失败时被拒绝。
histroy 的 push 方法在不同路由模式下的实现不同,下面我们以 hash 模式为例:
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
)
}
可以看到,主要是调用了 transitionTo 方法,并在回调中执行了 pushHash 方法。而 pushHash 的底层其实就是调用了 pushState 方法:
export function pushState (url?: string, replace?: boolean) {
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)
}
}
所以,归根结底,还是使用了浏览器的 replaceState 和 pushState 来做的。
- replaceState:在不添加新的历史记录的情况下替换当前页面的 URL
- pushState:会向浏览器的历史记录栈中添加一个新的记录,因此,当用户点击浏览器的后退按钮时,会回到之前的 URL。
# Vue Router 的响应式
Vue Router 中的响应式主要在于路由变化,需要自动渲染新路由对应的组件。
首先,如何监听到路由的变化?
对于这个问题 hash 和 history 模式的实现不同,在 Vue Router 3 以前,hash 模式监听路由变化采用的是监听 hashChange 的方式,而在 Vue3 以后,监听的是和 history 相同的 popState 事件。
值得注意的是:
我们调用 pushState 和 replaceState 是不会触发 popState 事件的!只有用户点击浏览器前进 / 后退按钮操作历史记录时,才会触发!所以在使用 popState 时,我们需要在 pushState 和 replaceState 后手动调用一次 transitionTo 方法。若是直接监听 hash 值的变化则不必如此。
// hash
/**
* 创建监听器,监听hash值的变化
*/
setupListeners() {
window.addEventListener('hashchange', () => {
this.transitionTo(getHash())
})
}
// history
/**
* 创建监听器,监听hash值的变化
*/
setupListeners() {
window.addEventListener('popstate', function () {
this.transitionTo(getPathName())
})
}
其次,路由变化时如何实现组件的切换?
这里就要用到响应式了,在前面我们也提到过,history 上的 current 属性被我们使用 Vue 提供的工具方法 defineReactive 定义为了响应式属性:
// 将 current 对象定义为响应式,并挂载到根实例上,可以通过 this._routerRoot._route 拿到
Vue.util.defineReactive(this, '_route', this._router.history.current)
这个属性保存了当前路由的相关信息,而每次页面切换时都需要走 history.transitionTo 方法,在此方法内,我们会对 this.current 进行更新:
transitionTo(location, callback) {
let record = this.router.match(location)
let route = createRoute(record, location)
let queue = this.router.beforeHooks
// 跳转的路径和匹配结果一致
if (location === this.current.path && route.matched.length === this.current.matched.length) {
// 拦截重复跳转
return
}
// 执行前置路由守卫
runQueue(this, queue, this.current, route, () => {
// 每次跳转,都要更新 current
this.current = route
if (callback) callback()
// 更新 _route 的值
this.cb && this.cb(route)
let afterQueue = this.router.afterHooks
// 执行后置路由守卫
runQueue(this, afterQueue, this.current, route, () => { })
})
}
因为 current 为响应式数据,所以 current 更新,会导致视图重新渲染。