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
相关推荐
callmeSoon4 天前
Vue2 模板编译三部曲(三)|生成器 Generator
vue.js·源码阅读
Tans55 天前
Java ReentrantLock 源码阅读笔记(上)
java·源码阅读
程序猿阿越7 天前
ChaosBlade源码(一)blade命令行
java·后端·源码阅读
码农明明15 天前
Android源码分析:从源头分析View事件的传递
android·操作系统·源码阅读
欧阳码农2 个月前
vue3的defineAsyncComponent是如何实现异步组件的呢?
vue.js·源码·源码阅读
鳄鱼不怕_牙医不怕2 个月前
Flutter 源码梳理系列(三十七):OffsetLayer
flutter·源码阅读
鳄鱼不怕_牙医不怕2 个月前
Flutter 源码梳理系列(三十六):RenderObject:PAINTING
flutter·源码阅读
鳄鱼不怕_牙医不怕2 个月前
Flutter 源码梳理系列(三十四):ContainerLayer
flutter·源码阅读
鳄鱼不怕_牙医不怕2 个月前
Flutter 源码梳理系列(三十一):PaintingContext
flutter·源码阅读
callmeSoon2 个月前
Vue2 模板编译三部曲(一)|架构设计 & 解析器 Parser
vue.js·源码阅读