在之前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
函数来处理路由逻辑。 - 支持预取链接以优化性能。
- 使用自定义的
- 全局组件注册:
- 注册全局组件
Content
和ClientOnly
,便于在整个应用中使用。
- 注册全局组件
- 数据提供:
- 提供全局的数据访问接口
$frontmatter
和$params
,方便在组件中获取页面数据。
- 提供全局的数据访问接口
- 主题扩展:
- 支持主题的继承和增强,允许用户自定义主题并添加额外的功能。
- 开发工具集成:
- 在开发模式下集成 Vue Devtools,以便更好地调试应用。
- 初始化逻辑:
- 在应用挂载前执行一些初始化操作,如更新头部信息、滚动到锚点位置等。
详细解释
- 主题解析:
resolveThemeExtends
: 递归地解析主题的继承关系,确保子主题可以继承父主题的所有属性和方法,并且可以在enhanceApp
方法中按顺序调用。
- 应用组件:
VitePressApp
: 定义了一个名为VitePressApp
的 Vue 组件,负责设置语言方向、启用预取、复制代码功能和代码组功能。此外,还调用了主题的setup
方法进行进一步的初始化。
- 应用创建函数:
createApp
: 创建并配置 Vue 应用实例。- 设置全局变量
__VITEPRESS__
表示这是一个 VitePress 应用。 - 创建路由器实例并将其提供给应用。
- 初始化数据并将其提供给应用。
- 注册全局组件
Content
和ClientOnly
。 - 暴露全局属性
$frontmatter
和$params
。 - 调用主题的
enhanceApp
方法进行增强。 - 在开发模式下集成 Vue Devtools。
- 设置全局变量
- 应用实例创建:
newApp
: 根据环境选择创建客户端或服务端渲染的应用实例。
- 路由创建:
newRouter
: 创建路由器实例,处理路径转换为文件路径,并支持预取链接以优化性能。
- 浏览器环境下的应用启动:
- 如果当前环境是浏览器,则调用
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
}
- 路由管理:
- 创建并管理应用的路由状态。
- 支持导航、页面加载和路由变化的生命周期钩子。
- 页面加载:
- 根据路径加载页面组件和数据。
- 处理页面未找到的情况,并提供回退组件。
- 事件监听:
- 监听点击事件以拦截内部链接并进行导航。
- 监听
popstate
和hashchange
事件以处理浏览器前进后退和哈希变化。
- 滚动行为:
- 根据哈希值滚动到页面中的特定元素。
- 支持平滑滚动效果。
- 热模块替换:
- 在开发模式下支持热更新页面数据。