VueRouter 原理解读 - 路由组件与组合式 API 的实现

在这个篇章当中我们来看一个相对比较简单轻松的内容,一个是 VueRouter 这个路由库当中提供的路由相关组件以及组合式API的实现。

一、相关组合式 API

为了更好的迎接 Vue.js 3.x 版本的 composition api,VueRouter 也是提供了一系列相关的组合式 api 以便于在 hooks 的场景下使用。废话不多说了,我们立马来看看 VueRouter 提供了那些组合式 API,并且深入研究其实现逻辑。

1.1 路由信息与路由导航 hooks

useRouter

获取 VueRouter 路由导航跳转操作

基本使用

javascript 复制代码
import { useRouter } from 'vue-router'

export default {
  setup() {
    const router = useRouter()

    function pushWithQuery(query) {
      router.push({
        name: 'home',
      })
    }
  },
}

源码解析

arduino 复制代码
export function useRouter(): Router {
  return inject(routerKey)!
}

方法源码实现十分简单暴力,就是使用 Vue.js 的Provide/Inject的能力,获取在 VueRouter createRouter里面定义的install方法当中注入在全局当中的相关信息。

useRoute

获取 VueRouter 路由信息

基本使用

javascript 复制代码
import { useRoute } from 'vue-router'
import { ref, watch } from 'vue'

export default {
  setup() {
    const route = useRoute()
    const userData = ref()

    // 当参数更改时获取用户信息
    watch(
      () => route.params.id,
      async newId => {
        userData.value = await fetchUser(newId)
      }
    )
  },
}

源码解析

arduino 复制代码
export function useRoute(): RouteLocationNormalizedLoaded {
  return inject(routeLocationKey)!
}

useRoute的实现和useRouter就是一样的逻辑,就是使用inject获取注入在 Vue 全局的路由相关的信息。

1.2 导航守卫回调

onBeforeRouteLeave 与 onBeforeRouteUpdate

VueRouter 4 当中主要提供了onBeforeRouteLeave路由离开与onBeforeRouteUpdate路由更新这两个路由守卫钩子 API。

基本使用

javascript 复制代码
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'

export default {
  setup() {
    // 与 beforeRouteLeave 相同,无法访问 `this`
    onBeforeRouteLeave((to, from) => {
      const answer = window.confirm(
        'Do you really want to leave? you have unsaved changes!'
      )
      // 取消导航并停留在同一页面上
      if (!answer) return false
    })

    const userData = ref()

    // 与 beforeRouteUpdate 相同,无法访问 `this`
    onBeforeRouteUpdate(async (to, from) => {
      //仅当 id 更改时才获取用户,例如仅 query 或 hash 值已更改
      if (to.params.id !== from.params.id) {
        userData.value = await fetchUser(to.params.id)
      }
    })
  },
}

源码解析

scss 复制代码
// vuejs:router/packages/router/src/navigationGuards.ts

function registerGuard(
  record: RouteRecordNormalized,
  name: 'leaveGuards' | 'updateGuards',
  guard: NavigationGuard
) {
  const removeFromList = () => {
    record[name].delete(guard)
  }

  // 在视图组件卸载、失活时候清除对应的导航守卫回调
  onUnmounted(removeFromList)
  onDeactivated(removeFromList)

  // 在视图组件激活时候注册对应的导航守卫回调(避免keep-alive的路由页面被激活时候没有触发)
  onActivated(() => {
    record[name].add(guard)
  })

  record[name].add(guard)
}

// beforeRouteLeave 导航守卫
export function onBeforeRouteLeave(leaveGuard: NavigationGuard) {
  const activeRecord: RouteRecordNormalized | undefined = inject(
    matchedRouteKey,
    {} as any
  ).value

  registerGuard(activeRecord, 'leaveGuards', leaveGuard)
}

// beforeRouteUpdate 导航守卫
export function onBeforeRouteUpdate(updateGuard: NavigationGuard) {
  const activeRecord: RouteRecordNormalized | undefined = inject(
    matchedRouteKey,
    {} as any
  ).value

  registerGuard(activeRecord, 'updateGuards', updateGuard)
}

能够看到这两个导航守卫的 composition api 的实现还是比较简单并且相近的,其实就是利用 Vue.js 的provide/inject能力的基础上从全局 Vue 对象当中获取到当前激活的路由的标准化路由项,然后使用registerGuard方法来进行对应钩子回调的注册与销毁处理。

registerGuard方法逻辑主要就是在对应相关匹配的路由组件当中注册相关的守卫钩子以及组件销毁卸载、失活时候将相关守卫钩子清除。

注:这里仅讲述相关导航守卫回调的注册逻辑,具体运行调用逻辑在过往的同系列文章 "导航守卫的实现" 中已经详细讲述,这里不再重复讲述了。传送门:VueRouter 原理解读 - 导航守卫的实现 - 掘金

基本使用

VueRouter 将 RouterLink 的内部行为作为一个组合式函数 (useLink) 进行开放业务方来引入使用。useLink hooks 方法接收一个类似 RouterLink 所有 prop 的响应式对象(可以理解就是路由地址 path、路由参数 query、param 等信息),并暴露底层属性来构建 RouterLink 点击跳转路由的组件或生成自定义链接:

javascript 复制代码
import { RouterLink, useLink } from 'vue-router'
import { computed } from 'vue'

export default {
  name: 'AppLink',

  props: {
    ...RouterLink.props,
    inactiveClass: String,
  },

  setup(props) {
    const {
      route,             // 解析出来的路由对象
      href,              // 用在链接里的 href
      isActive,          // 布尔类型 - 链接是否匹配当前路由
      isExactActive,     // 布尔类型 - 链接是否严格匹配当前路由
      navigate,          // 导航至该链接的函数,调用会进行 SPA 路由跳转
    } = useLink(props)

    const isExternalLink = computed(
      () => typeof props.to === 'string' && props.to.startsWith('http')
    )

    return { isExternalLink, href, navigate, isActive }
  },
}

源码解析

typescript 复制代码
// vuejs:router/packages/router/src/RouterLink.ts

export function useLink(props: UseLinkOptions) {
  const router = inject(routerKey)!
  const currentRoute = inject(routeLocationKey)!

  const route = computed(() => router.resolve(unref(props.to)))

  const activeRecordIndex = computed<number>(() => {
    const { matched } = route.value
    const { length } = matched
    const routeMatched: RouteRecord | undefined = matched[length - 1]
    const currentMatched = currentRoute.matched
    if (!routeMatched || !currentMatched.length) return -1
    const index = currentMatched.findIndex(
      isSameRouteRecord.bind(null, routeMatched)
    )
    if (index > -1) return index
    const parentRecordPath = getOriginalPath(
      matched[length - 2] as RouteRecord | undefined
    )
    return (
      length > 1 &&
        getOriginalPath(routeMatched) === parentRecordPath &&
        currentMatched[currentMatched.length - 1].path !== parentRecordPath
        ? currentMatched.findIndex(
            isSameRouteRecord.bind(null, matched[length - 2])
          )
        : index
    )
  })

  const isActive = computed<boolean>(
    () =>
      activeRecordIndex.value > -1 &&
      includesParams(currentRoute.params, route.value.params)
  )
  const isExactActive = computed<boolean>(
    () =>
      activeRecordIndex.value > -1 &&
      activeRecordIndex.value === currentRoute.matched.length - 1 &&
      isSameRouteLocationParams(currentRoute.params, route.value.params)
  )

  function navigate(e: MouseEvent = {} as MouseEvent): Promise<void | NavigationFailure> {
    if (guardEvent(e)) {
      return router[unref(props.replace) ? 'replace' : 'push'](unref(props.to)).catch(noop)
    }
    return Promise.resolve()
  }

  return {
    route,
    href: computed(() => route.value.href),
    isActive,
    isExactActive,
    navigate,
  }
}

我们根据useLink这个 hooks 方法返回的对象所挂载的属性或方法逐个来进行分析:

  • route:这是一个计算属性,返回的是需要跳转的网页路径路由解析出来的 VueRouter 路由对象;
    • 就是通过过往同 VueRouter 解析系列当中的路由匹配器对象的resolve方法对需要跳转的路由对象to进行处理即可。
  • href: 同样是计算属性,返回的是当前网页路由路径;
    • 从上面获得的route路由对象当中提取出对应的href属性即可。
  • isActive : 当前路由与props.to部分匹配;
    • 匹配参数相同则认为状态是激活
  • isExactActive : 类似上述的isActive,只不过该isExactActive是更加严格,是路由路径和参数完全匹配;
    • 在参数相同的基础上还多判断了当前路由匹配的层级和该组件设置的的路由匹配层级是否一致
  • navigate: 这是导航跳转到该props.to路由的方法
    • 根据props.replace具体确认调用router.push(props.to)或者router.replace(props.to)方法进行路由导航跳转操作。

二、路由组件

基础使用

router-link 是 VueRouter 封装提供的一个点击进行页面路由切换跳转的一个组件,和传统 a 标签 href 跳转相比是不会进行整个浏览器标签页的刷新变化操作,而仅仅是 SPA 路由页面的刷新跳转。

ini 复制代码
<router-link to="/" reaplace>Home</router-link>

源码解析

javascript 复制代码
// vuejs:router/packages/router/src/RouterLink.ts

export const RouterLinkImpl = /*#__PURE__*/ defineComponent({
  name: 'RouterLink',
  
  compatConfig: { MODE: 3 }, // 使用 Vue 3.x 版本
  
  props: {
    // 目标跳转路由
    to: {
      type: [String, Object] as PropType<RouteLocationRaw>,
      required: true,
    },

    // 路由跳转使用 push 还是 replace
    replace: Boolean,
    
    // 链接被激活时候标签渲染增加的 class 
    activeClass: String,

    // 链接被精准激活时候标签渲染增加的 class 
    exactActiveClass: String,

    // 是否不使用 a 链接标签来包裹插槽内容
    custom: Boolean,

    // aria-current 属性
    ariaCurrentValue: {
      type: String as PropType<RouterLinkProps['ariaCurrentValue']>,
      default: 'page',
    },
  },

  useLink,

  setup(props, { slots }) {
    // 使用 props 参数调用 useLink 创建 router-link 所需的一些属性和行为
    const link = reactive(useLink(props))

    // 在 VueRouter createRouter 内 install 方法通过 provide 进行了路由相关的全局变量的注入,
    // 这里则使用 inject 来进行全局变量的读取。
    const { options } = inject(routerKey)!

    // 计算 router-link 标签的 class 类
    const elClass = computed(() => ({
      // 被激活时候的 class
      [getLinkClass(
        props.activeClass,
        options.linkActiveClass,
        'router-link-active'
      )]: link.isActive,

      // 被精准激活时候的 class
      [getLinkClass(
        props.exactActiveClass,
        options.linkExactActiveClass,
        'router-link-exact-active'
      )]: link.isExactActive,
    }))

    return () => {
      // 处理 router-link 默认插槽内容
      const children = slots.default && slots.default(link)

      // 判断 custom:
      // 	为 true 则直接渲染插槽内容(此时配置的 to 也就无法跳转了);
      //  否则使用 a 标签包裹着插槽
      return props.custom
        ? children
        : h(
            'a',
            {
              'aria-current': link.isExactActive
                ? props.ariaCurrentValue
                : null,
              href: link.href,
              onClick: link.navigate,
              class: elClass.value,
            },
            children
          )
    }
  },
})

从 RouterLink 的声明定义来看

创建一个 a 标签,绑定 href 跳转路径为 props 的 to 属性值,绑定劫持点击跳转事件就是调用组件实例的 $router (也就是 History 的实例)的 pushState / replaceState 方法,最后利用 render 函数渲染该组件。

能看到其实router-link这个组件的实现并不难,其实就是对 props 的参数进行了一个计算处理,,然后根据custom属性值来调整真正渲染时候的组件根节点是<a>标签包裹着插槽内容,还是直接渲染插槽的内容。

  • 这里需要注意,如果custom传递了 true 值直接渲染插槽内容时候该router-link组件不会根据to传递的路由进行导航跳转处理,需要自己处理导航跳转

<a>标签路由导航跳转其实也并非直接使用的传统href属性进行浏览器网页的跳转,而是进行了一步拦截处理。

  • 通过useLinkhooks方法(具体源码在上面的小章节当中详细讲述了,这里就不再重复了)获取link对象并设置<a>标签的同名href属性,接着是通过点击事件调用useLink返回的link对象导航navigate方法来真正进行路由跳转。

2.2 router-view

获取到第几层嵌套的 RouterView,然后根据 RouterMatched 获取当前的 RouterView 所对应配置的路由页面组件,利用 render 函数直接渲染该组件。

基础使用

ini 复制代码
<router-view class="view left-sidebar" name="LeftSidebar"></router-view>
<router-view class="view main-content"></router-view>
<router-view class="view right-sidebar" name="RightSidebar"></router-view>

源码解析

typescript 复制代码
// vuejs:router/packages/router/src/RouterView.ts

export const RouterViewImpl = /*#__PURE__*/ defineComponent({
  name: 'RouterView',

  inheritAttrs: false, // 设置组件的props属性不会透传给插槽节点或子节点
  
  props: {
    // 设置了name属性则直接渲染该name对应的路由配置下的组件(固定路由路径了相当于)
    name: {
      type: String as PropType<string>,
      default: 'default',
    },

    // 路由配置对象
    route: Object as PropType<RouteLocationNormalizedLoaded>,
  },

  compatConfig: { MODE: 3 }, // 使用 Vue 3.x 版本

  setup(props, { attrs, slots }) {
    // 当前的路由
    const injectedRoute = inject(routerViewLocationKey)!

    // 要展示的目标路由 - 优先取 props 传入的路由,其次取当前的路由
    const routeToDisplay = computed<RouteLocationNormalizedLoaded>(() => props.route || injectedRoute.value)

    // 当前路由的 router-view 深度
    const injectedDepth = inject(viewDepthKey, 0)

    // 获取 router-view 的深度(因为 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 将相关路由信息进行全局注入
    provide(viewDepthKey, computed(() => depth.value + 1)) // 当前的路由层级
    provide(matchedRouteKey, matchedRouteRef) //
    provide(routerViewLocationKey, routeToDisplay)

    // 创建当前 router-view 的组件实例的指向 ref
    const viewRef = ref<ComponentPublicInstance>()

    // 监听路由变化时候调用配置的路由守卫(beforeEnter)钩子
    watch(
      () => [viewRef.value, matchedRouteRef.value, props.name] as const,
      ([instance, to, name], [oldInstance, from, oldName]) => {
        if (to) {
          to.instances[name] = instance
          if (from && from !== to && instance && instance === oldInstance) {
            if (!to.leaveGuards.size) {
              to.leaveGuards = from.leaveGuards
            }
            if (!to.updateGuards.size) {
              to.updateGuards = from.updateGuards
            }
          }
        }

        if (instance && to && (!from || !isSameRouteRecord(to, from) || !oldInstance)) {
          ;(to.enterCallbacks[name] || []).forEach(callback =>
            callback(instance)
          )
        }
      },
      { flush: 'post' }
    )

    return () => {
      const route = routeToDisplay.value
      const currentName = props.name
      const matchedRoute = matchedRouteRef.value
      const ViewComponent = matchedRoute && matchedRoute.components![currentName]

      // 如果当前路由匹配项当中找不到设置的视图组件则使用该 router-view 的默认插槽内容进行渲染展示
      if (!ViewComponent) {
        return normalizeSlot(slots.default, { Component: ViewComponent, route })
      }

      // 处理路由的 props 内容
      const routePropsOption = matchedRoute.props[currentName]
      const routeProps = routePropsOption
        ? routePropsOption === true
          ? route.params
          : typeof routePropsOption === 'function'
          ? routePropsOption(route)
          : routePropsOption
        : null

      // 定义节点卸载钩子事件: 当跳转离开时候也就是视图组件实例进行卸载时候销毁挂载对应的路由视图组件实例(防止无用的变量未被 GC 回收造成的内存泄漏
      const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => {
        if (vnode.component!.isUnmounted) {
          matchedRoute.instances[currentName] = null
        }
      }

      // 通过 Vue.js 的创建 VNode 的 h 函数来进行创建对应目标路由的路由视图组件 VNode
      const component = h(
        ViewComponent,
        assign({}, routeProps, attrs, { // 传入props数据以及组件的卸载钩子
          onVnodeUnmounted,
          ref: viewRef,
        })
      )

      return (
        // 有默认插槽则返回默认插槽内容,否则返回使用 h 函数创建的对应目标路由的路由视图组件 VNode 节点
        normalizeSlot(slots.default, { Component: component, route }) ||
        component
      )
    }
  },
})

深度迎合套娃性质,制定了一套 provide/inject 的父子 传递信息规则,通过父 传递下来的信息,使用 VueRouter 的 matcher 去找到正确的路由组件,没错,所谓的页面切换就是找组件的一个过程,对于一个个分散开来的 vue 组件,通过 matched 去找到组件。

注:关于 VueRouter 的路由匹配器 matcher 相关的原理解析等内容在过往的系列文章当中已经分析过了,因此这里不再详解了。传送门:VueRouter 原理解读 - 路由匹配器原理 - 掘金

主要逻辑其实并不复杂:

  1. 监听路由的变化,处理执行相关 VueRouter 的路由守卫和路由视图组件守卫等钩子;
  2. 处理 RouterView 组件的 props 与封装卸载的 unMounted 钩子函数;
  3. 使用 Vue.js 提供的h函数创建匹配路由的对应配置当中的视图组件 VNode 节点;
  4. 最后将当前匹配的路由所需要渲染的路由视图组件(插槽或者是前面创建的 VNode 节点)作为 defineComponent 函数的返回。

2.3 注册相关全局组件

在定义了 VueRouter 相关的 RouterLink 与 RouterView 这两个组件后,我们在使用这两个组件都是不需要进行手动声明注册的,那么是在哪里进行一个组件的全局注册呢?

这时候让我们来回忆一下在 Vue.js 的项目当中都是怎样引入 VueRouter 的呢?对,没错!就是通过 Vue 实例对象的 app.use 方法引入的:

arduino 复制代码
const router = VueRouter.createRouter({
  // ··· ···,
})

const app = Vue.createApp({})
app.use(router)

// ··· ···

这时候会执行运行具体什么逻辑呢?大家还记得在 VueRouter 一开始的初始化流程这篇文章当中讲述createRouter方法内逻辑的一个小点吗?对,运行的就是 VueRouter 里面的install方法!在这个方法里面就提及到注册相关的 VueRouter 组件,当时在 "初始化" 的文章当中是主要集中讲述了相关 VueRouter 的属性与其他的一些逻辑操作,没有讲述具体怎么处理 View 和 link 这两个组件的处理,下面就来分析这两个组件做了些什么处理:

install 方法的源码:

javascript 复制代码
// vuejs:router/packages/router/src/router.ts

install(app: App) {
  // ··· ···

  // 注册 VueRouter 的路由视图和链接组件为 Vue 全局组件
  app.component('RouterLink', RouterLink)
  app.component('RouterView', RouterView)
	
  // ··· ···
}

就是在这createRouterinstall方法当中利用传入的 Vue 对象进行 component 方法注册为全局组件,这样子在 Vue 项目当中通过执行app.use(router)就会自动调用 VueRouter 的install方法从而执行了上面的注册 RouterLink 与 RouterView 为全局组件的逻辑了。


参考资料

相关的系列文章

相关的参考资料

相关推荐
烂蜻蜓21 分钟前
深入理解 Uniapp 中的 px 与 rpx
前端·css·vue.js·uni-app·html
木亦Sam37 分钟前
响应式网页设计中媒体查询的进阶运用
前端·响应式设计
diemeng111940 分钟前
2024系统编程语言风云变幻:Rust持续领跑,Zig与Ada异军突起
开发语言·前端·后端·rust
烂蜻蜓42 分钟前
Uniapp 中布局魔法:display 属性
前端·javascript·css·vue.js·uni-app·html
java1234_小锋1 小时前
一周学会Flask3 Python Web开发-redirect重定向
前端·python·flask·flask3
琑951 小时前
nextjs项目搭建——头部导航
开发语言·前端·javascript
light多学一点2 小时前
视频的分片上传
前端
Gazer_S2 小时前
【Windows系统node_modules删除失败(EPERM)问题解析与应对方案】
前端·javascript·windows
bigyoung2 小时前
基于 React 的列表实现方案,包含创建和编辑状态,使用 Modal 弹框和表单的最佳实践
前端
乌木前端2 小时前
包管理工具lock文件的作用
前端·javascript