Vue3探秘系列— 路由:vue-router的实现原理(十六-下)

前言

Hello~大家好。我是秋天的一阵风

🎉 欢迎来到 Vue3探秘系列专栏!在这里,我们将深入探索 Vue3 的各种奥秘,从源码到实践,一步步揭开它的神秘面纱。📚

🚀 以下是本系列文章的导航目录,方便你快速找到感兴趣的内容:

  1. 虚拟结点 vnode 的页面挂载之旅(一)
    不止响应式:Vue3探秘系列--- 虚拟结点vnode的页面挂载之旅(一)
    🌟 探索虚拟 DOM 如何变成页面上的真实内容,开启 Vue3 的渲染之旅!
  2. 组件更新会发生什么(二)
    不止响应式:Vue3探秘系列--- 组件更新会发生什么(二)
    🔃 深入组件更新的内部机制,看看 Vue3 是如何高效更新界面的。
  3. diff 算法的完整过程(三)
    不止响应式:Vue3探秘系列--- diff算法的完整过程(三)
    🧩 揭秘 Vue3 的 diff 算法,理解它是如何高效比较和更新 DOM 的。
  4. 组件的初始化过程(四)
    不止响应式:Vue3探秘系列--- 组件的初始化过程(四)
    🌱 从零开始,了解 Vue3 组件是如何初始化的,掌握组件生命周期的关键步骤。
  5. 响应式设计(五)
    终于轮到你了:Vue3探秘系列--- 响应式设计(五)
    🔗 深入 Vue3 的响应式系统,探索 Proxy 如何实现高效的数据响应机制。

这只是系列的一部分,更多精彩内容还在持续更新中!🔍

在上一篇文章中,我们介绍了 Vue Router 的基本用法,并且开始探究它的实现原理.知道了在history模式下,Vue Router 是利用浏览器的 history API(history.pushStatehistory.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;
    };
  },
});
  1. RouterView 组件的 setup 函数的返回值是一个函数,那这个函数就是它的渲染函数。

  2. 在没有插槽的情况下,会返回 component 变量,它是根据ViewComponent渲染出来的,而 ViewComponent 是根据 matchedRoute.components[props.name] 求得的,而 matchedRoutematchedRouteRef 对应的 value

  3. matchedRouteRef 是一个计算属性,在不考虑prop传入 route 的情况下,它的getter是由 injectedRoute.matched[depth] 求得的,而 injectedRoute,就是我们在前面在安装路由时候,注入的响应式 currentRoute 对象,而 depth 就是表示这个RouterView的嵌套层级。

  4. 所以我们可以看到,RouterView 的渲染的路由组件和当前路径 currentRoutematched 对象相关,也和 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
}
}
  1. createRouterMatcher 函数内部定义了一个 matchers 数组和一些辅助函数,我们先重点关注 addRoute 函数的实现,我们只关注核心流程。

  2. createRouterMatcher 函数的最后,会遍历 routes 路径数组调用addRoute方法添加初始路径。

  3. addRoute 函数内部,首先会把 route 对象标准化成一个 record,其实就是给路径对象添加更丰富的属性。

  4. 然后再执行 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 对象中的 namepath 属性,在预先构建的 matchers 数组里精准定位到对应的 matcher

  • 找到匹配的 matcher 后,它会沿着 matcherparent 属性逐级向上追溯,收集路径上所有相关的 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 的工作原理。如果你对某个部分还有疑问,欢迎在评论区留言讨论!

相关推荐
brzhang2 分钟前
代码即图表:dbdiagram.io让数据库建模变得简单高效
前端·后端·架构
SummerGao.14 分钟前
【解决】layui layer的提示框,弹出框一闪而过的问题
前端·layui
懒懒小徐19 分钟前
大厂面试-框架篇
面试·职场和发展
wayhome在哪39 分钟前
大厂必考之大文件上传
面试
天天扭码42 分钟前
从数组到对象:JavaScript 遍历语法全解析(ES5 到 ES6 + 超详细指南)
前端·javascript·面试
拉不动的猪43 分钟前
前端开发中常见的数据结构优化问题
前端·javascript·面试
街尾杂货店&44 分钟前
css word
前端·css
Мартин.1 小时前
[Meachines] [Hard] CrimeStoppers LFI+ZIP-Shell+Firefox-Dec+DLINK+rootme-0.5
前端·firefox
冰镇生鲜1 小时前
快速静态界面 MDC规则约束 示范
前端
技术与健康1 小时前
【解读】Chrome 浏览器实验性功能全景
前端·chrome