在这个篇章当中我们来看一个相对比较简单轻松的内容,一个是 VueRouter 这个路由库当中提供的路由相关组件以及组合式API的实现。
一、相关组合式 API
为了更好的迎接 Vue.js 3.x 版本的 composition api,VueRouter 也是提供了一系列相关的组合式 api 以便于在 hooks 的场景下使用。废话不多说了,我们立马来看看 VueRouter 提供了那些组合式 API,并且深入研究其实现逻辑。
1.1 路由信息与路由导航 hooks
useRouter
获取 VueRouter 路由导航跳转操作
基本使用
javascript
import { useRouter } from 'vue-router'
export default {
setup() {
const router = useRouter()
function pushWithQuery(query) {
router.push({
name: 'home',
})
}
},
}
源码解析
arduino
export function useRouter(): Router {
return inject(routerKey)!
}
方法源码实现十分简单暴力,就是使用 Vue.js 的Provide/Inject
的能力,获取在 VueRouter createRouter
里面定义的install
方法当中注入在全局当中的相关信息。
useRoute
获取 VueRouter 路由信息
基本使用
javascript
import { useRoute } from 'vue-router'
import { ref, watch } from 'vue'
export default {
setup() {
const route = useRoute()
const userData = ref()
// 当参数更改时获取用户信息
watch(
() => route.params.id,
async newId => {
userData.value = await fetchUser(newId)
}
)
},
}
源码解析
arduino
export function useRoute(): RouteLocationNormalizedLoaded {
return inject(routeLocationKey)!
}
useRoute
的实现和useRouter
就是一样的逻辑,就是使用inject
获取注入在 Vue 全局的路由相关的信息。
1.2 导航守卫回调
onBeforeRouteLeave 与 onBeforeRouteUpdate
VueRouter 4 当中主要提供了onBeforeRouteLeave
路由离开与onBeforeRouteUpdate
路由更新这两个路由守卫钩子 API。
基本使用
javascript
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'
export default {
setup() {
// 与 beforeRouteLeave 相同,无法访问 `this`
onBeforeRouteLeave((to, from) => {
const answer = window.confirm(
'Do you really want to leave? you have unsaved changes!'
)
// 取消导航并停留在同一页面上
if (!answer) return false
})
const userData = ref()
// 与 beforeRouteUpdate 相同,无法访问 `this`
onBeforeRouteUpdate(async (to, from) => {
//仅当 id 更改时才获取用户,例如仅 query 或 hash 值已更改
if (to.params.id !== from.params.id) {
userData.value = await fetchUser(to.params.id)
}
})
},
}
源码解析
scss
// vuejs:router/packages/router/src/navigationGuards.ts
function registerGuard(
record: RouteRecordNormalized,
name: 'leaveGuards' | 'updateGuards',
guard: NavigationGuard
) {
const removeFromList = () => {
record[name].delete(guard)
}
// 在视图组件卸载、失活时候清除对应的导航守卫回调
onUnmounted(removeFromList)
onDeactivated(removeFromList)
// 在视图组件激活时候注册对应的导航守卫回调(避免keep-alive的路由页面被激活时候没有触发)
onActivated(() => {
record[name].add(guard)
})
record[name].add(guard)
}
// beforeRouteLeave 导航守卫
export function onBeforeRouteLeave(leaveGuard: NavigationGuard) {
const activeRecord: RouteRecordNormalized | undefined = inject(
matchedRouteKey,
{} as any
).value
registerGuard(activeRecord, 'leaveGuards', leaveGuard)
}
// beforeRouteUpdate 导航守卫
export function onBeforeRouteUpdate(updateGuard: NavigationGuard) {
const activeRecord: RouteRecordNormalized | undefined = inject(
matchedRouteKey,
{} as any
).value
registerGuard(activeRecord, 'updateGuards', updateGuard)
}
能够看到这两个导航守卫的 composition api 的实现还是比较简单并且相近的,其实就是利用 Vue.js 的provide/inject
能力的基础上从全局 Vue 对象当中获取到当前激活的路由的标准化路由项,然后使用registerGuard
方法来进行对应钩子回调的注册与销毁处理。
registerGuard
方法逻辑主要就是在对应相关匹配的路由组件当中注册相关的守卫钩子以及组件销毁卸载、失活时候将相关守卫钩子清除。
注:这里仅讲述相关导航守卫回调的注册逻辑,具体运行调用逻辑在过往的同系列文章 "导航守卫的实现" 中已经详细讲述,这里不再重复讲述了。传送门:VueRouter 原理解读 - 导航守卫的实现 - 掘金
1.3 useLink
基本使用
VueRouter 将 RouterLink 的内部行为作为一个组合式函数 (useLink) 进行开放业务方来引入使用。useLink hooks 方法接收一个类似 RouterLink 所有 prop 的响应式对象(可以理解就是路由地址 path、路由参数 query、param 等信息),并暴露底层属性来构建 RouterLink 点击跳转路由的组件或生成自定义链接:
javascript
import { RouterLink, useLink } from 'vue-router'
import { computed } from 'vue'
export default {
name: 'AppLink',
props: {
...RouterLink.props,
inactiveClass: String,
},
setup(props) {
const {
route, // 解析出来的路由对象
href, // 用在链接里的 href
isActive, // 布尔类型 - 链接是否匹配当前路由
isExactActive, // 布尔类型 - 链接是否严格匹配当前路由
navigate, // 导航至该链接的函数,调用会进行 SPA 路由跳转
} = useLink(props)
const isExternalLink = computed(
() => typeof props.to === 'string' && props.to.startsWith('http')
)
return { isExternalLink, href, navigate, isActive }
},
}
源码解析
typescript
// vuejs:router/packages/router/src/RouterLink.ts
export function useLink(props: UseLinkOptions) {
const router = inject(routerKey)!
const currentRoute = inject(routeLocationKey)!
const route = computed(() => router.resolve(unref(props.to)))
const activeRecordIndex = computed<number>(() => {
const { matched } = route.value
const { length } = matched
const routeMatched: RouteRecord | undefined = matched[length - 1]
const currentMatched = currentRoute.matched
if (!routeMatched || !currentMatched.length) return -1
const index = currentMatched.findIndex(
isSameRouteRecord.bind(null, routeMatched)
)
if (index > -1) return index
const parentRecordPath = getOriginalPath(
matched[length - 2] as RouteRecord | undefined
)
return (
length > 1 &&
getOriginalPath(routeMatched) === parentRecordPath &&
currentMatched[currentMatched.length - 1].path !== parentRecordPath
? currentMatched.findIndex(
isSameRouteRecord.bind(null, matched[length - 2])
)
: index
)
})
const isActive = computed<boolean>(
() =>
activeRecordIndex.value > -1 &&
includesParams(currentRoute.params, route.value.params)
)
const isExactActive = computed<boolean>(
() =>
activeRecordIndex.value > -1 &&
activeRecordIndex.value === currentRoute.matched.length - 1 &&
isSameRouteLocationParams(currentRoute.params, route.value.params)
)
function navigate(e: MouseEvent = {} as MouseEvent): Promise<void | NavigationFailure> {
if (guardEvent(e)) {
return router[unref(props.replace) ? 'replace' : 'push'](unref(props.to)).catch(noop)
}
return Promise.resolve()
}
return {
route,
href: computed(() => route.value.href),
isActive,
isExactActive,
navigate,
}
}
我们根据useLink
这个 hooks 方法返回的对象所挂载的属性或方法逐个来进行分析:
- route:这是一个计算属性,返回的是需要跳转的网页路径路由解析出来的 VueRouter 路由对象;
-
- 就是通过过往同 VueRouter 解析系列当中的路由匹配器对象的
resolve
方法对需要跳转的路由对象to
进行处理即可。
- 就是通过过往同 VueRouter 解析系列当中的路由匹配器对象的
- href: 同样是计算属性,返回的是当前网页路由路径;
-
- 从上面获得的
route
路由对象当中提取出对应的href
属性即可。
- 从上面获得的
- isActive : 当前路由与
props.to
部分匹配;
-
- 匹配参数相同则认为状态是激活
- isExactActive : 类似上述的
isActive
,只不过该isExactActive
是更加严格,是路由路径和参数完全匹配;
-
- 在参数相同的基础上还多判断了当前路由匹配的层级和该组件设置的的路由匹配层级是否一致
- navigate: 这是导航跳转到该
props.to
路由的方法
-
- 根据
props.replace
具体确认调用router.push(props.to)
或者router.replace(props.to)
方法进行路由导航跳转操作。
- 根据
二、路由组件
2.1 router-link
基础使用
router-link 是 VueRouter 封装提供的一个点击进行页面路由切换跳转的一个组件,和传统 a 标签 href 跳转相比是不会进行整个浏览器标签页的刷新变化操作,而仅仅是 SPA 路由页面的刷新跳转。
ini
<router-link to="/" reaplace>Home</router-link>
源码解析
javascript
// vuejs:router/packages/router/src/RouterLink.ts
export const RouterLinkImpl = /*#__PURE__*/ defineComponent({
name: 'RouterLink',
compatConfig: { MODE: 3 }, // 使用 Vue 3.x 版本
props: {
// 目标跳转路由
to: {
type: [String, Object] as PropType<RouteLocationRaw>,
required: true,
},
// 路由跳转使用 push 还是 replace
replace: Boolean,
// 链接被激活时候标签渲染增加的 class
activeClass: String,
// 链接被精准激活时候标签渲染增加的 class
exactActiveClass: String,
// 是否不使用 a 链接标签来包裹插槽内容
custom: Boolean,
// aria-current 属性
ariaCurrentValue: {
type: String as PropType<RouterLinkProps['ariaCurrentValue']>,
default: 'page',
},
},
useLink,
setup(props, { slots }) {
// 使用 props 参数调用 useLink 创建 router-link 所需的一些属性和行为
const link = reactive(useLink(props))
// 在 VueRouter createRouter 内 install 方法通过 provide 进行了路由相关的全局变量的注入,
// 这里则使用 inject 来进行全局变量的读取。
const { options } = inject(routerKey)!
// 计算 router-link 标签的 class 类
const elClass = computed(() => ({
// 被激活时候的 class
[getLinkClass(
props.activeClass,
options.linkActiveClass,
'router-link-active'
)]: link.isActive,
// 被精准激活时候的 class
[getLinkClass(
props.exactActiveClass,
options.linkExactActiveClass,
'router-link-exact-active'
)]: link.isExactActive,
}))
return () => {
// 处理 router-link 默认插槽内容
const children = slots.default && slots.default(link)
// 判断 custom:
// 为 true 则直接渲染插槽内容(此时配置的 to 也就无法跳转了);
// 否则使用 a 标签包裹着插槽
return props.custom
? children
: h(
'a',
{
'aria-current': link.isExactActive
? props.ariaCurrentValue
: null,
href: link.href,
onClick: link.navigate,
class: elClass.value,
},
children
)
}
},
})
从 RouterLink 的声明定义来看
创建一个 a 标签,绑定 href 跳转路径为 props 的 to 属性值,绑定劫持点击跳转事件就是调用组件实例的 $router (也就是 History 的实例)的 pushState / replaceState 方法,最后利用 render 函数渲染该组件。
能看到其实router-link
这个组件的实现并不难,其实就是对 props 的参数进行了一个计算处理,,然后根据custom
属性值来调整真正渲染时候的组件根节点是<a>
标签包裹着插槽内容,还是直接渲染插槽的内容。
- 这里需要注意,如果
custom
传递了 true 值直接渲染插槽内容时候该router-link
组件不会根据to
传递的路由进行导航跳转处理,需要自己处理导航跳转
<a>
标签路由导航跳转其实也并非直接使用的传统href
属性进行浏览器网页的跳转,而是进行了一步拦截处理。
- 通过
useLink
hooks方法(具体源码在上面的小章节当中详细讲述了,这里就不再重复了)获取link
对象并设置<a>
标签的同名href
属性,接着是通过点击事件调用useLink
返回的link
对象导航navigate
方法来真正进行路由跳转。
2.2 router-view
获取到第几层嵌套的 RouterView,然后根据 RouterMatched 获取当前的 RouterView 所对应配置的路由页面组件,利用 render 函数直接渲染该组件。
基础使用
ini
<router-view class="view left-sidebar" name="LeftSidebar"></router-view>
<router-view class="view main-content"></router-view>
<router-view class="view right-sidebar" name="RightSidebar"></router-view>
源码解析
typescript
// vuejs:router/packages/router/src/RouterView.ts
export const RouterViewImpl = /*#__PURE__*/ defineComponent({
name: 'RouterView',
inheritAttrs: false, // 设置组件的props属性不会透传给插槽节点或子节点
props: {
// 设置了name属性则直接渲染该name对应的路由配置下的组件(固定路由路径了相当于)
name: {
type: String as PropType<string>,
default: 'default',
},
// 路由配置对象
route: Object as PropType<RouteLocationNormalizedLoaded>,
},
compatConfig: { MODE: 3 }, // 使用 Vue 3.x 版本
setup(props, { attrs, slots }) {
// 当前的路由
const injectedRoute = inject(routerViewLocationKey)!
// 要展示的目标路由 - 优先取 props 传入的路由,其次取当前的路由
const routeToDisplay = computed<RouteLocationNormalizedLoaded>(() => props.route || injectedRoute.value)
// 当前路由的 router-view 深度
const injectedDepth = inject(viewDepthKey, 0)
// 获取 router-view 的深度(因为 router-view 是可以进行嵌套实现子路由页面渲染)
const depth = computed<number>(() => {
let initialDepth = unref(injectedDepth)
const { matched } = routeToDisplay.value
let matchedRoute: RouteLocationMatched | undefined
while (
(matchedRoute = matched[initialDepth]) &&
!matchedRoute.components
) {
initialDepth++
}
return initialDepth
})
// 获取目标路由对应的路由匹配器
const matchedRouteRef = computed<RouteLocationMatched | undefined>(
() => routeToDisplay.value.matched[depth.value]
)
// 利用 provide 将相关路由信息进行全局注入
provide(viewDepthKey, computed(() => depth.value + 1)) // 当前的路由层级
provide(matchedRouteKey, matchedRouteRef) //
provide(routerViewLocationKey, routeToDisplay)
// 创建当前 router-view 的组件实例的指向 ref
const viewRef = ref<ComponentPublicInstance>()
// 监听路由变化时候调用配置的路由守卫(beforeEnter)钩子
watch(
() => [viewRef.value, matchedRouteRef.value, props.name] as const,
([instance, to, name], [oldInstance, from, oldName]) => {
if (to) {
to.instances[name] = instance
if (from && from !== to && instance && instance === oldInstance) {
if (!to.leaveGuards.size) {
to.leaveGuards = from.leaveGuards
}
if (!to.updateGuards.size) {
to.updateGuards = from.updateGuards
}
}
}
if (instance && to && (!from || !isSameRouteRecord(to, from) || !oldInstance)) {
;(to.enterCallbacks[name] || []).forEach(callback =>
callback(instance)
)
}
},
{ flush: 'post' }
)
return () => {
const route = routeToDisplay.value
const currentName = props.name
const matchedRoute = matchedRouteRef.value
const ViewComponent = matchedRoute && matchedRoute.components![currentName]
// 如果当前路由匹配项当中找不到设置的视图组件则使用该 router-view 的默认插槽内容进行渲染展示
if (!ViewComponent) {
return normalizeSlot(slots.default, { Component: ViewComponent, route })
}
// 处理路由的 props 内容
const routePropsOption = matchedRoute.props[currentName]
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
// 定义节点卸载钩子事件: 当跳转离开时候也就是视图组件实例进行卸载时候销毁挂载对应的路由视图组件实例(防止无用的变量未被 GC 回收造成的内存泄漏
const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => {
if (vnode.component!.isUnmounted) {
matchedRoute.instances[currentName] = null
}
}
// 通过 Vue.js 的创建 VNode 的 h 函数来进行创建对应目标路由的路由视图组件 VNode
const component = h(
ViewComponent,
assign({}, routeProps, attrs, { // 传入props数据以及组件的卸载钩子
onVnodeUnmounted,
ref: viewRef,
})
)
return (
// 有默认插槽则返回默认插槽内容,否则返回使用 h 函数创建的对应目标路由的路由视图组件 VNode 节点
normalizeSlot(slots.default, { Component: component, route }) ||
component
)
}
},
})
深度迎合套娃性质,制定了一套 provide/inject 的父子 传递信息规则,通过父 传递下来的信息,使用 VueRouter 的 matcher 去找到正确的路由组件,没错,所谓的页面切换就是找组件的一个过程,对于一个个分散开来的 vue 组件,通过 matched 去找到组件。
注:关于 VueRouter 的路由匹配器 matcher 相关的原理解析等内容在过往的系列文章当中已经分析过了,因此这里不再详解了。传送门:VueRouter 原理解读 - 路由匹配器原理 - 掘金
主要逻辑其实并不复杂:
- 监听路由的变化,处理执行相关 VueRouter 的路由守卫和路由视图组件守卫等钩子;
- 处理 RouterView 组件的 props 与封装卸载的 unMounted 钩子函数;
- 使用 Vue.js 提供的
h
函数创建匹配路由的对应配置当中的视图组件 VNode 节点; - 最后将当前匹配的路由所需要渲染的路由视图组件(插槽或者是前面创建的 VNode 节点)作为 defineComponent 函数的返回。
2.3 注册相关全局组件
在定义了 VueRouter 相关的 RouterLink 与 RouterView 这两个组件后,我们在使用这两个组件都是不需要进行手动声明注册的,那么是在哪里进行一个组件的全局注册呢?
这时候让我们来回忆一下在 Vue.js 的项目当中都是怎样引入 VueRouter 的呢?对,没错!就是通过 Vue 实例对象的 app.use 方法引入的:
arduino
const router = VueRouter.createRouter({
// ··· ···,
})
const app = Vue.createApp({})
app.use(router)
// ··· ···
这时候会执行运行具体什么逻辑呢?大家还记得在 VueRouter 一开始的初始化流程这篇文章当中讲述createRouter
方法内逻辑的一个小点吗?对,运行的就是 VueRouter 里面的install
方法!在这个方法里面就提及到注册相关的 VueRouter 组件,当时在 "初始化" 的文章当中是主要集中讲述了相关 VueRouter 的属性与其他的一些逻辑操作,没有讲述具体怎么处理 View 和 link 这两个组件的处理,下面就来分析这两个组件做了些什么处理:
install 方法的源码:
javascript
// vuejs:router/packages/router/src/router.ts
install(app: App) {
// ··· ···
// 注册 VueRouter 的路由视图和链接组件为 Vue 全局组件
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)
// ··· ···
}
就是在这createRouter
的install
方法当中利用传入的 Vue 对象进行 component 方法注册为全局组件,这样子在 Vue 项目当中通过执行app.use(router)
就会自动调用 VueRouter 的install
方法从而执行了上面的注册 RouterLink 与 RouterView 为全局组件的逻辑了。
参考资料
相关的系列文章
- VueRouter 原理解读 - 初始化流程:juejin.cn/post/722060...
- VueRouter 原理解读 - 路由能力的原理与实现:juejin.cn/post/722118...
- VueRouter 原理解读 - 路由匹配器原理:juejin.cn/post/722914...
- VueRouter 原理解读 - 导航守卫的实现:juejin.cn/post/723320...
相关的参考资料
- VueRouter 组合式 api 官方文档:router.vuejs.org/zh/guide/ad...
- vue-router 如何做到页面切换?, 源码解析:juejin.cn/post/706199...