vue的路由,既熟悉又陌生,堪称是最熟悉的陌生人,我们在平时的开发中一直在用,但是对其原理却知道的非常少,那么他到底是如何实现切换更新视图,却又不去请求页面的呢?我们来揭晓答案。
我们知道在我们配置路由的时候,我们会写这样的一行代码:
const router = new Router({
mode: 'history,
routes: routes // routes没有引入
})
所以想要了解vue-router的原理一切都要从这个new Router()创建实例开始。很明显Router构造函数接收一个对象作为参数。routes不用多说,就是你的路由,是一个数组。mode是采用的模式。vue的路由有三种模式:其中在浏览器环境中是hash和history模式,在node环境中是abstract模式。
我们暂且不看abstract模式,只看hash和history模式。
mode的字段,如果是history,那么就是采用history模式,如果不写或者是hash就是hash模式,如果浏览器不支持history,那么就应该是hash模式。那么源码是如何处理这个mode呢?
import { install } from './install'
import { START } from './util/route'
import { assert } from './util/warn'
import { inBrowser } from './util/dom'
import { cleanPath } from './util/path'
import { createMatcher } from './create-matcher'
import { normalizeLocation } from './util/location'
import { supportsPushState } from './util/push-state'
import { handleScroll } from './util/scroll'
import { HashHistory } from './history/hash'
import { HTML5History } from './history/html5'
import { AbstractHistory } from './history/abstract'
import type { Matcher } from './create-matcher'
import { isNavigationFailure, NavigationFailureType } from './util/errors'
export default class VueRouter {
static install: () => void
static version: string
static isNavigationFailure: Function
static NavigationFailureType: any
app: any
apps: Array<any>
ready: boolean
readyCbs: Array<Function>
options: RouterOptions
mode: string
history: HashHistory | HTML5History | AbstractHistory
matcher: Matcher
fallback: boolean
beforeHooks: Array<?NavigationGuard>
resolveHooks: Array<?NavigationGuard>
afterHooks: Array<?AfterNavigationHook>
constructor (options: RouterOptions = {}) {
this.app = null
this.apps = []
this.options = options
this.beforeHooks = []
this.resolveHooks = []
this.afterHooks = []
this.matcher = createMatcher(options.routes || [], this)
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}`)
}
}
}
match (raw: RawLocation, current?: Route, redirectedFrom?: Location): Route {
return this.matcher.match(raw, current, redirectedFrom)
}
get currentRoute (): ?Route {
return this.history && this.history.current
}
init (app: any /* Vue component instance */) {
process.env.NODE_ENV !== 'production' &&
assert(
install.installed,
`not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
`before creating root instance.`
)
this.apps.push(app)
// set up app destroyed handler
// https://github.com/vuejs/vue-router/issues/2639
app.$once('hook:destroyed', () => {
// clean out app from this.apps array once destroyed
const index = this.apps.indexOf(app)
if (index > -1) this.apps.splice(index, 1)
// ensure we still have a main app or null if no apps
// we do not release the router so it can be reused
if (this.app === app) this.app = this.apps[0] || null
if (!this.app) {
// clean up event listeners
// https://github.com/vuejs/vue-router/issues/2341
this.history.teardownListeners()
}
})
// main app previously initialized
// return as we don't need to set up new history listener
if (this.app) {
return
}
this.app = app
const history = this.history
if (history instanceof HTML5History || history instanceof HashHistory) {
const handleInitialScroll = routeOrError => {
const from = history.current
const expectScroll = this.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll && 'fullPath' in routeOrError) {
handleScroll(this, routeOrError, from, false)
}
}
const setupListeners = routeOrError => {
history.setupListeners()
handleInitialScroll(routeOrError)
}
history.transitionTo(
history.getCurrentLocation(),
setupListeners,
setupListeners
)
}
history.listen(route => {
this.apps.forEach(app => {
app._route = route
})
})
}
beforeEach (fn: Function): Function {
return registerHook(this.beforeHooks, fn)
}
beforeResolve (fn: Function): Function {
return registerHook(this.resolveHooks, fn)
}
afterEach (fn: Function): Function {
return registerHook(this.afterHooks, fn)
}
onReady (cb: Function, errorCb?: Function) {
this.history.onReady(cb, errorCb)
}
onError (errorCb: Function) {
this.history.onError(errorCb)
}
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)
}
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
// $flow-disable-line
if (!onComplete && !onAbort && typeof Promise !== 'undefined') {
return new Promise((resolve, reject) => {
this.history.replace(location, resolve, reject)
})
} else {
this.history.replace(location, onComplete, onAbort)
}
}
go (n: number) {
this.history.go(n)
}
back () {
this.go(-1)
}
forward () {
this.go(1)
}
getMatchedComponents (to?: RawLocation | Route): Array<any> {
const route: any = to
? to.matched
? to
: this.resolve(to).route
: this.currentRoute
if (!route) {
return []
}
return [].concat.apply(
[],
route.matched.map(m => {
return Object.keys(m.components).map(key => {
return m.components[key]
})
})
)
}
resolve (
to: RawLocation,
current?: Route,
append?: boolean
): {
location: Location,
route: Route,
href: string,
// for backwards compat
normalizedTo: Location,
resolved: Route
} {
current = current || this.history.current
const location = normalizeLocation(to, current, append, this)
const route = this.match(location, current)
const fullPath = route.redirectedFrom || route.fullPath
const base = this.history.base
const href = createHref(base, fullPath, this.mode)
return {
location,
route,
href,
// for backwards compat
normalizedTo: location,
resolved: route
}
}
addRoutes (routes: Array<RouteConfig>) {
this.matcher.addRoutes(routes)
if (this.history.current !== START) {
this.history.transitionTo(this.history.getCurrentLocation())
}
}
}
function registerHook (list: Array<any>, fn: Function): Function {
list.push(fn)
return () => {
const i = list.indexOf(fn)
if (i > -1) list.splice(i, 1)
}
}
function createHref (base: string, fullPath: string, mode) {
var path = mode === 'hash' ? '#' + fullPath : fullPath
return base ? cleanPath(base + '/' + path) : path
}
VueRouter.install = install
VueRouter.version = '__VERSION__'
VueRouter.isNavigationFailure = isNavigationFailure
VueRouter.NavigationFailureType = NavigationFailureType
if (inBrowser && window.Vue) {
window.Vue.use(VueRouter)
}
可以看到,这是vueRouter的源码入口文件,实现了一个VueRouter的类,这个类实现了很多的方法,类似于go,back,resolve等等,其中构造函数中的switch case部分就是对mode字段的处理。看起来是不是很简单。所以我们就是要沿着这条线来往下看。看到了对mode的处理,那么hash对应的是HashHistory构造函数。history对应的是HTML5History构造函数。下面我们就看看这俩个类做了些什么?
首先就是vueRouter实现了一个history的类,用于对整个mode模式下的公用函数的封装。这里就不做过多的介绍,源码地址:https://github.com/vuejs/vue-router/blob/dev/src/history/base.js
HashHistory
HashHistory类首先继承了History类:
/* @flow */
import type Router from '../index'
import { History } from './base'
import { cleanPath } from '../util/path'
import { getLocation } from './html5'
import { setupScroll, handleScroll } from '../util/scroll'
import { pushState, replaceState, supportsPushState } from '../util/push-state'
export class HashHistory extends History {
constructor (router: Router, base: ?string, fallback: boolean) {
super(router, base)
// check history fallback deeplinking
if (fallback && checkFallback(this.base)) {
return
}
ensureSlash()
}
// this is delayed until the app mounts
// to avoid the hashchange listener being fired too early
setupListeners () {
if (this.listeners.length > 0) {
return
}
const router = this.router
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll) {
this.listeners.push(setupScroll())
}
const handleRoutingEvent = () => {
const current = this.current
if (!ensureSlash()) {
return
}
this.transitionTo(getHash(), route => {
if (supportsScroll) {
handleScroll(this.router, route, current, true)
}
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
}
const eventType = supportsPushState ? 'popstate' : 'hashchange'
window.addEventListener(
eventType,
handleRoutingEvent
)
this.listeners.push(() => {
window.removeEventListener(eventType, handleRoutingEvent)
})
}
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
)
}
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
)
}
go (n: number) {
window.history.go(n)
}
ensureURL (push?: boolean) {
const current = this.current.fullPath
if (getHash() !== current) {
push ? pushHash(current) : replaceHash(current)
}
}
getCurrentLocation () {
return getHash()
}
}
function checkFallback (base) {
const location = getLocation(base)
if (!/^\/#/.test(location)) {
window.location.replace(cleanPath(base + '/#' + location))
return true
}
}
function ensureSlash (): boolean {
const path = getHash()
if (path.charAt(0) === '/') {
return true
}
replaceHash('/' + path)
return false
}
export function getHash (): string {
// We can't use window.location.hash here because it's not
// consistent across browsers - Firefox will pre-decode it!
let href = window.location.href
const index = href.indexOf('#')
// empty path
if (index < 0) return ''
href = href.slice(index + 1)
// decode the hash but not the search or hash
// as search(query) is already decoded
// https://github.com/vuejs/vue-router/issues/2708
const searchIndex = href.indexOf('?')
if (searchIndex < 0) {
const hashIndex = href.indexOf('#')
if (hashIndex > -1) {
href = decodeURI(href.slice(0, hashIndex)) + href.slice(hashIndex)
} else href = decodeURI(href)
} else {
href = decodeURI(href.slice(0, searchIndex)) + href.slice(searchIndex)
}
return href
}
function getUrl (path) {
const href = window.location.href
const i = href.indexOf('#')
const base = i >= 0 ? href.slice(0, i) : href
return `${base}#${path}`
}
function pushHash (path) {
if (supportsPushState) {
pushState(getUrl(path))
} else {
window.location.hash = path
}
}
function replaceHash (path) {
if (supportsPushState) {
replaceState(getUrl(path))
} else {
window.location.replace(getUrl(path))
}
}
从源码中可以看到:HashHistory类实现了一系列的方法。
1 $router.push() //调用方法
2 HashHistory.push() //根据hash模式调用,设置hash并添加到浏览器历史记录(添加到栈顶)(window.location.hash= XXX)
3 History.transitionTo() //监测更新,更新则调用History.updateRoute()
4 History.updateRoute() //更新路由
5 {app._route= route} //替换当前app路由
6 vm.render() //更新视图
HashHistory.push() **将新路由添加到浏览器访问历史的栈顶,然后开始改变路由:
HashHistory.replace()方法与push()方法不同之处在于,它并不是将新路由添加到浏览器访问历史的栈顶,而是替换掉当前的路由.
HTML5History
History interface是浏览器历史记录栈提供的接口,通过back(), forward(), go()等方法,我们可以读取浏览器历史记录栈的信息,进行各种跳转操作。
从HTML5开始,History interface有进一步修炼:pushState(), replaceState() 这下不仅是读取了,还可以对浏览器历史记录栈进行修改:
window.history.pushState(stateObject, title, URL)window.history.replaceState(stateObject, title, URL)
/* @flow */
import type Router from '../index'
import { History } from './base'
import { cleanPath } from '../util/path'
import { START } from '../util/route'
import { setupScroll, handleScroll } from '../util/scroll'
import { pushState, replaceState, supportsPushState } from '../util/push-state'
export class HTML5History extends History {
_startLocation: string
constructor (router: Router, base: ?string) {
super(router, base)
this._startLocation = getLocation(this.base)
}
setupListeners () {
if (this.listeners.length > 0) {
return
}
const router = this.router
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll) {
this.listeners.push(setupScroll())
}
const handleRoutingEvent = () => {
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 === this._startLocation) {
return
}
this.transitionTo(location, route => {
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
}
window.addEventListener('popstate', handleRoutingEvent)
this.listeners.push(() => {
window.removeEventListener('popstate', handleRoutingEvent)
})
}
go (n: number) {
window.history.go(n)
}
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)
}
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.toLowerCase().indexOf(base.toLowerCase()) === 0) {
path = path.slice(base.length)
}
return (path || '/') + window.location.search + window.location.hash
}
可以看到,其实HTML5History模式和hashHistory很像,但是HTML5History改变路由的方式,是通过调用html5的pushState,popState等方法,对历史栈进行修改的。这是二者的本质区别。
读源码,知原理。。。