vitePress实现原理(五)路由基本实现

在之前vitepress实现原理(三)构建vite插件createVitePressPlugin函数的configureServer中提到了,当Vite启动开发服务器时调用时,对一切.html请求会返回如下的结构

let html = `<!DOCTYPE html>
<html>
  <head>
    <title></title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <meta name="description" content="">
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/@fs/${APP_PATH}/index.js"></script>
  </body>
</html>` 

那么显而易见:/@fs/${APP_PATH}/index.js就是入口文件(类似vue项目内的main.ts),根据之前代码分析得出APP_PATH就是项目目录/node_module/vitepress/client/app,也就是源码位置当中src/client/app目录。

三.Vue项目构建分析

源码位置:src/client/app/index.js

import RawTheme from '@theme/index'
import {
  createApp as createClientApp,
  createSSRApp,
  defineComponent,
  h,
  onMounted,
  watchEffect,
  type App
} from 'vue'
import { ClientOnly } from './components/ClientOnly'
import { Content } from './components/Content'
import { useCodeGroups } from './composables/codeGroups'
import { useCopyCode } from './composables/copyCode'
import { useUpdateHead } from './composables/head'
import { usePrefetch } from './composables/preFetch'
import { dataSymbol, initData, siteDataRef, useData } from './data'
import { RouterSymbol, createRouter, scrollTo, type Router } from './router'
import { inBrowser, pathToFile } from './utils'

// 解析主题的继承关系
function resolveThemeExtends(theme: typeof RawTheme): typeof RawTheme {
  if (theme.extends) {
    const base = resolveThemeExtends(theme.extends)
    return {
      ...base,
      ...theme,
      async enhanceApp(ctx) {
        if (base.enhanceApp) await base.enhanceApp(ctx)
        if (theme.enhanceApp) await theme.enhanceApp(ctx)
      }
    }
  }
  return theme
}

const Theme = resolveThemeExtends(RawTheme)

// 定义 VitePress 应用组件
const VitePressApp = defineComponent({
  name: 'VitePressApp',
  setup() {
    const { site, lang, dir } = useData()

    // 根据当前语言设置 HTML 元素的语言属性
    onMounted(() => {
      watchEffect(() => {
        document.documentElement.lang = lang.value
        document.documentElement.dir = dir.value
      })
    })

    // 生产模式下启用预取链接
    if (import.meta.env.PROD && site.value.router.prefetchLinks) {
      usePrefetch()
    }

    // 设置全局复制代码功能
    useCopyCode()
    // 设置全局代码组功能
    useCodeGroups()

    // 调用主题的 setup 方法进行进一步初始化
    if (Theme.setup) Theme.setup()

    // 返回主题的 Layout 组件
    return () => h(Theme.Layout!)
  }
})

// 创建并配置 Vue 应用实例
export async function createApp() {
  ;(globalThis as any).__VITEPRESS__ = true

  // 创建路由器实例
  const router = newRouter()
  // 创建 Vue 应用实例
  const app = newApp()
  // 提供路由器实例给应用
  app.provide(RouterSymbol, router)
  // 初始化数据并提供给应用
  const data = initData(router.route)
  app.provide(dataSymbol, data)

  // 注册全局组件
  app.component('Content', Content)
  app.component('ClientOnly', ClientOnly)

  // 暴露全局属性 $frontmatter 和 $params
  Object.defineProperties(app.config.globalProperties, {
    $frontmatter: {
      get() {
        return data.frontmatter.value
      }
    },
    $params: {
      get() {
        return data.page.value.params
      }
    }
  })

  // 调用主题的 enhanceApp 方法进行增强
  if (Theme.enhanceApp) {
    await Theme.enhanceApp({
      app,
      router,
      siteData: siteDataRef
    })
  }

  // 在开发模式下集成 Vue Devtools
  if (import.meta.env.DEV || __VUE_PROD_DEVTOOLS__) {
    import('./devtools.js').then(({ setupDevtools }) =>
      setupDevtools(app, router, data)
    )
  }

  return { app, router, data }
}

// 根据环境选择创建客户端或服务端渲染的应用实例
function newApp(): App {
  return import.meta.env.PROD ? createSSRApp(VitePressApp) : createClientApp(VitePressApp)
}

// 创建路由器实例
function newRouter(): Router {
  let isInitialPageLoad = inBrowser
  let initialPath: string

  return createRouter((path) => {
    let pageFilePath = pathToFile(path)
    let pageModule = null

    if (pageFilePath) {
      if (isInitialPageLoad) {
        initialPath = pageFilePath
      }

      // 使用 lean build 处理初始页面加载或返回到初始路径的情况
      if (isInitialPageLoad || initialPath === pageFilePath) {
        pageFilePath = pageFilePath.replace(/\.js$/, '.lean.js')
      }

      // 开发环境下处理路径问题
      if (import.meta.env.DEV) {
        pageModule = import(/*@vite-ignore*/ pageFilePath).catch(() => {
          const url = new URL(pageFilePath!, 'http://a.com')
          const path =
            (url.pathname.endsWith('/index.md')
              ? url.pathname.slice(0, -9) + '.md'
              : url.pathname.slice(0, -3) + '/index.md') +
            url.search +
            url.hash
          return import(/*@vite-ignore*/ path)
        })
      } else {
        pageModule = import(/*@vite-ignore*/ pageFilePath)
      }
    }

    if (inBrowser) {
      isInitialPageLoad = false
    }

    return pageModule
  }, Theme.NotFound)
}

// 浏览器环境下启动应用
if (inBrowser) {
  createApp().then(({ app, router, data }) => {
    // 等待页面组件加载完成后再挂载应用
    router.go().then(() => {
      // 动态更新头部标签
      useUpdateHead(router.route, data.site)
      app.mount('#app')

      // 在开发模式下滚动到锚点位置
      if (import.meta.env.DEV && location.hash) {
        const target = document.getElementById(decodeURIComponent(location.hash).slice(1))
        if (target) {
          scrollTo(target, location.hash)
        }
      }
    })
  })
}

主要功能
  • 应用创建:
    • 创建并配置一个 Vue 应用实例,适用于客户端和服务器端渲染(SSR)。
    • 初始化路由、数据和主题。
  • 路径解析与路由:
    • 使用自定义的 createRouter 函数来处理路由逻辑。
    • 支持预取链接以优化性能。
  • 全局组件注册:
    • 注册全局组件 ContentClientOnly,便于在整个应用中使用。
  • 数据提供:
    • 提供全局的数据访问接口 $frontmatter$params,方便在组件中获取页面数据。
  • 主题扩展:
    • 支持主题的继承和增强,允许用户自定义主题并添加额外的功能。
  • 开发工具集成:
    • 在开发模式下集成 Vue Devtools,以便更好地调试应用。
  • 初始化逻辑:
    • 在应用挂载前执行一些初始化操作,如更新头部信息、滚动到锚点位置等。
详细解释
  1. 主题解析:
    • resolveThemeExtends: 递归地解析主题的继承关系,确保子主题可以继承父主题的所有属性和方法,并且可以在 enhanceApp 方法中按顺序调用。
  2. 应用组件:
    • VitePressApp: 定义了一个名为 VitePressApp 的 Vue 组件,负责设置语言方向、启用预取、复制代码功能和代码组功能。此外,还调用了主题的 setup 方法进行进一步的初始化。
  3. 应用创建函数:
    • createApp: 创建并配置 Vue 应用实例。
      • 设置全局变量 __VITEPRESS__ 表示这是一个 VitePress 应用。
      • 创建路由器实例并将其提供给应用。
      • 初始化数据并将其提供给应用。
      • 注册全局组件 ContentClientOnly
      • 暴露全局属性 $frontmatter$params
      • 调用主题的 enhanceApp 方法进行增强。
      • 在开发模式下集成 Vue Devtools。
  4. 应用实例创建:
    • newApp: 根据环境选择创建客户端或服务端渲染的应用实例。
  5. 路由创建:
    • newRouter: 创建路由器实例,处理路径转换为文件路径,并支持预取链接以优化性能。
  6. 浏览器环境下的应用启动:
    • 如果当前环境是浏览器,则调用 createApp 创建应用实例,并在路由导航完成后挂载应用到 DOM 上。
    • 更新头部信息,并在开发模式下滚动到锚点位置。

(一)createRouter函数

源码位置:src/client/app/router.ts

import type { Component, InjectionKey } from 'vue' // 导入 Vue 组件和注入键类型
import { inject, markRaw, nextTick, reactive, readonly } from 'vue' // 导入 Vue 的相关函数
import type { Awaitable, PageData, PageDataPayload } from '../shared' // 导入共享类型
import { notFoundPageData, treatAsHtml } from '../shared' // 导入默认页面数据和处理 HTML 类型的函数
import { siteDataRef } from './data' // 导入站点数据引用
import { getScrollOffset, inBrowser, withBase } from './utils' // 导入工具函数

// 定义路由接口
export interface Route {
  path: string // 路径字符串
  data: PageData // 页面数据对象
  component: Component | null // 页面组件或空值
}

// 定义路由器接口
export interface Router {
  route: Route // 当前路由对象
  go: (to?: string) => Promise<void> // 导航到新 URL 的方法
  onBeforeRouteChange?: (to: string) => Awaitable<void | boolean> // 在路由更改之前调用的钩子
  onBeforePageLoad?: (to: string) => Awaitable<void | boolean> // 在页面组件加载之前调用的钩子
  onAfterPageLoad?: (to: string) => Awaitable<void> // 在页面组件加载之后调用的钩子
  onAfterRouteChanged?: (to: string) => Awaitable<void> // 在路由更改之后调用的钩子
}

// 定义路由器的注入键
export const RouterSymbol: InjectionKey<Router> = Symbol()

// 使用假主机解析路径和哈希
const fakeHost = 'http://a.com'

// 获取默认路由
const getDefaultRoute = (): Route => ({
  path: '/', // 默认路径为根路径
  component: null, // 默认组件为空
  data: notFoundPageData // 默认数据为未找到页面的数据
})

// 定义页面模块接口
interface PageModule {
  __pageData: PageData // 页面数据
  default: Component // 默认导出的组件
}

// 创建路由器函数
export function createRouter(
  loadPageModule: (path: string) => Awaitable<PageModule | null>, // 加载页面模块的函数
  fallbackComponent?: Component // 回退组件(可选)
): Router {
  // 创建响应式路由对象
  const route = reactive(getDefaultRoute())

  // 初始化路由器对象
  const router: Router = {
    route,
    go
  }

  // 导航到新 URL 的方法
  async function go(href: string = inBrowser ? location.href : '/') {
    href = normalizeHref(href) // 标准化 URL
    if ((await router.onBeforeRouteChange?.(href)) === false) return // 在路由更改之前调用钩子,如果返回 false 则取消导航
    if (inBrowser && href !== normalizeHref(location.href)) {
      // 保存滚动位置并在更改 URL 前替换当前历史状态
      history.replaceState({ scrollPosition: window.scrollY }, '')
      history.pushState({}, '', href)
    }
    await loadPage(href) // 加载页面
    await router.onAfterRouteChanged?.(href) // 在路由更改之后调用钩子
  }

  let latestPendingPath: string | null = null // 最新的待处理路径

  // 加载页面的方法
  async function loadPage(href: string, scrollPosition = 0, isRetry = false) {
    if ((await router.onBeforePageLoad?.(href)) === false) return // 在页面组件加载之前调用钩子,如果返回 false 则取消导航
    const targetLoc = new URL(href, fakeHost) // 解析目标 URL
    const pendingPath = (latestPendingPath = targetLoc.pathname) // 设置最新的待处理路径
    try {
      let page = await loadPageModule(pendingPath) // 加载页面模块
      if (!page) {
        throw new Error(`Page not found: ${pendingPath}`) // 如果页面不存在则抛出错误
      }
      if (latestPendingPath === pendingPath) {
        latestPendingPath = null // 清除最新的待处理路径

        const { default: comp, __pageData } = page // 解构页面模块
        if (!comp) {
          throw new Error(`Invalid route component: ${comp}`) // 如果组件无效则抛出错误
        }

        await router.onAfterPageLoad?.(href) // 在页面组件加载之后调用钩子

        route.path = inBrowser ? pendingPath : withBase(pendingPath) // 更新路由路径
        route.component = markRaw(comp) // 设置路由组件
        route.data = import.meta.env.PROD
          ? markRaw(__pageData)
          : (readonly(__pageData) as PageData) // 设置路由数据

        if (inBrowser) {
          nextTick(() => {
            let actualPathname =
              siteDataRef.value.base +
              __pageData.relativePath.replace(/(?:(^|\/)index)?\.md$/, '$1') // 计算实际路径名
            if (!siteDataRef.value.cleanUrls && !actualPathname.endsWith('/')) {
              actualPathname += '.html' // 添加 .html 后缀
            }
            if (actualPathname !== targetLoc.pathname) {
              targetLoc.pathname = actualPathname // 更新目标路径名
              href = actualPathname + targetLoc.search + targetLoc.hash // 更新 href
              history.replaceState({}, '', href) // 替换历史状态
            }

            if (targetLoc.hash && !scrollPosition) {
              let target: HTMLElement | null = null
              try {
                target = document.getElementById(
                  decodeURIComponent(targetLoc.hash).slice(1)
                ) // 获取目标元素
              } catch (e) {
                console.warn(e) // 捕获并警告异常
              }
              if (target) {
                scrollTo(target, targetLoc.hash) // 滚动到目标元素
                return
              }
            }
            window.scrollTo(0, scrollPosition) // 滚动到指定位置
          })
        }
      }
    } catch (err: any) {
      if (
        !/fetch|Page not found/.test(err.message) &&
        !/^\/404(\.html|\/)?$/.test(href)
      ) {
        console.error(err) // 捕获并记录其他错误
      }

      // 失败后重试:页面到哈希映射可能已被无效化
      if (!isRetry) {
        try {
          const res = await fetch(siteDataRef.value.base + 'hashmap.json') // 获取哈希映射
          ; (window as any).__VP_HASH_MAP__ = await res.json() // 更新哈希映射
          await loadPage(href, scrollPosition, true) // 重新加载页面
          return
        } catch (e) {}
      }

      if (latestPendingPath === pendingPath) {
        latestPendingPath = null // 清除最新的待处理路径
        route.path = inBrowser ? pendingPath : withBase(pendingPath) // 更新路由路径
        route.component = fallbackComponent ? markRaw(fallbackComponent) : null // 设置回退组件
        const relativePath = inBrowser
          ? pendingPath
            .replace(/(^|\/)$/, '$1index')
            .replace(/(\.html)?$/, '.md')
            .replace(/^\//, '')
          : '404.md' // 计算相对路径
        route.data = { ...notFoundPageData, relativePath } // 设置未找到页面的数据
      }
    }
  }

  if (inBrowser) {
    if (history.state === null) {
      history.replaceState({}, '') // 初始化历史状态
    }

    // 监听点击事件以拦截内部链接
    window.addEventListener(
      'click',
      (e) => {
        if (
          e.defaultPrevented ||
          !(e.target instanceof Element) ||
          e.target.closest('button') || // 临时修复 docsearch 操作按钮
          e.button !== 0 ||
          e.ctrlKey ||
          e.shiftKey ||
          e.altKey ||
          e.metaKey
        )
          return // 忽略非左键点击或其他修饰键

        const link = e.target.closest<HTMLAnchorElement | SVGAElement>('a') // 查找最近的 a 或 svg:a 元素
        if (
          !link ||
          link.closest('.vp-raw') ||
          link.hasAttribute('download') ||
          link.hasAttribute('target')
        )
          return // 忽略带有特定属性的链接

        const linkHref =
          link.getAttribute('href') ??
          (link instanceof SVGAElement ? link.getAttribute('xlink:href') : null) // 获取链接的 href 属性
        if (linkHref == null) return // 忽略没有 href 属性的链接

        const { href, origin, pathname, hash, search } = new URL(
          linkHref,
          link.baseURI
        ) // 解析链接的 URL
        const currentUrl = new URL(location.href) // 复制当前 URL 以便保留旧数据
        // 仅拦截入站 HTML 链接
        if (origin === currentUrl.origin && treatAsHtml(pathname)) {
          e.preventDefault() // 阻止默认行为
          if (
            pathname === currentUrl.pathname &&
            search === currentUrl.search
          ) {
            // 同一页面中的哈希锚点之间滚动
            // 避免哈希相同时重复的历史条目
            if (hash !== currentUrl.hash) {
              history.pushState({}, '', href) // 推送新的历史状态
              // 触发 hashchange 事件以便主题监听
              window.dispatchEvent(
                new HashChangeEvent('hashchange', {
                  oldURL: currentUrl.href,
                  newURL: href
                })
              )
            }
            if (hash) {
              // 平滑滚动到右侧锚点
              scrollTo(link, hash, link.classList.contains('header-anchor'))
            } else {
              window.scrollTo(0, 0) // 滚动到顶部
            }
          } else {
            go(href) // 导航到新的 URL
          }
        }
      },
      { capture: true }
    )

    // 监听 popstate 事件以处理浏览器前进后退
    window.addEventListener('popstate', async (e) => {
      if (e.state === null) {
        return // 忽略没有状态的对象
      }
      await loadPage(
        normalizeHref(location.href),
        (e.state && e.state.scrollPosition) || 0
      ) // 加载页面并恢复滚动位置
      router.onAfterRouteChanged?.(location.href) // 在路由更改之后调用钩子
    })

    // 阻止默认的 hashchange 行为
    window.addEventListener('hashchange', (e) => {
      e.preventDefault() // 阻止默认行为
    })
  }

  handleHMR(route) // 处理热模块替换

  return router // 返回路由器实例
}

// 注入路由器
export function useRouter(): Router {
  const router = inject(RouterSymbol)
  if (!router) {
    throw new Error('useRouter() is called without provider.') // 抛出错误
  }
  return router // 返回路由器实例
}

// 获取当前路由
export function useRoute(): Route {
  return useRouter().route // 返回当前路由对象
}

// 滚动到指定元素
export function scrollTo(el: Element, hash: string, smooth = false) {
  let target: Element | null = null

  try {
    target = el.classList.contains('header-anchor')
      ? el
      : document.getElementById(decodeURIComponent(hash).slice(1)) // 获取目标元素
  } catch (e) {
    console.warn(e) // 捕获并警告异常
  }

  if (target) {
    const targetPadding = parseInt(
      window.getComputedStyle(target).paddingTop,
      10
    ) // 获取目标元素的上内边距
    const targetTop =
      window.scrollY +
      target.getBoundingClientRect().top -
      getScrollOffset() +
      targetPadding // 计算目标元素的顶部位置
    function scrollToTarget() {
      if (!smooth || Math.abs(targetTop - window.scrollY) > window.innerHeight)
        window.scrollTo(0, targetTop) // 如果不需要平滑滚动或距离过大,则直接滚动
      else window.scrollTo({ left: 0, top: targetTop, behavior: 'smooth' }) // 否则使用平滑滚动
    }
    requestAnimationFrame(scrollToTarget) // 请求下一帧动画执行滚动操作
  }
}

// 处理热模块替换
function handleHMR(route: Route): void {
  if (import.meta.hot) {
    import.meta.hot.on('vitepress:pageData', (payload: PageDataPayload) => {
      if (shouldHotReload(payload)) {
        route.data = payload.pageData // 更新路由数据
      }
    })
  }
}

// 判断是否需要热重载页面数据
function shouldHotReload(payload: PageDataPayload): boolean {
  const payloadPath = payload.path.replace(/(?:(^|\/)index)?\.md$/, '$1') // 处理路径
  const locationPath = location.pathname
    .replace(/(?:(^|\/)index)?\.html$/, '')
    .slice(siteDataRef.value.base.length - 1) // 处理当前路径
  return payloadPath === locationPath // 比较路径是否相同
}

// 标准化 URL
function normalizeHref(href: string): string {
  const url = new URL(href, fakeHost) // 解析 URL
  url.pathname = url.pathname.replace(/(^|\/)index(\.html)?$/, '$1') // 处理路径
  // 确保正确的深层链接,使得页面刷新时能正确加载文件。
  if (siteDataRef.value.cleanUrls)
    url.pathname = url.pathname.replace(/\.html$/, '') // 移除 .html 后缀
  else if (!url.pathname.endsWith('/') && !url.pathname.endsWith('.html'))
    url.pathname += '.html' // 添加 .html 后缀
  return url.pathname + url.search + url.hash // 返回标准化后的 URL
}

  • 路由管理:
    • 创建并管理应用的路由状态。
    • 支持导航、页面加载和路由变化的生命周期钩子。
  • 页面加载:
    • 根据路径加载页面组件和数据。
    • 处理页面未找到的情况,并提供回退组件。
  • 事件监听:
    • 监听点击事件以拦截内部链接并进行导航。
    • 监听 popstatehashchange 事件以处理浏览器前进后退和哈希变化。
  • 滚动行为:
    • 根据哈希值滚动到页面中的特定元素。
    • 支持平滑滚动效果。
  • 热模块替换:
    • 在开发模式下支持热更新页面数据。
VitePress 是一个由 Vite 构建的静态站点生成器,它结合了 Vue.js 的组件化思想和 Markdown 的易用性。在 VitePress实现动态路由主要涉及到配置 `vite.config.js` 文件中的 `pages` 和 `router` 部分。 动态路由实现通常涉及以下几个步骤: 1. **创建动态路径**: 在 `pages` 数组中,你可以定义一个对象,其路径是一个动态字符串,例如: ```javascript const dynamicRoute = { path: '/posts/:slug', // 动态部分是 :slug,可以匹配任何 URL 部分 component: () => import('./Post.vue'), // 导入处理该路由的组件 }; ``` 2. **访问动态参数**: 在导入的组件内部,你可以通过 `this.$route.params.slug` 来访问动态参数(在这种情况下是 `slug`)。 3. **设置路由守卫**: 如果你需要在路由切换前或后执行某些操作,可以使用全局或局部的路由守卫(如 `beforeEach` 和 `afterEach`)。 4. **重定向和命名路由**: 可以根据需要定义重定向,或者为路由分配别名,以便于管理。 5. **预渲染(SSR)支持**: 虽然 VitePress 主要是用于静态站点生成,但如果你需要服务器端渲染,可以通过配置 `vite.config.js` 中的 `generate` 配置来实现部分动态路由的 SSR。 相关问题: 1. 如何在 VitePress 中处理多个动态参数? 2. 怎么在 VitePress路由守卫中获取动态参数? 3. 是否可以在 VitePress实现基于角色的权限控制?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值