Vitepress 原理
什么是 Vitepress?
VitePress 是一个静态站点生成器 (SSG),专为构建快速、以内容为中心的站点而设计。简而言之,VitePress 获取用 Markdown 编写的内容,对其应用主题,并生成可以轻松部署到任何地方的静态 HTML 页面。
原理
安装
::: code-tabs#shell
@tab npm
$ npm add -D vitepress
@tab pnpm
$ pnpm add -D vitepress
@tab yarn
$ yarn add -D vitepress
@tab yarn(pnp)
$ yarn add -D vitepress vue
@tab bun
$ bun add -D vitepress
:::
安装向导
::: code-tabs#shell
@tab npm
$ npx vitepress init
@tab pnpm
$ pnpm vitepress init
@tab yarn
$ yarn vitepress init
@tab bun
$ bun vitepress init
:::
启动
在 Vitepress 项目中,通过执行以下命令启动项目:
viepress dev
执行完命令后,稍作等待便可在浏览器中访问了。
启动服务主要有两步:
- 创建 Vite 服务
- 执行 Vite 插件
创建 Vite 服务
// src/node/server.ts
import { createServer as createViteServer, type ServerOptions } from "vite";
import { resolveConfig } from "./config";
import { createVitePressPlugin } from "./plugin";
export async function createServer(
root: string = process.cwd(),
serverOptions: ServerOptions & { base?: string } = {},
recreateServer?: () => Promise<void>,
) {
const config = await resolveConfig(root);
if (serverOptions.base) {
config.site.base = serverOptions.base;
delete serverOptions.base;
}
return createViteServer({
root: config.srcDir,
base: config.site.base,
cacheDir: config.cacheDir,
plugins: await createVitePressPlugin(config, false, {}, {}, recreateServer),
server: serverOptions,
customLogger: config.logger,
configFile: config.vite?.configFile,
});
}
上述代码创建并启动了一个 Vite 服务: 首先,通过调用 resolveConfig
方法,读取用户的 Vitepress 配置并合并为一个 config
对象 (配置路径为:.vitepress/config.mts
),再将部分配置传入 createViteServer
方法中,创建并启动 Vite 服务。
执行 Vite 插件
正常来说,Vite 需要一个 HTML 作为入口文件,但 Vitepress 并没有一个 HTML 文件,其实这部分工作由 Vite 插件来完成, 在上面代码中,通过调用 createVitePressPlugin
方法,创建并执行 Vite 插件。
// src/node/server.ts
return createViteServer({
// ...
plugins: await createVitePressPlugin(config, false, {}, {}, recreateServer),
// ...
});
createVitePressPlugin
返回了一个插件列表,其中包含了一个名为 vitePressPlugin
的插件:
// src/node/plugin.ts
const vitePressPlugin: Plugin = {
name: "vitepress",
// ...
configureServer(server) {
// ...
return () => {
server.middlewares.use(async (req, res, next) => {
const url = req.url && cleanUrl(req.url);
if (url?.endsWith(".html")) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/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>`;
html = await server.transformIndexHtml(url, html, req.originalUrl);
res.end(html);
return;
}
next();
});
};
},
// ...
};
插件中定义了一个 configureServer
生命周期,并在其中返回了一个 HTML 文件,座位 Vite 服务的入口文件, 当我们访问服务时,浏览器渲染网页,执行 HTML 中引入的 Script 文件,服务启动。
文档渲染
创建路由
Vitepress 没有使用 Vue Router 来管理路由,而是自己实现了一个简单的路由模块(拍桌子.png): 首先通过监听 window 的点击事件,当用户点击 <a />
标签时,执行跳转函数 go
// src/client/app/router.ts
async function go(href: string = inBrowser ? location.href : "/") {
href = normalizeHref(href);
if ((await router.onBeforeRouteChange?.(href)) === false) return;
if (inBrowser && href !== normalizeHref(location.href)) {
// save scroll position before changing url
history.replaceState({ scrollPosition: window.scrollY }, "");
history.pushState({}, "", href);
}
await loadPage(href);
await router.onAfterRouteChanged?.(href);
}
先调用 history.replaceState
,将当前页面的位置信息 scrollY
保存到 history state
中, 再调用 history.pushState
,更新 url,最后调用 loadPage
加载 url 对应的页面。
同时监听 popstate
事件,当用户使用浏览器前进、返回等操作时,调用 loadPage
方法,加载 url 对应的 markdown 文件,并根据 history state
中保存的页面位置信息进行定位:
// src/client/app/router.ts
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);
});
提示
loadPage 具体内容可以在源码中查看。
创建 Vue 应用
// src/client/app/index.ts
import {
createApp as createClientApp,
createSSRApp,
defineComponent,
h,
onMounted,
watchEffect,
type App,
} from "vue";
// ...
function newApp(): App {
return import.meta.env.PROD
? createSSRApp(VitePressApp)
: createClientApp(VitePressApp);
}
// ...
export async function createApp() {
//...
const app = newApp();
// ...
return { app, router, data };
}
通过执行 createClientApp(VitePressApp)
创建 Vue 应用, VitePressApp
是默认主题的 Layout
组件
// src/client/app/index.ts
const VitePressApp = defineComponent({
name: "VitePressApp",
setup() {
// ...
return () => h(Theme.Layout!);
},
});
再将上面路由对象注册到 Vue 应用中,并注册两个全局组件 Content
和 ClientOnly
// src/client/app/index.ts
export async function createApp() {
// ...
// 注入路由
app.provide(RouterSymbol, router);
const data = initData(router.route);
app.provide(dataSymbol, data);
// 注册全局组件
app.component("Content", Content);
app.component("ClientOnly", ClientOnly);
// ...
}
Markdown 渲染
在启动服务的过程中,有一个vitePressPlugin
插件,Markdown 渲染工作就是在这个插件中的 transform
中实现的
// src/node/plugin.ts
async transform(code, id) {
if (id.endsWith('.vue')) {
return processClientJS(code, id)
} else if (id.endsWith('.md')) {
// transform .md files into vueSrc so plugin-vue can handle it
const { vueSrc, deadLinks, includes } = await markdownToVue(
code,
id,
config.publicDir
)
allDeadLinks.push(...deadLinks)
if (includes.length) {
includes.forEach((i) => {
;(importerMap[slash(i)] ??= new Set()).add(id)
this.addWatchFile(i)
})
}
return processClientJS(vueSrc, id)
}
}
当使用 import
加载 .md
文件时,就会调用 transform
对文件内容进行转换,执行 markdownToVue
, 将 Markdown 内容转换为 Vue SFC
,再通过 @vitejs/plugin-vue
插件将 Vue 组件渲染到页面。
// src/node/markdownToVue.ts
const html = md.render(src, env);
// ...
const vueSrc = [
...injectPageDataCode(
sfcBlocks?.scripts.map((item) => item.content) ?? [],
pageData,
),
`<template><div>${html}</div></template>`,
...(sfcBlocks?.styles.map((item) => item.content) ?? []),
...(sfcBlocks?.customBlocks.map((item) => item.content) ?? []),
].join("\n");
这里的 md
是一个 markdown-it
对象,通过调用 md.render
函数,将 markdown 内容转成 HTML 格式, 转换后的内容会通过全局组件 Content
渲染。
提示
如果再 markdown 中编写 Vue 语法,由于不是 markdown 语法,所以 markdown-it
是不会进行转换。
总结
Vitepress 是一个非常优秀的文档构建工具,具体源码可在GitHub上查看
希望 Vitepress 生态可以快速完善起来🤪🤪🤪