vue-router源码解析——工作流程
上文中分析了vue-router的初始化流程,这篇文章就以其工作方式为线索分析相关源码的运作。
router-link
最常用的工作方式就是借助该组件绑定点击事件为用户提供路由导航了。先看该组件的源码
export default {
name: 'RouterLink',
props: {
to: {
type: toTypes,
required: true
},
tag: {
type: String,
default: 'a'
},
exact: Boolean,
append: Boolean,
replace: Boolean,
activeClass: String,
exactActiveClass: String,
event: {
type: eventTypes,
default: 'click'
}
},
render (h: Function) {
const router = this.$router
const current = this.$route
const { location, route, href } = router.resolve(this.to, current, this.append)
const classes = {}
const globalActiveClass = router.options.linkActiveClass
const globalExactActiveClass = router.options.linkExactActiveClass
// Support global empty active class
const activeClassFallback = globalActiveClass == null
? 'router-link-active'
: globalActiveClass
const exactActiveClassFallback = globalExactActiveClass == null
? 'router-link-exact-active'
: globalExactActiveClass
const activeClass = this.activeClass == null
? activeClassFallback
: this.activeClass
const exactActiveClass = this.exactActiveClass == null
? exactActiveClassFallback
: this.exactActiveClass
const compareTarget = location.path
? createRoute(null, location, null, router)
: route
classes[exactActiveClass] = isSameRoute(current, compareTarget)
classes[activeClass] = this.exact
? classes[exactActiveClass]
: isIncludedRoute(current, compareTarget)
const handler = e => {
if (guardEvent(e)) {
if (this.replace) {
router.replace(location)
} else {
router.push(location)
}
}
}
const on = { click: guardEvent }
if (Array.isArray(this.event)) {
this.event.forEach(e => { on[e] = handler })
} else {
on[this.event] = handler
}
const data: any = {
class: classes
}
if (this.tag === 'a') {
data.on = on
data.attrs = { href }
} else {
// find the first <a> child and apply listener and href
const a = findAnchor(this.$slots.default)
if (a) {
// in case the <a> is a static node
a.isStatic = false
const aData = a.data = extend({}, a.data)
aData.on = on
const aAttrs = a.data.attrs = extend({}, a.data.attrs)
aAttrs.href = href
} else {
// doesn't have <a> child, apply listener to self
data.on = on
}
}
return h(this.tag, data, this.$slots.default)
}
}
function guardEvent (e) {
// don't redirect with control keys
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
// don't redirect when preventDefault called
if (e.defaultPrevented) return
// don't redirect on right click
if (e.button !== undefined && e.button !== 0) return
// don't redirect if `target="_blank"`
if (e.currentTarget && e.currentTarget.getAttribute) {
const target = e.currentTarget.getAttribute('target')
if (/\b_blank\b/i.test(target)) return
}
// this may be a Weex event which doesn't have this method
if (e.preventDefault) {
e.preventDefault()
}
return true
}
function findAnchor (children) {
if (children) {
let child
for (let i = 0; i < children.length; i++) {
child = children[i]
if (child.tag === 'a') {
return child
}
if (child.children && (child = findAnchor(child.children))) {
return child
}
}
}
}
先看渲染函数,首先获取到了vue-router初始化时挂载到原型的router实例和当前路由匹配对象,并从路由匹配对象中获取当前路由信息。
接着是一系列的当前路由激活class的判断,这些class将作用于最终渲染的组件中。
再声明了传入event的监听和处理函数,其中通过guardEvent回调处理特殊的跳转以及默认行为处理,然后再通过router实例的replace或push方法完成编程式导航。
最后处理router-view的渲染,根据传入tag参数决定渲染dom节点类型,根据slot模板决定如何绑定事件,如果存在a标签则绑定到第一个a标签上,否则就绑定到当前元素。
至此就完成了router-link的挂载,可以看到该组件并不渲染真实dom, 作为无状态组件,只是对slot增强功能,通过绑定点击事件触发router实例的push或replace方法完成编程式导航。
history
无论是push或者replace,都是histroy实例的方法。上文中只是做了大致介绍,这篇文章就深入到history的具体细节。
hash
首先看实例化的构造器部分
constructor (router: Router, base: ?string, fallback: boolean) {
super(router, base)
// check history fallback deeplinking
if (fallback && checkFallback(this.base)) {
return
}
ensureSlash()
}
function ensureSlash (): boolean {
const path = getHash()
if (path.charAt(0) === '/') {
return true
}
replaceHash('/' + path)
return false
}
function checkFallback (base) {
const location = getLocation(base)
if (!/^\/#/.test(location)) {
window.location.replace(
cleanPath(base + '/#' + location)
)
return true
}
}
这里主要做的就是首先调用基类构造器,继承所有history通用的部分,也就是前文讲的过渡逻辑以及相关钩子的处理。然后通过checkFallback
函数检查当前hash模式是否通过HTML5模式降级而来,如果是降级而来则需要对location做处理保证hash模式下路径以/#开头。最后通过ensureSlash保证hash以/开头。
接下来是一个比较长的setupListeners方法
// this is delayed until the app mounts
// to avoid the hashchange listener being fired too early
setupListeners () {
const router = this.router
const expectScroll = router.options.scrollBehavior
const supportsScroll = supportsPushState && expectScroll
if (supportsScroll) {
setupScroll()
}
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)
}
})
})
这个方法会在vue-router实例初始化的时候调用,也就是初次进入应用导航的时候。为什么不在history对象初始化时就完成路由变化监听器的绑定呢?实际上注释就解释了这一点,避免hashchange事件过早被触发导致其他路由生命周期方法触发时机出问题。
接下来就是真正的导航方法push和replace声明了。
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)
}
主要做的事很简单,调用通用的transitionTo方法完成前文讲的整个过渡逻辑以及各种路由钩子,最终触发回调处理滚动事件和hash变化。
html5
了解了hashHistory的结构后,html5History就能轻松理解了,整体组织思路和前者是完全一样的,同样是继承baseHistory,初始化时监听路由变化,以及push和replace的声明,和hash区别主要在于减少了对location的处理,用HTML5的history相关API完成location的更新。源码如下,就不多介绍了。
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()
}
const initLocation = getLocation(this.base)
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 => {
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 => {
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)
}
}
abstract
这部分就更简单了,因为主要是为服务端渲染而服务的,省去了location变化的处理,只用实现编程式导航部分即可,通过一个stack模拟history的堆栈信息。源码如下
export class AbstractHistory extends History {
index: number;
stack: Array<Route>;
constructor (router: Router, base: ?string) {
super(router, base)
this.stack = []
this.index = -1
}
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(location, route => {
this.stack = this.stack.slice(0, this.index + 1).concat(route)
this.index++
onComplete && onComplete(route)
}, onAbort)
}
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
this.transitionTo(location, route => {
this.stack = this.stack.slice(0, this.index).concat(route)
onComplete && onComplete(route)
}, onAbort)
}
go (n: number) {
const targetIndex = this.index + n
if (targetIndex < 0 || targetIndex >= this.stack.length) {
return
}
const route = this.stack[targetIndex]
this.confirmTransition(route, () => {
this.index = targetIndex
this.updateRoute(route)
})
}
getCurrentLocation () {
const current = this.stack[this.stack.length - 1]
return current ? current.fullPath : '/'
}
ensureURL () {
// noop
}
}
router-view
通过push或replace进入编程式导航的流程其实和前文分析的初始化后第一次导航是完全一致了,这里就不再赘述,直接进入导航完成后,组件的渲染部分——router-view。该组件的组织其实异常简单,除了render方法外,只有3个属性。就先看这3个属性。
export default {
name: 'RouterView',
functional: true,
props: {
name: {
type: String,
default: 'default'
}
},
render(...) {
// ...
}
}
name声明该组件名称,functional表示该组件是函数式组件,意味着该组件是无状态无实例的,组件需要的一切都是通过上下文传递。 props中的name表示渲染对应路由配置中components下相关组件。
再看render方法。
render (_, { props, children, parent, data }) {
// used by devtools to display a router-view badge
data.routerView = true
// directly use parent context's createElement() function
// so that components rendered by router-view can resolve named slots
const h = parent.$createElement
const name = props.name
const route = parent.$route
const cache = parent._routerViewCache || (parent._routerViewCache = {})
// determine current view depth, also check to see if the tree
// has been toggled inactive but kept-alive.
let depth = 0
let inactive = false
while (parent && parent._routerRoot !== parent) {
if (parent.$vnode && parent.$vnode.data.routerView) {
depth++
}
if (parent._inactive) {
inactive = true
}
parent = parent.$parent
}
data.routerViewDepth = depth
// render previous view if the tree is inactive and kept-alive
if (inactive) {
return h(cache[name], data, children)
}
const matched = route.matched[depth]
// render empty node if no matched route
if (!matched) {
cache[name] = null
return h()
}
const component = cache[name] = matched.components[name]
// attach instance registration hook
// this will be called in the instance's injected lifecycle hooks
data.registerRouteInstance = (vm, val) => {
// val could be undefined for unregistration
const current = matched.instances[name]
if (
(val && current !== vm) ||
(!val && current === vm)
) {
matched.instances[name] = val
}
}
// also register instance in prepatch hook
// in case the same component instance is reused across different routes
;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
matched.instances[name] = vnode.componentInstance
}
// resolve props
let propsToPass = data.props = resolveProps(route, matched.props && matched.props[name])
if (propsToPass) {
// clone to prevent mutation
propsToPass = data.props = extend({}, propsToPass)
// pass non-declared props as attrs
const attrs = data.attrs = data.attrs || {}
for (const key in propsToPass) {
if (!component.props || !(key in component.props)) {
attrs[key] = propsToPass[key]
delete propsToPass[key]
}
}
}
return h(component, data, children)
}
function resolveProps(route, config) {
switch (typeof config) {
case 'undefined':
return
case 'object':
return config
case 'function':
return config(route)
case 'boolean':
return config ? route.params : undefined
default:
if (process.env.NODE_ENV !== 'production') {
warn(
false,
`props in "${route.path}" is a ${typeof config}, ` +
`expecting an object, function or boolean.`
)
}
}
}
首先从父组件上下文中获取渲染函数以及当前路由对象,再通过一层while循环获取当前组件的深度,再根据深度获取到当前路由对象中对应深度匹配到的组件。如果没有匹配则渲染空节点。
如果匹配到组件,则给该组件将要渲染的data设置registerRouteInstance方法,该方法的调用在vue-router初始化时已经混入到了所有组件的beforeCreate方法以及destroyed中,目的是在当前路由对象的instances属性中记录该组件实例。同样的在vdom的prepatch阶段也进行一次组件实例记录,用来复用同一个组件。
最后解析props,调用render函数渲染匹配组件就完成了过渡后路由视图的展示。
小结
至此也可以看出,vue-router工作的连接视图组件到导航逻辑的关键就在于两个无状态组件。link组件靠事件监听完成视图到导航逻辑部分,然后导航逻辑完成当前路由对象更新,view组件靠父组件的$route获取当前路由对象并取得更新后匹配组件再渲染,完成导航逻辑到视图的部分,整个工作流程就形成闭环了。
-- EOF --