vue-router 源码解析 | 6k 字 - 【下】
- 各位好,我是光辉 😎
- 本篇是
vue-router
源码解析的下篇,即收尾篇
- 本篇主要介绍了下面几点
- 介绍了
vue-router
是如何处理滚动的
view、link
组件都怎么实现的?
- 路由变化又是怎么触发重新渲染的等等
- 另外还是要说一下
- 第一次做源码解析,肯定有很多错误或理解不到位的地方,欢迎指正 🤞
- 项目地址
https://github.com/BryanAdamss/vue-router-for-analysis
- 如果觉得对你有帮助,记得给我一个
star
✨
- uml 图源文件
https://github.com/BryanAdamss/vue-router-for-analysis/blob/dev/vue-router.EAP
- 关联文章链接
滚动处理
- 我们知道
vue-router
可以处理一些滚动行为,例如记录页面滚动位置,然后在切换路由时滚到顶部或保持原先位置;
- 它主要接收一个
scrollBehavior
参数,scrollBehavior
有以下玩法
const router = new VueRouter({
routes: [...],
scrollBehavior (to, from, savedPosition) {
}
})
scrollBehavior (to, from, savedPosition) {
return { x: 0, y: 0 }
}
scrollBehavior (to, from, savedPosition) {
if (to.hash) {
return {
selector: to.hash
}
}
}
scrollBehavior (to, from, savedPosition) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ x: 0, y: 0 })
}, 500)
}
)
}
- 既支持滚动到指定位置,也可以滚动页面某个页面锚点位置和异步滚动
- 那它是如何做到呢?具体的逻辑又是怎样的呢?
- 我们前面都知道
HTML5History
在初始化时和HashHistory
在setupListener
时都会调用setupScroll
函数,初始化滚动相关的逻辑
- 并在
popstate
或hashchange
事件触发路由跳转时,调用handleScroll
处理滚动行为
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)
}
})
}
)
}
setupScroll
export function setupScroll() {
const protocolAndPath = window.location.protocol + '//' + window.location.host
const absolutePath = window.location.href.replace(protocolAndPath, '')
const stateCopy = extend({}, window.history.state)
stateCopy.key = getStateKey()
window.history.replaceState(stateCopy, '', absolutePath)
window.addEventListener('popstate', (e) => {
saveScrollPosition()
if (e.state && e.state.key) {
setStateKey(e.state.key)
}
})
}
- 可以看到其利用
History API
的来完成位置的保存
- 在
popstate
时记录滚动位置并更新状态obj
的key
- 这个
key
是用来在state
中标识每个路由用的
- 可以看下
key
的存取
const Time =
inBrowser && window.performance && window.performance.now
? window.performance
: Date
export function genStateKey(): string {
return Time.now().toFixed(3)
}
let _key: string = genStateKey()
export function getStateKey() {
return _key
}
export function setStateKey(key: string) {
return (_key = key)
}
- 可以看到声明了一个
_key
,其是一个三位的时间戳,更新和读取都是操作这一个_key
setupScroll
时,首先拷贝了当前的state
,并为其生成一个唯一key
- 通过
replaceState
将添加了key
的state
保存到当前路由的absolutePath
上
- 然后监听
popstate
事件,其只能通过浏览器的 前进/后退 按钮触发
- 触发后会保存当前位置,并更新
_key
- 这样就可以在路由发生变化触发
popstate
时,保存当前位置并设置唯一_key
- 看下其是如何存取位置信息的
const positionStore = Object.create(null)
export function saveScrollPosition() {
const key = getStateKey()
if (key) {
positionStore[key] = {
x: window.pageXOffset,
y: window.pageYOffset,
}
}
}
- 其利用
positionStore
对象配合唯一的_key
来存取位置
- 在
handleScroll
时就可以通过_key
取出之前保存的位置
handleScroll
- 处理滚动的代码位于
src/util/scroll.js
export function handleScroll (
router: Router,
to: Route,
from: Route,
isPop: boolean
) {
if (!router.app) {
return
}
const behavior = router.options.scrollBehavior
if (!behavior) {
return
}
if (process.env.NODE_ENV !== 'production') {
assert(typeof behavior === 'function', `scrollBehavior must be a function`)
}
router.app.$nextTick(() => {
const position = getScrollPosition()
const shouldScroll = behavior.call(
router,
to,
from,
isPop ? position : null
)
if (!shouldScroll) {
return
}
if (typeof shouldScroll.then === 'function') {
shouldScroll
.then(shouldScroll => {
scrollToPosition((shouldScroll: any), position)
})
.catch(err => {
if (process.env.NODE_ENV !== 'production') {
assert(false, err.toString())
}
})
} else {
scrollToPosition(shouldScroll, position)
}
})
}
- 在
$nextTick
中调用getScrollPosition
获取之前保存好的位置
- 再调用我们传入的
scrollBehavior
查看其返回值来确定是否需要进行滚动
- 还判断了一波是否是异步滚动
- 若是,则等待其
resolved
再调用scrollToPosition
- 否则直接调用
scrollToPosition
getScrollPosition
、scrollToPosition
代码如下
function getScrollPosition(): ?Object {
const key = getStateKey()
if (key) {
return positionStore[key]
}
}
function scrollToPosition(shouldScroll, position) {
const isObject = typeof shouldScroll === 'object'
if (isObject && typeof shouldScroll.selector === 'string') {
const el = hashStartsWithNumberRE.test(shouldScroll.selector)
? document.getElementById(shouldScroll.selector.slice(1))
: document.querySelector(shouldScroll.selector)
if (el) {
let offset =
shouldScroll.offset && typeof shouldScroll.offset === 'object'
? shouldScroll.offset
: {}
offset = normalizeOffset(offset)
position = getElementPosition(el, offset)
} else if (isValidPosition(shouldScroll)) {
position = normalizePosition(shouldScroll)
}
} else if (isObject && isValidPosition(shouldScroll)) {
position = normalizePosition(shouldScroll)
}
if (position) {
window.scrollTo(position.x, position.y)
}
}
- 获取滚动位置,是利用
_key
从positionStore
上读取之前保存的位置信息
scrollToPosition
的逻辑很清晰,其处理了滚动到指定dom
和直接滚动到特定位置的场景
小结
vue-router
处理滚动主要利用了History API
可以保存状态的特性实现
- 在路由进入前保存滚动位置,并在下次路由变化时,尝试取回之前位置,在
$nextTick
中真正的处理滚动
- 其支持滚动到指定位置、指定 DOM、异步滚动等场景
view 组件
vue-router
内置了router-view
、router-link
两个组件
- 前者负责在匹配到路由记录后将对应路由组件渲染出来
- 后者支持用户在具有路由功能的应用中 (点击) 导航
- 我们先来看
router-view
组件
router-view
router-view
的主要职责就是将路由组件渲染出来
- 定义位于
src/components/view.js
export default {
name: 'RouterView',
functional: true,
props: {
name: {
type: String,
default: 'default',
},
},
render( _, { props, children, parent, data }) {
data.routerView = true
const h = parent.$createElement
const name = props.name
const route = parent.$route
const cache = parent._routerViewCache || (parent._routerViewCache = {})
let depth = 0
let inactive = false
while (parent && parent._routerRoot !== parent) {
const vnodeData = parent.$vnode ? parent.$vnode.data : {}
if (vnodeData.routerView) {
depth++
}
if (vnodeData.keepAlive && parent._directInactive && parent._inactive) {
inactive = true
}
parent = parent.$parent
}
data.routerViewDepth = depth
if (inactive) {
const cachedData = cache[name]
const cachedComponent = cachedData && cachedData.component
if (cachedComponent) {
if (cachedData.configProps) {
fillPropsinData(
cachedComponent,
data,
cachedData.route,
cachedData.configProps
)
}
return h(cachedComponent, data, children)
} else {
return h()
}
}
const matched = route.matched[depth]
const component = matched && matched.components[name]
if (!matched || !component) {
cache[name] = null
return h()
}
cache[name] = { component }
data.registerRouteInstance = (vm, val) => {
const current = matched.instances[name]
if (
(val && current !== vm) ||
(!val && current === vm)
) {
matched.instances[name] = val
}
}
;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
matched.instances[name] = vnode.componentInstance
}
data.hook.init = (vnode) => {
if (
vnode.data.keepAlive &&
vnode.componentInstance &&
vnode.componentInstance !== matched.instances[name]
) {
matched.instances[name] = vnode.componentInstance
}
}
const configProps = matched.props && matched.props[name]
if (configProps) {
extend(cache[name], {
route,
configProps,
})
fillPropsinData(component, data, route, configProps)
}
return h(component, data, children)
},
}
- 其被定义成一个函数式组件,这代表它没有状态和实例(this 上下文),只接收了
name
来做命名视图
- 我们重点看下
render
方法
- 由于其是一个函数式组件,所以很多操作是借助父节点来完成的
- 为了支持解析命名插槽,其没有使用自己的
createElement
方法,而是使用父节点的createElement
方法
- 由于没有 this 上下文,无法通过
this.$route
获得当前路由对象,干脆就直接使用父节点的$route
- 可以看到添加了一个标志量
routerView
,主要用来在vue-devtools
中标识view
组件和在查找深度时用
- 然后声明了一个缓存对象
_routerViewCache
并赋值给cache
变量,用来在keep-alive
激活时快速取出被缓存的路由组件
- 开始从当前节点往上查找
Vue根实例
,在查找的过程中计算出view
组件的深度以及是否被kepp-alive
包裹并处于inative
状态
depth
主要用来获取当前view
对应的路由记录
- 前面说过,
vue-router
是支持嵌套路由的,对应的view
也是可以嵌套的
- 而且在匹配路由记录时,有下面的逻辑,
当一个路由记录匹配了,如果其还有父路由记录,则父路由记录肯定也是匹配的
,其会一直向上查找,找到一个父记录,就通过unshift
塞入route.matched
数组中的,所以父记录肯定在前,子记录在后,当前精准匹配的记录在最后
- 见
src/util/route.js formatMatch方法
depth
的计算在遇到父view
组件时,自增 1,通过不断向上查找,不断自增depth
,直到找到Vue根实例
才停止
- 停止时
route.matched[depth]
值就是当前view
对应的路由记录
- 有了路由记录,我们就可以从上取出对应的路由组件实例,然后渲染即可
- 关于路由记录和路由组件实例是如何绑定的,我们下面会讲
- 我们先看
非inactive
状态是如何渲染路由组件实例的
- 通过
route.matched[depth]
取出当前view
匹配的路由记录
- 然后再取出对应的路由组件实例
- 如果路由记录和路由组件实例有一个不存在,则渲染空结点,并重置
cache[name]
值
- 如果都能找到,则先把组件实例缓存下来
- 如果有配置动态路由参数,则把路由参数缓存到路由组件实例上,并调用
fillPropsinData
填充props
- 调用
h
渲染对应的路由组件实例即可
- 当组件处于
inactive
状态时,我们就可以从cache
中取出之前缓存的路由组件实例和路由参数,然后渲染就可以了
- 主流程如上,但还有一个重要的点没提
- 路由记录和路由组件实例是如何绑定的?
- 相信你已经注意到
data.registerRouteInstance
方法,没错,他就是用来为路由记录绑定路由组件实例的
registerInstance
- 我们先看下调用的地方
- 主要在
src/install.js
的全局混入中
export function install(Vue){
...
Vue.mixin({
beforeCreate () {
...
registerInstance(this, this)
},
destroyed () {
registerInstance(this)
}
})
}
- 可以看到其在全局混入的
beforeCreate
、destroyed
钩子中都有被调用
- 前者传入了两个 vm 实例,后者只传入了一个 vm 实例
- 我们看下实现,代码也位于
src/install.js
中
const registerInstance = (vm, callVal) => {
let i = vm.$options._parentVnode
if (isDef(i) && isDef((i = i.data)) && isDef((i = i.registerRouteInstance))) {
i(vm, callVal)
}
}
- 可以看到其接收一个
vm实例
和callVal
做为入参
- 然后取了
vm
的父节点做为 i 的初值
- 接着一步一步给
i赋值
,同时判断i
是否定义
- 到最后,
i
的值为vm.$options._parentVnode.data.registerRouteInstance
- 然后将两个入参传入
i
中调用
- 注意,这时的 i 是 vm 父节点上的方法,并不是 vm 上的方法
- 我们全局检索下
registerRouteInstance
关键字,发现其只被定义在了view.js
中,也就是router-view
组件中
- 结合上面一条,i 即
registerRouteInstance
是vm父节点
上的方法,而只有router-view
组件定义了registerRouteInstance
- 所以,只有当
vm
是router-view
的子节点时,registerRouteInstance
方法才会被调用
i(vm, callVal)
可以表达为vm._parentVnode.registerRouteInstance(vm,vm)
- 看下
registerRouteInstance
的实现
...
data.registerRouteInstance = (vm, val) => {
const current = matched.instances[name]
if (
(val && current !== vm) ||
(!val && current === vm)
) {
matched.instances[name] = val
}
}
matched
保存的是当前匹配到的路由记录,name
是命名视图名
- 如果
val
存在,并且当前路由组件和传入的不同,重新赋值
- 如果
val
不存在,且当前路由组件和传入的相同,也重新赋值,但是此时 val 为undefined
,相当于解绑
- 可以看到参数数量不同,一个函数实现了绑定和解绑的双重操作
- 通过这个方法就完成了路由记录和路由组件实例的绑定与解绑操作
- 这样就可以在
view
组件render
时,通过route.matched[depth].components[name]
取到路由组件进行渲染
- 还有些场景也需要进行绑定
- 当相同组件在不同路由间复用时,需要为路由记录绑定路由组件
keep-alive
组件被激活时,需要为路由记录绑定路由组件
小结
router-view
是一个函数式组件,有时需要借助父节点的能力,例如使用父节点的渲染函数来解析命名插槽
- 通过
routerView
来标识view
组件,方便vue-devtools
识别出view
组件和确定view
组件深度
- 通过向上查找,确定当前
view
的深度depth
,通过depth
取到对应的路由记录
- 再取出通过
registerInstance
绑定的路由组件实例
- 如果有动态路由参数,则先填充
props
然后再渲染
- 如果
view
被keep-alive
包裹并且处于inactive
状态,则从缓存中取出路由组件实例并渲染
如何触发重新渲染
- 在导航解析的章节,我们提过,导航解析成功后
- 会调用
updateRoute
方法,重新为全局的_routerRoot._route
即$route
赋值
updateRoute (route: Route) {
const prev = this.current
this.current = route
this.cb && this.cb(route)
...
}
- 在
view
组件中,会使用$parent.$route
即全局的_routerRoot._route
...
render (_, { props, children, parent, data }) {
...
const route = parent.$route
...
}
- 而在
install.js
的全局混入中,将_route
定义为响应式的,依赖了_route
的地方,在_route
发生变化时,都会重新渲染
Vue.mixin({
beforeCreate () {
...
Vue.util.defineReactive(this, '_route', this._router.history.current)
}
})
- 这样就完成了渲染的闭环,
view
依赖$route
,导航解析成功更新$route
,触发view
渲染
- 看完了
view
组件,我们来看下另外一个组件router-link
link 组件
router-link
组件被定义在src/components/link.js
中
- 主要用来支持用户在具有路由功能的应用中 (点击) 导航
router-link
import { createRoute, isSameRoute, isIncludedRoute } from '../util/route'
import { extend } from '../util/misc'
import { normalizeLocation } from '../util/location'
import { warn } from '../util/warn'
const toTypes: Array<Function> = [String, Object]
const eventTypes: Array<Function> = [String, Array]
const noop = () => {}
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,
ariaCurrentValue: {
type: String,
default: 'page',
},
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
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 = route.redirectedFrom
? createRoute(null, normalizeLocation(route.redirectedFrom), null, router)
: route
classes[exactActiveClass] = isSameRoute(current, compareTarget)
classes[activeClass] = this.exact
? classes[exactActiveClass]
: isIncludedRoute(current, compareTarget)
const ariaCurrentValue = classes[exactActiveClass]
? this.ariaCurrentValue
: null
const handler = (e) => {
if (guardEvent(e)) {
if (this.replace) {
router.replace(location, noop)
} else {
router.push(location, noop)
}
}
}
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 }
const scopedSlot =
!this.$scopedSlots.$hasNormal &&
this.$scopedSlots.default &&
this.$scopedSlots.default({
href,
route,
navigate: handler,
isActive: classes[activeClass],
isExactActive: classes[exactActiveClass],
})
if (scopedSlot) {
if (scopedSlot.length === 1) {
return scopedSlot[0]
} else if (scopedSlot.length > 1 || !scopedSlot.length) {
if (process.env.NODE_ENV !== 'production') {
warn(
false,
`RouterLink with to="${this.to}" is trying to use a scoped slot but it didn't provide exactly one child. Wrapping the content with a span element.`
)
}
return scopedSlot.length === 0 ? h() : h('span', {}, scopedSlot)
}
}
if (this.tag === 'a') {
data.on = on
data.attrs = { href, 'aria-current': ariaCurrentValue }
} else {
const a = findAnchor(this.$slots.default)
if (a) {
a.isStatic = false
const aData = (a.data = extend({}, a.data))
aData.on = aData.on || {}
for (const event in aData.on) {
const handler = aData.on[event]
if (event in on) {
aData.on[event] = Array.isArray(handler) ? handler : [handler]
}
}
for (const event in on) {
if (event in aData.on) {
aData.on[event].push(on[event])
} else {
aData.on[event] = handler
}
}
const aAttrs = (a.data.attrs = extend({}, a.data.attrs))
aAttrs.href = href
aAttrs['aria-current'] = ariaCurrentValue
} else {
data.on = on
}
}
return h(this.tag, data, this.$slots.default)
},
}
function guardEvent(e) {
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
if (e.defaultPrevented) return
if (e.button !== undefined && e.button !== 0) return
if (e.currentTarget && e.currentTarget.getAttribute) {
const target = e.currentTarget.getAttribute('target')
if (/\b_blank\b/i.test(target)) return
}
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
}
}
}
}
- 其实现就是一个普通的组件,实现了点击时跳转到
to
对应的路由功能
- 由于支持点击时需要标识样式类、精准匹配
exact
场景,所以通过sameRoute
、isIncludedRoute
来实现样式类的标识和精准匹配标识
- 在点击时,屏蔽了部分特殊场景,如点击时同时按下
ctrl
、alt
、shift
等control keys
时,不做跳转
- 看完组件后,我们再来看看
router
还给我们提供哪些实例方法
实例属性、方法
router
对外暴露了很多属性和方法
- 这些属性和方法在前面的源码部分也都有用过
实例属性
router.app
router.mode
router.currentRoute
实例方法
- 用注册全局导航守卫
router.beforeEach
router.beforeResolve
router.afterEach
- 编程式导航相关
router.push
router.replace
router.go
router.back
router.forward
- 服务端渲染相关
router.getMatchedComponents
- 返回目标位置或是当前路由匹配的组件数组 (是数组的定义/构造类,不是实例)
router.onReady
- 该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件
router.onError
- 注册一个回调,该回调会在路由导航过程中出错时被调用
- 动态路由
- 解析
router.resolve
- 传入一个对象,尝试解析并返回一个目标位置
总结
- 至此,我们完成了
vue-router@2
的所有源码分析 🎉
- 如果您觉得还可以,记得帮我点个赞 👍
参考
PS
npm 包