Vue Router 4(for Vue3) 源码分析

前言

本文是基于 Vue Router 4(for Vue3) 分析的 ,repository。拆分的 MEMO 如下,路由的钩子函数这部分源码不会深入。建议对官方文档有一定了解,了解源码为什么要这样做

涉及到 html5 的 pushState、replaceState API 以及 popstatehashchange 这些事件概念

MEMO

  • createRouter // router 入口
  • history // 属性决定历史记录模式是 hash 还是 HTML5 的 api
  • 根据 URL 匹配组件,计算嵌套组件深度
  • router view // 渲染 URL 对应的组件
  • 历史条目记录监听
  • 根据路由 URL 变化更新组件

createRouter 创建 router 入口

文件位置:src/router.ts

  • app.use(router) 实际上调用的是 router.install 方法

  • 返回了 router: Router 接口形状的对象

  • addRouteremoveRoute 这些 API 也是在 createRouter 内部声明,封装到 router: Router 内部进行返回的

  • install 方法内针对 router-link、router-view 进行了全局注册 app.component,还有全局对象 $router

    arduino 复制代码
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)
    
      app.config.globalProperties.$router = router
  • 通过 provide/inject 向后代组件注入了 routerKey、routeLocationKey、routerViewLocationKey

    scss 复制代码
      app.provide(routerKey, router)
      app.provide(routeLocationKey, shallowReactive(reactiveRoute))
      app.provide(routerViewLocationKey, currentRoute)
  • app unmount 的时候会调用 removeHistoryListener 方法卸载路由。removeHistoryListener 由 routerHistory.listen 返回,后面介绍 MEMO 2 history 会重新介绍

    ini 复制代码
      app.unmount = function () {
        installedApps.delete(app)
        // the router is not attached to an app anymore
        if (installedApps.size < 1) {
          // invalidate the current navigation
          pendingLocation = START_LOCATION_NORMALIZED
          removeHistoryListener && removeHistoryListener()
          removeHistoryListener = null
          currentRoute.value = START_LOCATION_NORMALIZED
          started = false
          ready = false
        }
        unmountApp()
      }

history 属性

history 可以设置 html5 模式、hash 模式、memory 模式,官方文档

序前言

这部分有几个前置知识以及思考

  • popstate 事件定义 mdn。重点是以下 备注: 调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。
  • popstate 和 hashchange 事件触发条件,是否存在包含关系。因为等下源码会揭示 hash 模式最终也是调用 html5 模式的 createWebHistory 函数,也就是说 popstate 事件一定程度上是可以代替 hashchange 事件的(技术向前发展,不讨论兼容性相关)。参考链接 cdn,也可以在浏览器实践操作下

hash 模式

文件位置:src/history/hash.ts

  • createWebHashHistory(base?: string) { ... return createWebHistory(base) }

    • base 默认值为 pathName + search
    • 以 《 # 》结尾
    • 最终调用的是 HTML5 模式的入口,返回 createWebHistory(base)
    • API 文档
    csharp 复制代码
      base = location.host ? base || location.pathname + location.search : ''
      if (!base.includes('#')) base += '#'
      if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) {
        warn(
          `A hash base must end with a "#":\n"${base}" should be "${base.replace(
            /#.*$/,
            '#'
          )}".`
        )
      }
      return createWebHistory(base)

html5 模式

文件位置:src/history/html5.ts

  • createWebHistory(base?: string): RouterHistory,返回 routerHistory 对象

  • 通过 Object.defineProperty 声明了 location、state 等只读属性到 routerHistory;Object.assign 合并了 routerHistory.push、replace、go、listen 等方法。这些属性和方法对应的文档

    dart 复制代码
      Object.defineProperty(routerHistory, 'location', {
        enumerable: true,
        get: () => historyNavigation.location.value,
      })
    
      Object.defineProperty(routerHistory, 'state', {
        enumerable: true,
        get: () => historyNavigation.state.value,
      })
      const routerHistory: RouterHistory = assign(
        {
          // it's overridden right after
          location: '',
          base,
          go,
          createHref: createHref.bind(null, base),
        },
    
        historyNavigation, // 包含了 push、replace
        historyListeners // 包含了 listen、destroy
      )
  • routerHistory 的 push、replace、go 方法本质是对 history 的 pushState、replaceState、go 封装。其中 replaceState 以及 pushState 是集成在 changeLocation 里面的

    scss 复制代码
        try {
          // BROWSER QUIRK
          // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds
          history[replace ? 'replaceState' : 'pushState'](state, '', url)
          historyState.value = state
        } catch (err) {
          if (__DEV__) {
            warn('Error with push/replace State', err)
          } else {
            console.error(err)
          }
          // Force the navigation, this also resets the call count
          location[replace ? 'replace' : 'assign'](url)
        }

根据 URL 匹配组件

文件位置:src/matcher/index.ts

序前言

vue router 官方文档的 children 属性决定一个路由组件是否为嵌套组件。组件之间的嵌套关系可以用父子关系来表示,它们是一起渲染形成完整的一个页面的

提前了解下 matcherMap 数据结构,因为这部分数据结构嵌套的比较复杂,这一块源码也主要是以 matcherMap 数据作为依赖

csharp 复制代码
    matcherMap.set(matcher.record.name, matcher) // [[name, matcher: ], ...]
    
    matcher: RouteRecordMatcher = {
      record: RouteRecordNormalized,
      parent,
      // these needs to be populated by the parent
      children: [],
      alias: [],
    }
    
    record: RouteRecordNormalized = {
      path: record.path,
      redirect: record.redirect,
      name: record.name,
      ...
      components:
        'components' in record
          ? record.components || null
          : record.component && { default: record.component },
    }
  • createRouterMatche(routes),vue 外部传入 routes 属性作为参数

    • 主要创建 matcherMap = new Map<RouteRecordName, RouteRecordMatcher>() 对象 // [[name, matcher: ], ...]
    • 返回 addRoute, resolve, removeRoute, getRoutes, getRecordMatcher 等方法,用于辅助 $router 动态更新路由 官方文档
  • addRoute 与 routes.children 递归调用,为每个一个 route 都保存了 RouteRecordMatcher 对象,[[name, matcher: RouteRecordMatcher], ...]

    ini 复制代码
    // add initial routes
    routes.forEach(route => addRoute(route))
    • 内部与 route.children 搭配递归调用。这一部分添加的 parent 是嵌套路由组件的依据
    css 复制代码
    for (let i = 0; i < children.length; i++) {
      addRoute(
        children[i],
        matcher,
        originalRecord && originalRecord.children[i]
      )
    }
  • 重点matcher.resolve(location , currentLocation): MatcherLocation

    • 返回 toLocation 的 MatcherLocation 数据,里面最重要的是 matched 属性

    • matched 是一个先进先出的队列 ,通过 unshift 保存着 toLocation matcher 的 record: RouteRecordNormalized 数据,以及它所有祖先组件 location matcher 的 record

      • 如果 location 是一个嵌套路由,则 matched 匹配所有祖先组件的 matcher,下标越大,嵌套层级越深
      • 如果 location 不是嵌套路由,matched 只有一个元素,即当前路由
  • routerHistory.listen、pushWithRedirect 都有调用到 matcher.resolve 生成 toLocation,赋值给 currentLocation

    • 后面会重新介绍 routerHistory.listen、pushWithRedirect 这 2 个方法
  • router view 使用 matched 匹配路由组件

    • 前面已经介绍了 matched 保存的是一个嵌套路由组件的 matcher 数组。默认的是非嵌套路由组件,且 components 肯定非空,所以 viewDepthKey = 1

    • 如果是嵌套路由,则所有祖先组件以及 currentRoute 会一起渲染形成完整页面。每个祖先组件内部都有 <RouterView /> 组件用于生成下一层级路由组件。以下源码可知,每一个嵌套路由都 depth.value + 1,从而计算出最后的 currentRoute 路由深度

      • 嵌套路由的 components 可以为空,显示子路由组件内容。这时候会进入 while 循环,循环内部的 initialDepth++ 表明 empty component 也算进嵌套路由深度里面
      typescript 复制代码
      const injectedDepth = inject(viewDepthKey, 0)
      // The depth changes based on empty components option, which allows passthrough routes e.g. routes with children
      // that are used to reuse the `path` property
      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)
      )

router view 渲染 URL 对应的组件

文件位置:src/RuterView.ts

export 的 interface

  • RouterViewProps // API 文档
  • RouterViewDevtoolsContext // 开发者工具
  • RouterViewImpl // 只在 RouterView 内部有使用
  • RouterView // 渲染当前路由

RouterView RouterView = RouterViewImpl as unknown as {...}

  • 返回 vnode

    php 复制代码
      return (
        // pass the vnode to the slot as a prop.
        // h and <component :is="..."> both accept vnodes
        normalizeSlot(slots.default, { Component: component, route }) ||
        component
      )
  • 通过 matchedRoute.components 匹配当前路由

    • 没有找到就使用 slots.default 来渲染
    php 复制代码
      const ViewComponent =
        matchedRoute && matchedRoute.components![currentName]
    
      if (!ViewComponent) {
        return normalizeSlot(slots.default, { Component: ViewComponent, route })
      }

历史条目记录监听

文件位置:src/history/html5.ts

  • routerHistory.listen

    • 本质是一个发布/订阅模式。通过 listen 收集回调函数保存到 listeners: NavigationCallback[] 里,后续发布模式下执行
    • 返回 teardown 用于卸载 listener,即上文提及的 removeHistoryListener
    scss 复制代码
        // set up the listener and prepare teardown callbacks
        listeners.push(callback)
    
        const teardown = () => {
          const index = listeners.indexOf(callback)
          if (index > -1) listeners.splice(index, 1)
        }
    
        teardowns.push(teardown)
        return teardown
  • 通过 popstate 监听 URL 变化,执行相应 handler

    • window.addEventListener('popstate', popStateHandler)
    • 在 popStateHandler 事件监听中,循环执行已收集的 listeners
    php 复制代码
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,
        direction: delta
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      })
    })

更新组件

文件位置:src/router.ts

2 种途径更新组件

  • 通过事件监听更新路由组件

    • routerHistory.listen 事件监听中调用。上面提及到 listen 会收集 callback,在 popstate 事件中遍历执行 listeners
    • listen 内部执行 pushWithRedirect 重定向以及 navigate(toLocation, from) 导航等操作
  • 通过 $router.push$router.replace 方法主动更新路由

    • 内部也是通过 pushWithRedirect 调用。

以上方法最后都会调用 finalizeNavigation 更新 currentRoute

  • 内部通过 routerHistory 的 push、replace 更新历史条目,也就是 history.pushStatehistory.replaceState 更新历史条目

    scss 复制代码
    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
    currentRoute.value = toLocation
相关推荐
程序猿阿越6 天前
Kafka源码(六)消费者消费
java·后端·源码阅读
zh_xuan12 天前
Android android.util.LruCache源码阅读
android·源码阅读·lrucache
魏思凡15 天前
爆肝一万多字,我准备了寿司 kotlin 协程原理
kotlin·源码阅读
白鲸开源19 天前
一文掌握 Apache SeaTunnel 构建系统与分发基础架构
大数据·开源·源码阅读
Tans51 个月前
Androidx Fragment 源码阅读笔记(下)
android jetpack·源码阅读
Tans51 个月前
Androidx Fragment 源码阅读笔记(上)
android jetpack·源码阅读
Tans51 个月前
Androidx Lifecycle 源码阅读笔记
android·android jetpack·源码阅读
凡小烦1 个月前
LeakCanary源码解析
源码阅读·leakcanary
程序猿阿越1 个月前
Kafka源码(四)发送消息-服务端
java·后端·源码阅读
CYRUS_STUDIO2 个月前
Android 源码如何导入 Android Studio?踩坑与解决方案详解
android·android studio·源码阅读