簡介
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
方法會執行導航上的守衛,這兩步就不看了,感興趣同學可以自己查閱,至此主要的流程已經分析完了。