简介
Vue Router 是Vue.js的官方路由。与Vue.js核心深度集成,让用Vue.js构建单页应用(SPA)变得更加简单。
使用
创建
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},
]
constrouter = 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,},],},
]
下面我们从源码的角度看下页面是如何加载并显示到页面上的
原理
上面基础的使用方法可以看出,主要包含三个步骤:
1.创建createRouter
,并在app中use
使用这个路由
2.在模版中使用router-view
标签
3.导航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全局安装routerinstall(app: App) {const router = this// 全局注册组件RouterLink、RouterViewapp.component('RouterLink', RouterLink)app.component('RouterView', RouterView) // 全局声明router实例,this.$router访问app.config.globalProperties.$router = router// 全局注册this.$route 访问当前路由currentRouteObject.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 problemsif (isBrowser &&// used for the initial navigation client side to avoid pushing// multiple times when the router is used in multiple apps!started &¤tRoute.value === START_LOCATION_NORMALIZED) {// see above// 浏览器情况下,push一个初始页面,不指定url默认首页‘/’started = truepush(routerHistory.location).catch(err => {if (__DEV__) warn('Unexpected error when starting the router:', err)})} // ...app.provide(routerKey, router)app.provide(routeLocationKey, reactive(reactiveRoute))// 全局注入当前路由currentRouteapp.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() // 拿到之前注册的currentRouteconst 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.valuelet matchedRoute: RouteLocationMatched | undefinedwhile ((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 differentconst currentName = props.nameconst matchedRoute = matchedRouteRef.valueconst 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 vnodesnormalizeSlot(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
依次为:
跳转
分析跳转流程之前,先看下路由注册的解析逻辑,在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和命名路由Mapconst 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<img src="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// 命名路由添加到路由Mapif (matcher.record.name && !isAliasRecord(matcher))matcherMap.set(matcher.record.name, matcher)}function resolve( location: Readonly<MatcherLocationRaw>,currentLocation: Readonly<MatcherLocation> ): MatcherLocation {let matcher: RouteRecordMatcher | undefinedlet params: PathParams = {}let path: MatcherLocation['path']let name: MatcherLocation['name']if ('name' in location && location.name) {// 命名路由解析出pathmatcher = matcherMap.get(location.name)// ...// throws if cannot be stringifiedpath = 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 encodingpath = location.path//...matcher = matchers.find(m => m.re.test(path))// matcher should have a value after the loopif (matcher) {// we know the matcher works because we tested the regexpparams = matcher.parse(path)!name = matcher.record.name}// push相对路径} else {// match by name or path of current routematcher = 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 providedparams = assign({}, currentLocation.params, location.params)path = matcher.stringify(params)}const matched: MatcherLocation['matched'] = []let parentMatcher: RouteRecordMatcher | undefined = matcherwhile (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 " style="margin: auto" />
}
总结一下,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> {// 解析出目标locationconst targetLocation: RouteLocation = (pendingLocation = resolve(to))const from = currentRoute.valueconst data: HistoryState | undefined = (to as RouteLocationOptions).stateconst force: boolean | undefined = (to as RouteLocationOptions).force// to could be a string where `replace` is a functionconst replace = (to as RouteLocationOptions).replace === trueconst 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 existsredirectedFrom || targetLocation)// if it was a redirect we already called `pushWithRedirect` aboveconst 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 navigationfailure = 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 placeconst error = checkCanceledNavigation(toLocation, from)if (error) return error// only consider as push if it's not the first navigationconst isFirstNavigation = from === START_LOCATION_NORMALIZEDconst 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保存历史到routerHistoryif (isPush) {// on the initial navigation, we want to reuse the scroll position from// history state if it existsif (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 = toLocationhandleScroll(toLocation, from, isPush, isFirstNavigation)markAsReady()}
currentRoute.value = toLocation
执行完后,会触发router-view
中routeToDisplay
值变化,重新计算matchedRouteRef
获得新的ViewComponent
,完成页面刷新。 上面还有两点,router
的resolve
会调用到matcher
的resolve
,填充刚刚说过的matched
数组,navigate
方法会执行导航上的守卫,这两步就不看了,感兴趣同学可以自己查阅,至此主要的流程已经分析完了。
参考
tchedRouteRef获得新的
ViewComponent,完成页面刷新。 上面还有两点,
router的
resolve会调用到
matcher的
resolve,填充刚刚说过的
matched数组,
navigate`方法会执行导航上的守卫,这两步就不看了,感兴趣同学可以自己查阅,至此主要的流程已经分析完了。
最后
最近找到一个VUE的文档,它将VUE的各个知识点进行了总结,整理成了《Vue 开发必须知道的36个技巧》。内容比较详实,对各个知识点的讲解也十分到位。
有需要的小伙伴,可以点击下方卡片领取,无偿分享