前言
Hello~大家好。我是秋天的一阵风
🎉 欢迎来到 Vue3探秘系列专栏!在这里,我们将深入探索 Vue3 的各种奥秘,从源码到实践,一步步揭开它的神秘面纱。📚
🚀 以下是本系列文章的导航目录,方便你快速找到感兴趣的内容:
- 虚拟结点 vnode 的页面挂载之旅(一)
不止响应式:Vue3探秘系列--- 虚拟结点vnode的页面挂载之旅(一)
🌟 探索虚拟 DOM 如何变成页面上的真实内容,开启 Vue3 的渲染之旅!- 组件更新会发生什么(二)
不止响应式:Vue3探秘系列--- 组件更新会发生什么(二)
🔃 深入组件更新的内部机制,看看 Vue3 是如何高效更新界面的。- diff 算法的完整过程(三)
不止响应式:Vue3探秘系列--- diff算法的完整过程(三)
🧩 揭秘 Vue3 的 diff 算法,理解它是如何高效比较和更新 DOM 的。- 组件的初始化过程(四)
不止响应式:Vue3探秘系列--- 组件的初始化过程(四)
🌱 从零开始,了解 Vue3 组件是如何初始化的,掌握组件生命周期的关键步骤。- 响应式设计(五)
终于轮到你了:Vue3探秘系列--- 响应式设计(五)
🔗 深入 Vue3 的响应式系统,探索Proxy
如何实现高效的数据响应机制。这只是系列的一部分,更多精彩内容还在持续更新中!🔍
在上一篇文章中,我们介绍了 Vue Router
的基本用法,并且开始探究它的实现原理.知道了在history
模式下,Vue Router
是利用浏览器的 history
API(history.pushState
和 history.replaceState
) 动态更新 URL,并通过监听 popstate
事件处理用户点击回退按钮的情况
现在问题来了,改变了路由URL后,页面是如何根据URL的变化来渲染映射呢? 我们继续探究~
一、RouterView 组件
路由组件是通过RouterView
来组件渲染的:
javascript
const RouterView = defineComponent({
name: "RouterView",
props: {
name: {
type: String,
default: "default",
},
route: Object,
},
setup(props, { attrs, slots }) {
warnDeprecatedUsage();
const injectedRoute = inject(routeLocationKey);
const depth = inject(viewDepthKey, 0);
const matchedRouteRef = computed(
() => (props.route || injectedRoute).matched[depth]
);
provide(viewDepthKey, depth + 1);
provide(matchedRouteKey, matchedRouteRef);
const viewRef = ref();
watch(
() => [viewRef.value, matchedRouteRef.value, props.name],
([instance, to, name], [oldInstance, from, oldName]) => {
if (to) {
to.instances[name] = instance;
if (from && instance === oldInstance) {
to.leaveGuards = from.leaveGuards;
to.updateGuards = from.updateGuards;
}
}
if (
instance &&
to &&
(!from || !isSameRouteRecord(to, from) || !oldInstance)
) {
(to.enterCallbacks[name] || []).forEach((callback) =>
callback(instance)
);
}
}
);
return () => {
const route = props.route || injectedRoute;
const matchedRoute = matchedRouteRef.value;
const ViewComponent = matchedRoute && matchedRoute.components[props.name];
const currentName = props.name;
if (!ViewComponent) {
return slots.default
? slots.default({ Component: ViewComponent, route })
: null;
}
const routePropsOption = matchedRoute.props[props.name];
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === "function"
? routePropsOption(route)
: routePropsOption
: null;
const onVnodeUnmounted = (vnode) => {
if (vnode.component.isUnmounted) {
matchedRoute.instances[currentName] = null;
}
};
const component = h(
ViewComponent,
assign({}, routeProps, attrs, {
onVnodeUnmounted,
ref: viewRef,
})
);
return slots.default
? slots.default({ Component: component, route })
: component;
};
},
});
-
RouterView
组件的setup
函数的返回值是一个函数,那这个函数就是它的渲染函数。 -
在没有插槽的情况下,会返回
component
变量,它是根据ViewComponent
渲染出来的,而ViewComponent
是根据matchedRoute.components[props.name]
求得的,而matchedRoute
是matchedRouteRef
对应的value
。 -
matchedRouteRef
是一个计算属性,在不考虑prop
传入route
的情况下,它的getter
是由injectedRoute.matched[depth]
求得的,而injectedRoute
,就是我们在前面在安装路由时候,注入的响应式currentRoute
对象,而depth
就是表示这个RouterView
的嵌套层级。 -
所以我们可以看到,
RouterView
的渲染的路由组件和当前路径currentRoute
的matched
对象相关,也和RouterView
自身的嵌套层级相关。
那么接下来,我们就来看路径对象中的 matched
的值是怎么在路径切换的情况下更新的。
1. 嵌套场景
我们还是通过示例的方式来说明,我们对前面的示例稍做修改,加上嵌套路由的场景:
javascript
// public.index.html
<div id="app">
<h1>Hello App!</h1>
<p>
<router-link to="/">Go to Home</router-link>
<router-link to="/about">Go to About</router-link>
</p>
<router-view></router-view>
</div>
javascript
// main.js
import { createApp } from "vue";
import { createRouter, createWebHashHistory } from "vue-router";
const Home = { template: "<div>Home</div>" };
const About = {
template: `<div>About
<router-link to="/about/user">Go User</router-link>
<router-view></router-view>
</div>`,
};
const User = {
template: "<div>User</div>,",
};
const routes = [
{ path: "/", component: Home },
{
path: "/about",
component: About,
children: [
{
path: "user",
component: User,
},
],
},
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
const app = createApp({});
app.use(router);
app.mount("#app");
我们在about
组件里面又加了一个RouterView
组件,然后往route
是数组里加了一个children
属性,对应About
组件嵌套路由的配置。
当我们执行 createRouter
函数创建路由的时候,内部会执行如下代码来创建一个 matcher
对象:
ini
const matcher = createRouterMatcher(options.routes, options);
2. createRouterMatcher
javascript
function createRouterMatcher(routes, globalOptions) {
const matchers = []
const matcherMap = new Map()
globalOptions = mergeOptions({ strict: false, end: true, sensitive: false }, globalOptions)
function addRoute(record, parent, originalRecord) {
let isRootAdd = !originalRecord;
let mainNormalizedRecord = normalizeRouteRecord(record);
mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record;
const options = mergeOptions(globalOptions, record);
const normalizedRecords = [mainNormalizedRecord, ];
let matcher;
let originalMatcher;
for (const normalizedRecord of normalizedRecords) {
let { path } = normalizedRecord;
if (parent && path[0] !== '/') {
let parentPath = parent.record.path
let connectingSlash =
parentPath[parentPath.length - 1] === '/' ? '' : '/'
normalizedRecord.path =
parent.record.path + (path && connectingSlash + path)
}
matcher = createRouteRecordMatcher(normalizedRecord, parent, options)
// if we are an alias we must tell the original record that we exist
// so we can be removed
if (originalRecord) {
originalRecord.alias.push(matcher)
if (__DEV__) {
checkSameParams(originalRecord, matcher)
}
} else {
// otherwise, the first record is the original and others are aliases
originalMatcher = originalMatcher || matcher
if (originalMatcher !== matcher) originalMatcher.alias.push(matcher)
// remove the route if named and only for the top record (avoid in nested calls)
// this works because the original record is the first one
if (isRootAdd && record.name && !isAliasRecord(matcher))
removeRoute(record.name)
}
if ('children' in mainNormalizedRecord) {
let children = mainNormalizedRecord.children
for (let i = 0; i < children.length; i++) {
addRoute(
children[i],
matcher,
originalRecord && originalRecord.children[i]
)
}
}
originalRecord = originalRecord || matcher
insertMatcher(matcher)
}
return originalMatcher
? () => {
// since other matchers are aliases, they should be removed by the original matcher
removeRoute(originalMatcher!)
}
: noop
}
function insertMatcher(matcher: RouteRecordMatcher) {
let i = 0
// console.log('i is', { i })
while (
i < matchers.length &&
comparePathParserScore(matcher, matchers[i]) >= 0
)
i++
// console.log('END i is', { i })
// while (i < matchers.length && matcher.score <= matchers[i].score) i++
matchers.splice(i, 0, matcher)
// only add the original record to the name map
if (matcher.record.name && !isAliasRecord(matcher))
matcherMap.set(matcher.record.name, matcher)
}
// 定义其它一些辅助函数
// 添加初始路径
routes.forEach(route = >addRoute(route)) return {
addRoute,
resolve,
removeRoute,
getRoutes,
getRecordMatcher
}
}
-
createRouterMatcher
函数内部定义了一个matchers
数组和一些辅助函数,我们先重点关注addRoute
函数的实现,我们只关注核心流程。 -
在
createRouterMatcher
函数的最后,会遍历routes
路径数组调用addRoute
方法添加初始路径。 -
在
addRoute
函数内部,首先会把route
对象标准化成一个record
,其实就是给路径对象添加更丰富的属性。 -
然后再执行
createRouteRecordMatcher
函数,传入标准化的record
对象,我们再来看它的实现:
3. createRouterMatcher
javascript
function createRouteRecordMatcher(record, parent, options) {
const parser = tokensToParser(tokenizePath(record.path), options)
{
const existingKeys = new Set()
for (const key of parser.keys) {
if (existingKeys.has(key.name))
warn(`Found duplicated params with name "${key.name}" for path "${record.path}". Only the last one will be available on "$route.params".`)
existingKeys.add(key.name)
}
}
const matcher = assign(parser, {
record,
parent,
children: [],
alias: []
})
if (parent) {
if (!matcher.record.aliasOf === !parent.record.aliasOf)
parent.children.push(matcher)
}
return matcher
}
createRouteRecordMatcher
函数生成的 matcher
对象不仅包含用于存储路由记录的 record
属性,还扩展了其他属性。特别地,如果存在一个 parent matcher
,当前的 matcher
会被添加到 parent.children
数组中,从而构建出父子关系,形成树状结构。
那么,什么情况下会出现 parent matcher
呢?回到 addRoute
函数,创建 matcher
对象后,会检查 record
是否包含 children
属性。如果存在 children
,则遍历这些子路由,并递归调用 addRoute
方法来添加它们。在这个过程中,当前的 matcher
作为 parent
参数传递给递归调用,这就是 parent matcher
的来源。
在所有子路由处理完成后,insertMatcher
函数会被调用,将当前的 matcher
插入到全局的 matchers
数组中。通过这种方式,matchers
数组被初始化,包含了用户配置的所有路由路径。
接下来,我们回到之前的问题:路径对象中的 matched
数组是如何在路径切换时更新的。
之前我们提到过,切换路径会执行 pushWithRedirect
方法,内部会执行一段代码:
javascript
const targetLocation = (pendingLocation = resolve(to));
这里会执行 resolve
函数解析生成 targetLocation
,这个targetLocation
最后也会在 finalizeNavigation
的时候赋值 currentRoute
更新当前路径。我们来看 resolve
函数的实现:
javascript
function resolve(location, currentLocation) {
let matcher
let params = {}
let path
let name
if ('name' in location && location.name) {
matcher = matcherMap.get(location.name)
if (!matcher)
throw createRouterError(1 /* MATCHER_NOT_FOUND */, {
location,
})
name = matcher.record.name
params = assign(
paramsFromLocation(currentLocation.params,
matcher.keys.filter(k => !k.optional).map(k => k.name)), location.params)
path = matcher.stringify(params)
}
else if ('path' in location) {
path = location.path
if ( !path.startsWith('/')) {
warn(`The Matcher cannot resolve relative paths but received "${path}". Unless you directly called `matcher.resolve("${path}")`, this is probably a bug in vue-router. Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue-router-next.`)
}
matcher = matchers.find(m => m.re.test(path))
} else {
matcher = currentLocation.name ? matcherMap.get(currentLocation.name) : matchers.find(m = >m.re.test(currentLocation.path)) if (!matcher) throw createRouterError(1
/* MATCHER_NOT_FOUND */
, {
location,
currentLocation,
}) name = matcher.record.name params = assign({},
currentLocation.params, location.params) path = matcher.stringify(params)
}
const matched = [] let parentMatcher = matcher
while (parentMatcher) {
matched.unshift(parentMatcher.record)
parentMatcher = parentMatcher.parent
}
return {
name,
path,
params,
matched,
meta: mergeMetaFields(matched),
}
}
-
resolve
函数的核心任务是依据传入的location
对象中的name
或path
属性,在预先构建的matchers
数组里精准定位到对应的matcher
。 -
找到匹配的
matcher
后,它会沿着matcher
的parent
属性逐级向上追溯,收集路径上所有相关的matcher
,并从这些matcher
中提取它们的record
属性,最终构建出一个matched
数组。 -
这个数组随后被整合进一个新的路径对象中并返回。
-
构建
matched
数组的目的是为了完整地记录路径信息,其顺序与RouterView
组件的嵌套层级相匹配。具体来说,matched
数组的第n
个元素对应着第n
层嵌套的RouterView
组件所对应的路由记录。 -
因此,与
to
相比,targetLocation
的独特之处在于它额外包含了matched
数组。这使得RouterView
组件能够通过injectedRoute.matched[depth][props.name]
获取到对应的组件定义,并据此渲染出相应的组件。
总结
通过上述机制,Vue Router
实现了路径与组件的动态映射。它利用浏览器的 history
API 动态更新 URL,并通过监听 popstate
事件处理用户操作。RouterView
组件负责根据当前路径渲染对应的路由组件,而 createRouterMatcher
则负责解析路由配置并生成匹配器。这种设计不仅支持嵌套路由,还能够高效地处理路径切换和组件更新。
希望这篇文章能帮助你更好地理解 Vue Router
的工作原理。如果你对某个部分还有疑问,欢迎在评论区留言讨论!