简介
Vue Router 是Vue.js的官方路由。与Vue.js核心深度集成,让用Vue.js构建单页应用(SPA)变得更加简单。
对于开发和维护管理后台类的前端项目,页面结构和组合可能非常复杂,所以正确的理解和使用Vue Router就显得尤为重要。
使用
创建
1、在安装好Vue Router依赖后,在App.vue
中引入router-view
,它是渲染的容器
<div id="app">
<router-view></router-view>
</div>
2、创建路由router/index.js
const routes = [
{ path: '/', component: Home},
{ path: '/login', name: 'login', component: Login},
]
const router = createRouter({
history: createWebHistory(),
routes: routes,
})
export default router
3、在main.js
中使用路由
import router from "./router";
const app = createApp(App)
app.use(router)
app.mount('#app')
然后就可以在任意组件中使用this.$router
形式访问它,并且以 this.$route
的形式访问当前路由:
// Home.vue
export default {
computed: {
username() {
// 我们很快就会看到 `params` 是什么
return this.$route.params.username
},
},
methods: {
goToDashboard() {
if (isAuthenticated) {
this.$router.push('/dashboard')
} else {
this.$router.push('/login')
}
},
},
}
嵌套路由
一些应用程序的 UI 由多层嵌套的组件组成。在这种情况下,URL 的片段通常对应于特定的嵌套组件结构,例如:
/user/johnny/profile /user/johnny/posts
+------------------+ +-----------------+
| User | | User |
| +--------------+ | | +-------------+ |
| | Profile | | +------------> | | Posts | |
| | | | | | | |
| +--------------+ | | +-------------+ |
+------------------+ +-----------------+
在上层app节点的顶层router-view
下,又包含的组件自己嵌套的router-view
,例如以上的user
模版:
const User = {
template: `
<div class="user">
<h2>User {{ $route.params.id }}</h2>
<router-view></router-view>
</div>
`,
}
要将组件渲染到这个嵌套的router-view
中,我们需要在路由中配置 children
:
const routes = [
{
path: '/user/:id',
component: User,
children: [
{
// 当 /user/:id/profile 匹配成功
// UserProfile 将被渲染到 User 的 <router-view> 内部
path: 'profile',
component: UserProfile,
},
{
// 当 /user/:id/posts 匹配成功
// UserPosts 将被渲染到 User 的 <router-view> 内部
path: 'posts',
component: UserPosts,
},
],
},
]
下面我们从源码的角度看下页面是如何加载并显示到页面上的
原理
上面基础的使用方法可以看出,主要包含三个步骤:
- 创建
createRouter
,并在app中use
使用这个路由 - 在模版中使用
router-view
标签 - 导航
push
,跳转页面
从routers声明的数组结构可以看出,声明的路由path
会被注册成路由表指向component
声明的组件,并在push
方法调用时,从路由表查出对应组件并加载。下面看下源码是如何实现这一过程的,Vue Router源码分析版本为4.1.5
创建安装
首先看下createRouter
方法实现:
/**
* Creates a Router instance that can be used by a Vue app.
*
* @param options - {@link RouterOptions}
*/
export function createRouter(options: RouterOptions): Router {
const matcher = createRouterMatcher(options.routes, options)
// ...
function addRoute(
parentOrRoute: RouteRecordName | RouteRecordRaw,
route?: RouteRecordRaw
) {
// ...
}
function getRoutes() {
return matcher.getRoutes().map(routeMatcher => routeMatcher.record)
}
function hasRoute(name: RouteRecordName): boolean {
return !!matcher.getRecordMatcher(name)
}
function push(to: RouteLocationRaw) {
return pushWithRedirect(to)
}
function replace(to: RouteLocationRaw) {
return push(assign(locationAsObject(to), { replace: true }))
}
// ...
const router: Router = {
currentRoute,
listening: true,
addRoute,
removeRoute,
hasRoute,
getRoutes,
resolve,
options,
push,
replace,
go,
back: () => go(-1),
forward: () => go(1),
beforeEach: beforeGuards.add,
beforeResolve: beforeResolveGuards.add,
afterEach: afterGuards.add,
onError: errorHandlers.add,
isReady,
// 在app全局安装router
install(app: App) {
const router = this
// 全局注册组件RouterLink、RouterView
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)
// 全局声明router实例,this.$router访问
app.config.globalProperties.$router = router
// 全局注册this.$route 访问当前路由currentRoute
Object.defineProperty(app.config.globalProperties, '$route', {
enumerable: true,
get: () => unref(currentRoute),
})
// this initial navigation is only necessary on client, on server it doesn't
// make sense because it will create an extra unnecessary navigation and could
// lead to problems
if (
isBrowser &&
// used for the initial navigation client side to avoid pushing
// multiple times when the router is used in multiple apps
!started &&
currentRoute.value === START_LOCATION_NORMALIZED
) {
// see above
// 浏览器情况下,push一个初始页面,不指定url默认首页‘/’
started = true
push(routerHistory.location).catch(err => {
if (__DEV__) warn('Unexpected error when starting the router:', err)
})
}
// ...
app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
// 全局注入当前路由currentRoute
app.provide(routerViewLocationKey, currentRoute)
// ...
},
}
return router
}
createRouter
方法返回了当前路由实例,内部初始化了一些路由的常用方法,和在组件中打印this.$router
结构是一样的,那install
方法是在哪里调用的呢?在安装时调用了app.use(router)
,看下use
方法,在runtime-core.cjs.prod.js
下:
use(plugin, ...options) {
if (installedPlugins.has(plugin)) ;
else if (plugin && shared.isFunction(plugin.install)) {
installedPlugins.add(plugin);
// 如果是插件,调用插件的install方法,并把当前app传入
plugin.install(app, ...options);
}
else if (shared.isFunction(plugin)) {
installedPlugins.add(plugin);
plugin(app, ...options);
}
else ;
return app;
},
至此已经完成了全局的router创建安装,并可以在代码中使用router-view
,this.$router
和实例的一些方法了,那么页面上是如何展示被加载的component
呢?需要看下渲染组件router-view
的内部实现
渲染
install
方法注册了RouterView
组件,实现在RouterView.ts
:
/**
* Component to display the current route the user is at.
*/
export const RouterView = RouterViewImpl as unknown as {
// ...
}
RouterViewImpl
实现:
export const RouterViewImpl = /*#__PURE__*/ defineComponent({
name: 'RouterView',
// ...
setup(props, { attrs, slots }) {
__DEV__ && warnDeprecatedUsage()
// 拿到之前注册的currentRoute
const injectedRoute = inject(routerViewLocationKey)!
// 当前要显示的route,监听route值变化时会刷新
const routeToDisplay = computed<RouteLocationNormalizedLoaded>(
() => props.route || injectedRoute.value
)
// 获取当前router-view深度层级,在嵌套路由时使用
const injectedDepth = inject(viewDepthKey, 0)
// 在当前router-view深度下去匹配要显示的路由matched
// matched 是个数组,在resolve方法被赋值,如果有匹配到则在当前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(
viewDepthKey,
computed(() => depth.value + 1)
)
provide(matchedRouteKey, matchedRouteRef)
provide(routerViewLocationKey, routeToDisplay)
const viewRef = ref<ComponentPublicInstance>()
// watch at the same time the component instance, the route record we are
// rendering, and the name
// 监听匹配路由变化时,刷新
watch(
() => [viewRef.value, matchedRouteRef.value, props.name] as const,
([instance, to, name], [oldInstance, from, oldName]) => {
// ...
},
{ flush: 'post' }
)
return () => {
const route = routeToDisplay.value
// we need the value at the time we render because when we unmount, we
// navigated to a different location so the value is different
const currentName = props.name
const matchedRoute = matchedRouteRef.value
const ViewComponent =
matchedRoute && matchedRoute.components![currentName]
if (!ViewComponent) {
return normalizeSlot(slots.default, { Component: ViewComponent, route })
}
// ...
// 关键:h函数,渲染路由中获得的组件
const component = h(
ViewComponent,
assign({}, routeProps, attrs, {
onVnodeUnmounted,
ref: viewRef,
})
)
return (
// pass the vnode to the slot as a prop.
// h and <component :is="..."> both accept vnodes
normalizeSlot(slots.default, { Component: component, route }) ||
component
)
}
},
})
实现嵌套路由的核心是使用深度depth
控制,初始router-view
深度为0,内部嵌套深度依次加1,比如对如下嵌套关系:
const routes = [
{
path: '/',
component: Home,
children: [
{
path: 'product',
component: ProductManage
},
]
},
{ path: '/login', name: 'login', component: Login }
]
它们在resolve
中被解析成的routeToDisplay.value
依次为:
matched
是个数组,在push
的resolve
时,把当前路径path
拆分解析成对应routes
数组中可以匹配的对象,然后初始值的router-view
,就取深度为0的值,深度1的router-view
就取到mactched[1]
的'/product'
对应的route,分别渲染
跳转
分析跳转流程之前,先看下路由注册的解析逻辑,在createRouter
方法中调用了createRouterMatcher
方法,该方法创建了一个路由匹配器,内部封装了路由注册和跳转的具体实现,外部创建的router
是对matcher
的包了一层提供API,并屏蔽实现细节。看下实现:
/**
* Creates a Router Matcher.
*
* @internal
* @param routes - array of initial routes
* @param globalOptions - global route options
*/
export function createRouterMatcher(
routes: Readonly<RouteRecordRaw[]>,
globalOptions: PathParserOptions
): RouterMatcher {
// normalized ordered array of matchers
// 匹配器的两个容器,匹配器Array和命名路由Map
const matchers: RouteRecordMatcher[] = []
const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
function getRecordMatcher(name: RouteRecordName) {
return matcherMap.get(name)
}
function addRoute(
record: RouteRecordRaw,
parent?: RouteRecordMatcher,
originalRecord?: RouteRecordMatcher
) {
// ...
// 如果记录中声明'alias'别名,把别名当作path,插入一条新的记录
if ('alias' in record) {
const aliases =
typeof record.alias === 'string' ? [record.alias] : record.alias!
for (const alias of aliases) {
normalizedRecords.push(
assign({}, mainNormalizedRecord, {
// this allows us to hold a copy of the `components` option
// so that async components cache is hold on the original record
components: originalRecord
? originalRecord.record.components
: mainNormalizedRecord.components,
path: alias,
// we might be the child of an alias
aliasOf: originalRecord
? originalRecord.record
: mainNormalizedRecord,
// the aliases are always of the same kind as the original since they
// are defined on the same record
}) as typeof mainNormalizedRecord
)
}
}
let matcher: RouteRecordMatcher
let originalMatcher: RouteRecordMatcher | undefined
for (const normalizedRecord of normalizedRecords) {
// ...
// create the object beforehand, so it can be passed to children
// 遍历记录,生成一个matcher
matcher = createRouteRecordMatcher(normalizedRecord, parent, options)
// ...
// 添加到容器
insertMatcher(matcher)
}
return originalMatcher
? () => {
// since other matchers are aliases, they should be removed by the original matcher
removeRoute(originalMatcher!)
}
: noop
}
function removeRoute(matcherRef: RouteRecordName | RouteRecordMatcher) {
// 删除路由元素
if (isRouteName(matcherRef)) {
const matcher = matcherMap.get(matcherRef)
if (matcher) {
matcherMap.delete(matcherRef)
matchers.splice(matchers.indexOf(matcher), 1)
matcher.children.forEach(removeRoute)
matcher.alias.forEach(removeRoute)
}
} else {
const index = matchers.indexOf(matcherRef)
if (index > -1) {
matchers.splice(index, 1)
if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name)
matcherRef.children.forEach(removeRoute)
matcherRef.alias.forEach(removeRoute)
}
}
}
function getRoutes() {
return matchers
}
function insertMatcher(matcher: RouteRecordMatcher) {
let i = 0
while (
i < matchers.length &&
comparePathParserScore(matcher, matchers[i]) >= 0 &&
// Adding children with empty path should still appear before the parent
// https://github.com/vuejs/router/issues/1124
(matcher.record.path !== matchers[i].record.path ||
!isRecordChildOf(matcher, matchers[i]))
)
i++
// 将matcher添加到数组末尾
matchers.splice(i, 0, matcher)
// only add the original record to the name map
// 命名路由添加到路由Map
if (matcher.record.name && !isAliasRecord(matcher))
matcherMap.set(matcher.record.name, matcher)
}
function resolve(
location: Readonly<MatcherLocationRaw>,
currentLocation: Readonly<MatcherLocation>
): MatcherLocation {
let matcher: RouteRecordMatcher | undefined
let params: PathParams = {}
let path: MatcherLocation['path']
let name: MatcherLocation['name']
if ('name' in location && location.name) {
// 命名路由解析出path
matcher = matcherMap.get(location.name)
// ...
// throws if cannot be stringified
path = matcher.stringify(params)
} else if ('path' in location) {
// no need to resolve the path with the matcher as it was provided
// this also allows the user to control the encoding
path = location.path
//...
matcher = matchers.find(m => m.re.test(path))
// matcher should have a value after the loop
if (matcher) {
// we know the matcher works because we tested the regexp
params = matcher.parse(path)!
name = matcher.record.name
}
// push相对路径
} else {
// match by name or path of current route
matcher = currentLocation.name
? matcherMap.get(currentLocation.name)
: matchers.find(m => m.re.test(currentLocation.path))
if (!matcher)
throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, {
location,
currentLocation,
})
name = matcher.record.name
// since we are navigating to the same location, we don't need to pick the
// params like when `name` is provided
params = assign({}, currentLocation.params, location.params)
path = matcher.stringify(params)
}
const matched: MatcherLocation['matched'] = []
let parentMatcher: RouteRecordMatcher | undefined = matcher
while (parentMatcher) {
// reversed order so parents are at the beginning
// 和当前path匹配的记录,插入到数组头部,让父级先匹配
matched.unshift(parentMatcher.record)
parentMatcher = parentMatcher.parent
}
return {
name,
path,
params,
matched,
meta: mergeMetaFields(matched),
}
}
// 添加初始路由
routes.forEach(route => addRoute(route))
return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
}
总结一下,createRouterMatcher
方法,为每一个routres
执行了addRoute
方法,调用了insertMatcher
,将生成的matchers
插入到容器中,后边在调用的时候,通过resolve
方法,将记录匹配到到Matcher.record
记录保存到MatcherLocation
的matched
数组中,后续router-view
会根据depth
从数组取应该要渲染的元素。
push
方法执行流程:
function push(to: RouteLocationRaw) {
return pushWithRedirect(to)
}
// ...
function pushWithRedirect(
to: RouteLocationRaw | RouteLocation,
redirectedFrom?: RouteLocation
): Promise<NavigationFailure | void | undefined> {
// 解析出目标location
const targetLocation: RouteLocation = (pendingLocation = resolve(to))
const from = currentRoute.value
const data: HistoryState | undefined = (to as RouteLocationOptions).state
const force: boolean | undefined = (to as RouteLocationOptions).force
// to could be a string where `replace` is a function
const replace = (to as RouteLocationOptions).replace === true
const shouldRedirect = handleRedirectRecord(targetLocation)
// 重定向逻辑
if (shouldRedirect)
return pushWithRedirect(
assign(locationAsObject(shouldRedirect), {
state:
typeof shouldRedirect === 'object'
? assign({}, data, shouldRedirect.state)
: data,
force,
replace,
}),
// keep original redirectedFrom if it exists
redirectedFrom || targetLocation
)
// if it was a redirect we already called `pushWithRedirect` above
const toLocation = targetLocation as RouteLocationNormalized
// ...
return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
.catch((error: NavigationFailure | NavigationRedirectError) =>
// ...
)
.then((failure: NavigationFailure | NavigationRedirectError | void) => {
if (failure) {
// ...
} else {
// if we fail we don't finalize the navigation
failure = finalizeNavigation(
toLocation as RouteLocationNormalizedLoaded,
from,
true,
replace,
data
)
}
triggerAfterEach(
toLocation as RouteLocationNormalizedLoaded,
from,
failure
)
return failure
})
}
在没有失败情况下调用finalizeNavigation
做最终跳转,看下实现:
/**
* - Cleans up any navigation guards
* - Changes the url if necessary
* - Calls the scrollBehavior
*/
function finalizeNavigation(
toLocation: RouteLocationNormalizedLoaded,
from: RouteLocationNormalizedLoaded,
isPush: boolean,
replace?: boolean,
data?: HistoryState
): NavigationFailure | void {
// a more recent navigation took place
const error = checkCanceledNavigation(toLocation, from)
if (error) return error
// only consider as push if it's not the first navigation
const isFirstNavigation = from === START_LOCATION_NORMALIZED
const state = !isBrowser ? {} : history.state
// change URL only if the user did a push/replace and if it's not the initial navigation because
// it's just reflecting the url
// 如果是push保存历史到routerHistory
if (isPush) {
// on the initial navigation, we want to reuse the scroll position from
// history state if it exists
if (replace || isFirstNavigation)
routerHistory.replace(
toLocation.fullPath,
assign(
{
scroll: isFirstNavigation && state && state.scroll,
},
data
)
)
else routerHistory.push(toLocation.fullPath, data)
}
// accept current navigation
// 给当前路由赋值,会触发监听的router-view刷新
currentRoute.value = toLocation
handleScroll(toLocation, from, isPush, isFirstNavigation)
markAsReady()
}
currentRoute.value = toLocation
执行完后,会触发router-view
中routeToDisplay
值变化,重新计算matchedRouteRef
获得新的ViewComponent
,完成页面刷新。
上面还有两点,router
的resolve
会调用到matcher
的resolve
,填充刚刚说过的matched
数组,navigate
方法会执行导航上的守卫,这两步就不看了,感兴趣同学可以自己查阅,至此主要的流程已经分析完了。