「手搓系列 01」 从零搭建 Vue 文档站,学习从静态生成到语法解析
最近在实习,好久不更新了,休假回校之前,准备开一个新的合集 「手搓系列」,我们从头实现一些习以为常的前端轮子/技术栈/工具链,用手写的方式学习它的思路,同时得到些新的思考。
手搓的故事就从比较知名的文档网站 VitePress 开始吧,之后预计还有打包工具,ui 库,以及前端基建,干货多多,可以考虑长期追更(doge),自己写一遍既可以给大家讲原理,自己也能学习,两全其美,每一个部分都由“分析效果——规划实现——MVP 调通——难点解决——项目成品”组成
让我们开始吧
[!NOTE]
受限于精力,业余时间实在不多,这个 demo 项目的样式,交互还在设计,完成之后会开源
分析效果
VitePress 文档站,一个知名的静态文档站点,被无数项目和开发者所采用,简洁精美,配置简单,深受开发者喜爱。
分析它的效果,首先是 yaml 驱动的元信息,md 中直接书写 Vue 组件,智能生成 siderbar 和 toc,主页内容的自定义,markdown 扩展语法,代码着色高亮等等
规划实现
Vite 插件
我们将依托于 Vite 的强大能力,一步步手动实现这些特性
在 Vite 的构建中,我们可以编写插件来自定义 vite 处理文件的操作,严格来说,对于 vite 来说,它既不认识文件的扩展名,也看不懂里面的内容,之所以能够解析 vue/react 等等库,也就是因为插件的存在 Vite 插件(也就是 rollup)的类型定义是这样的
interface Plugin$1<A = any> extends Rollup.Plugin<A> {
hotUpdate?: ObjectHook<(this: MinimalPluginContext & {
environment: DevEnvironment;
}, options: HotUpdateOptions) => Array<EnvironmentModuleNode> | void | Promise<Array<EnvironmentModuleNode> | void>>;
resolveId?: ObjectHook<(this: PluginContext, source: string, importer: string | undefined, options: {
attributes: Record<string, string>;
custom?: CustomPluginOptions;
ssr?: boolean;
isEntry: boolean;
}) => Promise<ResolveIdResult> | ResolveIdResult, {
filter?: {
id?: StringFilter<RegExp>;
};
}>;
load?: ObjectHook<(this: PluginContext, id: string, options?: {
ssr?: boolean;
}) => Promise<LoadResult> | LoadResult, {
filter?: {
id?: StringFilter;
};
}>;
transform?: ObjectHook<(this: TransformPluginContext, code: string, id: string, options?: {
ssr?: boolean;
}) => Promise<Rollup.TransformResult> | Rollup.TransformResult, {
filter?: {
id?: StringFilter;
code?: StringFilter;
};
}>;
sharedDuringBuild?: boolean;
perEnvironmentStartEndDuringDev?: boolean;
enforce?: 'pre' | 'post';
apply?: 'serve' | 'build' | ((this: void, config: UserConfig, env: ConfigEnv) => boolean);
applyToEnvironment?: (environment: PartialEnvironment) => boolean | Promise<boolean> | PluginOption;
config?: ObjectHook<(this: ConfigPluginContext, config: UserConfig, env: ConfigEnv) => Omit<UserConfig, 'plugins'> | null | void | Promise<Omit<UserConfig, 'plugins'> | null | void>>;
configEnvironment?: ObjectHook<(this: ConfigPluginContext, name: string, config: EnvironmentOptions, env: ConfigEnv & {
isSsrTargetWebworker?: boolean;
}) => EnvironmentOptions | null | void | Promise<EnvironmentOptions | null | void>>;
configResolved?: ObjectHook<(this: MinimalPluginContextWithoutEnvironment, config: ResolvedConfig) => void | Promise<void>>;
configureServer?: ObjectHook<ServerHook>;
configurePreviewServer?: ObjectHook<PreviewServerHook>;
transformIndexHtml?: IndexHtmlTransform;
buildApp?: ObjectHook<BuildAppHook>;
handleHotUpdate?: ObjectHook<(this: MinimalPluginContextWithoutEnvironment, ctx: HmrContext) => Array<ModuleNode> | void | Promise<Array<ModuleNode> | void>>;
}
其中对我们来说最常用的就是 transform,id 就是文件名,code 就是代码内容,也就是相当于拿到每个文件,你告诉这个文件要怎么处理,处理完还给它就行 于是我们之前说到的特性,markdown 解析可以这里拿,元数据可以这里拿,甚至组件的渲染都可以这里自定义
当然别的特性也有大用,我们慢慢讲解
静态网站生成(SSG)
这名词听起来这么高大上,其实很好理解。
你应该了解过,也用过/实现过 SSR,也就是服务端渲染,这种方式利用 node runtime,在 node 中创建基础应用然后为客户端分发部分渲染好的字符串,由客户端加载 js 斌完成“水合”,进而提升了性能和 SEO 体验
但是这个时候又有新的问题出现了,SSR 对服务端的性能要求很高,或者是像一些静态的内容很少更新,无需或很少交互的内容/serverless,那么我们就让 node 渲染一次然后直接存起来不就好了,诶,于是你发明了 SSG,通过构建时候的一次渲染+客户端脚本水合,这使得你的应用极为轻量,因为你可以在构建阶段完成大部分的页面渲染。
技术选择
选择好了大概方向,那我们来决定用什么来写。
我们采用 Vite+Vue+Markdown-it+shiki+tailwind 手搓这个站点
其中如果是 react 的话其实绝大部分都采用 mdx,一步到位没有乐趣了
而 vue+markdown-it 刚好能发挥 vue sfc 的灵活性,以及自己掌控完全解析的高可玩性
shiki 没啥好说的,好用就是了,当然也可以 highlightjs。
从 MVP 开始
让我们从构建最小可行性产品开始
- 项目初始化:首先,我们用 Vite 创建一个基本的 Vue 项目作为我们的骨架。
- Markdown 解析:然后,我们要让 Vite 能“看懂”
.md文件,并把它转换成 Vue 组件。 - 路由系统:接着,我们会建立一个简单的文件路由,让不同的 URL 能访问到对应的 Markdown 页面。
- 静态站点生成 (SSG):最后,我们会编写一个构建脚本,把所有页面打包成最终的静态 HTML 文件。
pnpm create vite@latest
先建项目,没啥可说的,Vue3+ts 模板,然后装好依赖
第一个插件
首先我们要让 Vite 认识 .md,让我们在 vite.config.js 写下第一个插件
import { defineConfig, Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import Markdown from 'markdown-it'
// 1. 初始化 markdown-it
const md = new Markdown()
// 2. 自定义插件
const markdownPlugin: Plugin = {
// 插件名称
name: 'vite-plugin-markdown-to-vue',
// 这是一个预处理
enforece: "pre",
// transform 钩子
transform(code: string, id: string) {
// 检查文件扩展名是否是 .md
if (id.endsWith('.md')) {
// 使用 markdown-it 将 Markdown 文本转换为 HTML
const html = md.render(code);
// 3. 将 HTML 包装成一个 Vue 组件的模板字符串
// 注意:这里需要使用 backticks (`) 包裹 HTML,并将其导出
const vueComponent = `
<template>
<div class="markdown-body">
${html}
</div>
</template>
<script lang="ts">
</script>
`;
// 返回转换后的 Vue 组件代码
return {
code: vueComponent
};
}
},
};
// https://vitejs.dev/config/
export default defineConfig({
// 4. 在 Vite 中使用我们的插件
plugins: [vue({
// 这里不要忘记这里让 vue compiler 也要处理 .md
include: [/\.vue$/, /\.md$/],
}), markdownPlugin],
})
是的,如你所见,就这么简单,使用 md.render 渲染成 html,组装成 sfc 之后直接给到下一步的 vue compiler 就可以了。
现在,我们直接创建一个 Markdown 文件,并在我们的 Vue 应用里使用它。
在
src文件夹下创建一个名为hello.md的文件,内容如下:Markdown
text# Hello, VitePress Clone! This is a paragraph rendered from Markdown. - Item 1 - Item 2修改
src/App.vue文件,清空原有内容,然后引入并使用这个 Markdown 文件:Code snippet
text<template> <HelloWorld /> </template> <script setup> // 像导入一个普通的 Vue 组件一样导入 .md 文件 import HelloWorld from './hello.md' </script>
诶,这样就渲染出来了,原汁原味的 html
注:这里引入 ide 会报错,建一个 d.ts 就行
declare module '*.md' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
内容目录与路由系统
为了方便书写内容,我们创建 content/ 文件夹,现在开始构建路由
我们希望程序能自动扫描 content/ 目录下的所有 .md 文件,并为它们生成对应的页面路由。例如,content/hello.md 应该能通过 /hello 这个网址访问到。
这个过程可以分为两大部分:
- 扫描文件 (Node.js):我们需要在 Vite 的配置文件 (
vite.config.ts) 里编写一段 Node.js 代码,用来读取content/目录下的所有文件,并生成一个路由配置列表。 - 使用路由 (浏览器端):我们需要在 Vue 应用 (
src/目录) 中安装和配置vue-router,让它使用我们上一步生成的路由列表来展示不同的页面。
先装 router
pnpm add vue-router
好玩的来了,构建工具在构建中可以生成一些“虚拟模块 (Virtual Module)”。
听起来很高级,但原理很简单:我们将编写一个 Vite 插件,这个插件会创建一个 只存在于内存中 的“虚拟文件”。我们的 Vue 应用可以像导入普通文件一样导入这个虚拟文件,从而获取到我们动态生成的路由列表。
那就简单了,无外乎两步:
- 在 Vite 插件中:扫描
content/目录,生成路由配置,并通过一个特殊的虚拟 ID (咱们这利用virtual:routes) 来提供这些配置。 - 在 Vue 应用中:导入
virtual:routes,并用它来初始化vue-router。
我们需要一个新的 Vite 插件。这次,之前说的其他钩子就有用了:resolveId 和 load。
resolveId钩子:当 Vite 看到import ... from 'virtual:routes'这样的语句时,它会问:“这个 ‘virtual: routes’ 到底是什么东西?” 这个钩子就是用来回答这个问题的。我们会告诉 Vite:“是的,我认识这个 ID,你交给我来处理就行。”load钩子:在resolveId确认了 ID 之后,Vite 就会调用load钩子,问:“好了,那这个模块的内容是什么?” 在这里,我们就会动态地生成代码并返回。
利用这个功能,让我们创建新的插件:
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import Markdown from 'markdown-it'
import fs from 'fs'
import path from 'path'
const md = new Markdown()
// 之前的 markdownPlugin,不管它
const markdownPlugin: Plugin = { ... };
const routesPlugin: Plugin = {
name: 'vite-plugin-virtual-routes',
resolveId(id) {
// 如果导入的 ID 是 'virtual: routes',就告诉 Vite 我们要处理它
if (id === 'virtual:routes') {
return id;
}
},
load(id) {
// 确认是我们的虚拟模块
if (id === 'virtual:routes') {
// 1. 定义 content 文件夹的路径
const contentDir = path.resolve(__dirname, 'content');
// 2. 读取文件夹下的所有文件名
const files = fs.readdirSync(contentDir);
// 3. 为每个 .md 文件生成一个路由对象
const routes = files.filter(file => file.endsWith('.md')).map(file => {
const name = file.replace('.md', '');
const path = name === 'index' ? '/' : `/${name}`;
// 这里直接导入
return `{
path: '${path}',
component: () => import('/content/${file}')
}`;
});
// 4. 将路由对象数组转换成 JavaScript 代码字符串就 ok 了
return `export const routes = [${routes.join(', ')}]`;
}
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue({
include: [/\.vue$/, /\.md$/],
}),
markdownPlugin,
routesPlugin
],
})
同样为了 ts 更好推断,还要添加 d.ts
declare module 'virtual:routes' {
import type { RouteRecordRaw } from 'vue-router'
export const routes: RouteRecordRaw[]
}
于是就可以直接引入
import { routes } from 'virtual:routes'
创建路由 use router-view 不再赘述,前端开发写过无数次了,于是你就有了简单路由。
但是这还不够呀,文档一定会有多级路由存在的,那也简单,递归处理一下就好了呗
递归很简单,基本功了,直接上代码
import fs from "fs";
import path from "path";
import type {Plugin} from "vite";
function generateRoutesFromDir(dir: string, basePath: string = '/') {
const entries = fs.readdirSync(dir, {withFileTypes: true});
const routes: Array<{ path: string; importPath: string }> = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const newBasePath = path.posix.join(basePath, entry.name);
const subRoutes = generateRoutesFromDir(fullPath, newBasePath);
routes.push(...subRoutes);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
const name = entry.name.replace('.md', '');
const routePath = name === 'index' ? basePath : path.posix.join(basePath, name);
const importPath = path.posix.join('/content', basePath, entry.name);
routes.push({
path: routePath,
importPath: importPath,
});
}
}
return routes;
}
export function routesPlugin(): Plugin {
return {
name: 'vite-plugin-virtual-routes',
resolveId(id) {
if (id === 'virtual:routes') {
return id;
}
},
load(id) {
if (id === 'virtual:routes') {
const contentDir = path.resolve(__dirname, '../content');
const routes = generateRoutesFromDir(contentDir);
const routesString = routes.map(route => `{
path: '${route.path}',
component: () => import(/* @vite-ignore */ '${route.importPath}')
}`);
return `export const routes = [${routesString.join(', ')}]`;
}
}
}
}
到这里,你的应用便有了雏形,可以在不同页面之间导航了。
从 SSR 到 SSG
相信熟悉前端的你一定写过 SSR 的手动实现,思路就是维护两个 entry,一个 server,一个 client,使用打包工具分别处理,收到请求首先内存 router 切换,状态库注入,然后渲染发给客户端,
这里还是实现一次吧,毕竟懂了 SSR 就简单了。
首先让 router 在服务端内存维护,客户端历史记录维护。
import {
createRouter as _createRouter,
createWebHistory,
createMemoryHistory
} from 'vue-router'
import { routes } from 'virtual:routes'
export const createRouter = () => _createRouter({
// Vite 会提供一个环境变量 import.meta.env.SSR
// 在浏览器环境(SSR 为 false),我们使用 history 模式
// 在 Node.js 环境(SSR 为 true),我们使用 memory 模式
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes,
})
随后主应用使用这个额算是工厂函数
import { createApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router' // <-- 1. 导入 createRouter 函数
const app = createApp(App)
const router = createRouter() // <-- 2. 调用函数创建实例
app.use(router)
// 在挂载应用之前,我们需要确保路由已经准备就绪
router.isReady().then(() => {
app.mount('#app')
})
好,接下来是服务端入口,我们新建一个 entry-ssr.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router'
// 在 Node.js 环境中调用
export function createApp() {
const app = createSSRApp(App)
const router = createRouter()
app.use(router)
// 返回 app 和 router 实例
return { app, router }
}
Vite 的强大又来了,它提供一个 createServer 可以方便创建服务端环境,而 ssrLoadModule 就是为加载 ssr 入口而生,优化常用使用场景确实方便于 webpack 维护三套配置。
用我们之前扫描到的所有路由,依次完成渲染
// ssg.ts
import { build, createServer } from 'vite'
import path from 'path'
import fs from 'fs/promises'
import { renderToString } from 'vue/server-renderer'
console.log('Building for client...');
await build();
console.log('Client build complete.');
console.log('Starting SSR...');
const server = await createServer({
server: { middlewareMode: true },
appType: 'custom',
});
try {
const { createApp } = await server.ssrLoadModule('./src/entry-ssr.ts');
const { routes } = await server.ssrLoadModule('virtual:routes');
const routesToRender = routes.map((route: any) => route.path);
console.log('Discovered routes to render:', routesToRender);
for (const route of routesToRender) {
const { app, router } = createApp();
await router.push(route);
await router.isReady();
const html = await renderToString(app);
// 我们暂时还只打印 HTML 片段
console.log(`Rendered HTML for ${route}`);
}
} finally {
await server.close();
}
console.log('SSR complete.');
})();
正如我们之前的思路,我们把这些存起来就行了,存哪里呢,诶,存在客户端的打包刚好
为了渲染好的内容能够精准插入,我们不妨给它标记下,编辑根 index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app">
<!-- app inject-->
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
我们添加了个注释,这样便于 SSR 拿到字符串之后替换 思路有了,相信对大家来说超级简单,直接最后的代码~
import { build, createServer } from 'vite'
import path from 'path'
import fs from 'fs/promises'
import { renderToString } from 'vue/server-renderer'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
;(async () => {
const __dirname = dirname(fileURLToPath(import.meta.url));
console.log('Building for client...');
await build();
console.log('Client build complete.');
console.log('Starting SSG (Static Site Generation)...');
const server = await createServer({
server: { middlewareMode: true },
appType: 'custom',
});
try {
const { createApp } = await server.ssrLoadModule('./src/entry-ssr.ts');
const { routes } = await server.ssrLoadModule('virtual:routes');
const routesToRender = routes.map((route: any) => route.path);
const template = await fs.readFile(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
for (const route of routesToRender) {
const { app, router } = createApp();
await router.push(route);
await router.isReady();
const appHtml = await renderToString(app);
const finalHtml = template.replace(`<!-- app inject-->`, appHtml);
// 计算输出路径, 确保“干净”的 URL (例如 /hello 对应 /hello/index.html)
const dirPath = path.join(__dirname, '../dist', route);
const filePath = path.join(dirPath, 'index.html');
// 递归创建目录, 然后写入最终的 HTML 文件
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(filePath, finalHtml);
console.log(`✓ Pre-rendered: ${route}`);
}
} finally {
await server.close();
}
console.log('SSG complete. Your static site is ready in the "dist" folder.');
})();
到这里,你的网站成功实现静态生成!(撒花)
在这里之后,你可以比如用 gray-matter 解析元数据,创建 meta 组件,这些思路差不多
难点解决
难题到这里开始了。
shiki 代码着色
核心挑战:同步 vs. 异步
在集成 shiki 时,我们会遇到一个非常经典且重要的问题:markdown-it 的渲染过程是 同步 的,但 shiki 的高亮过程是 异步 的(因为它需要异步加载不同语言的语法文件)。
要解决这个矛盾,我们只需要顶层 await 来初始化 shiki,然后把它作为高亮引擎提供给 markdown-it。其实也就是等他加载完再继续
pnpm add shiki
import MarkdownIt from 'markdown-it'
import {createHighlighter} from 'shiki'
const highlighter = await createHighlighter({
themes: ['nord', 'github-light'],
langs: ['ts', 'js', 'json', 'vue', 'css', 'html', 'md']
})
// Shiki 准备好之后, 我们才创建 markdown-it 实例
export const md = new MarkdownIt({
highlight(code, lang) {
if (!lang || !highlighter.getLoadedLanguages().includes(lang as any)) {
return `<pre class="shiki"><code>${code}</code></pre>`;
}
return highlighter.codeToHtml(code, { lang })
}
})
现在我们只需要用这里的 md 对象替换 vite 插件里面的就好啦,很轻松,并且由于只有构建时引入一次,也不会影响客户端代码大小。
自定义组件解析
还记得之前我们说过 Vue 在这里的优势是 sfc 嘛,我们不妨将整篇文章作为 sfc 组件,利用 @vue/compiler-sfc 一把梭哈
当然也不是那么暴力,整体的思路是
原始文档 → 正则替换组件 → Markdown 渲染 → SFC 组件 → 最终渲染
我们就从简单的 <Alert/> 入手,首先是写好正则:
// 注册的自定义语法/组件映射
const customSyntaxMap = new Map<RegExp, (...args: any[]) => string>([
// Alert 组件支持
[/:::\s*(info|warning|success|danger)(?:\s+(.+?))?\s*\n(.*?)\n:::/gs, (_match: string, type: string, title: string, content: string) => {
const titleAttr = title ? ` title="${title.trim()}"` : ''
return `<Alert type="${type}"${titleAttr}>${content.trim()}</Alert>`
}],
])
于是我们可以直接在 transform 钩子中替换与渲染:
我直接在注释中讲解吧
transform(code: string, id: string) {
if (!id.endsWith('.md')) {
return null
}
// 提取前置信息和最终文本
const { data: frontmatter, content: markdownContent } = matter(code)
let processedContent = markdownContent
const components = new Set<string>()
// 处理自定义语法
for (const [regex, replacer] of customSyntaxMap) {
processedContent = processedContent.replace(regex, (...args) => {
const result = replacer(...args)
const componentMatch = result.match(/<([A-Z][a-zA-Z0-9]*)/)
if (componentMatch) {
components.add(componentMatch[1])
}
return result
})
}
// 分割内容,也就是每段逐个处理
const parts: Array<{ type: 'markdown' | 'component', content: string }> = []
const componentRegex = /<([A-Z][a-zA-Z0-9]*)[^>]*>.*?<\/\1>/gs
let lastIndex = 0
let match
while ((match = componentRegex.exec(processedContent)) !== null) {
if (match.index > lastIndex) {
const markdownPart = processedContent.slice(lastIndex, match.index).trim()
if (markdownPart) {
parts.push({ type: 'markdown', content: markdownPart })
}
}
parts.push({ type: 'component', content: match[0] })
lastIndex = match.index + match[0].length
}
if (lastIndex < processedContent.length) {
const markdownPart = processedContent.slice(lastIndex).trim()
if (markdownPart) {
parts.push({ type: 'markdown', content: markdownPart })
}
}
// 生成组件导入,拼接到最后 sfc 用
const componentImports = Array.from(components)
.map(name => `import ${name} from '/src/components/${name}.vue'`)
.join('\n')
// 生成模板内容
const templateContent = parts.map(part => {
if (part.type === 'component') {
return part.content
} else {
const htmlContent = md.render(part.content)
return `<div class="markdown-content">${htmlContent}</div>`
}
}).join('\n ')
// 直接生成带布局的完整页面
const vueComponent = `<script setup lang="ts">
import Layout from '/src/layouts/Layout.vue'
// 这里引入注册组件
${componentImports}
const frontmatter = ${JSON.stringify(frontmatter)}
</script>
<template>
<Layout>
<div class="page-content">
<h1 v-if="frontmatter?.title" class="page-title">{{frontmatter.title}}</h1>
<div class="markdown-body">
${templateContent}
</div>
</div>
</Layout>
</template>
<style scoped>
// 对应的样式
</style>`
return {
code: vueComponent
}
}
到这里,「手搓系列 01」就告一段落了。我们一起从零构建了一个完整的静态文档站,从最基础的 Vite 插件,到核心的 SSG 渲染,再到代码高亮和自定义组件解析。这个过程不仅是为了得到一个能用的轮子,更是为了深入理解这些习以为常的技术栈背后,每个环节是如何协同工作的。
最终的项目我还在慢慢开发,涉及到每个组件写一遍,还有 UI 和功能,精力真的不够,等到做完第一时间更新和开源。
手写一遍,才能真正掌握它的精髓。
如果你对这个系列感兴趣,可以长期关注 RSS。在接下来的文章里,我们还会继续探索更多有趣的前端轮子,比如打包工具、UI 库和前端基建等。让我们动手继续敲下去