什麼是 Vitepress?
Vitepress 是由 Vite 和 Vue 驅動的靜態站點生成器,透過獲取 Markdown 編寫的內容,並可以生成對應的靜態 HTML 頁面。我們經常使用 Vitepress 構建部落格等靜態網站,本文主要解析一下 Vitepress 的實現原理,下面就開始吧!
原理
初始化專案
根據官方文件推薦,我們執行以下命令初始化專案:
npx vitepress init
執行完命令便會進入一個設定介面,透過設定專案名等引數,最終生成一個 vitepress 專案。
我們都知道,npx vitepress init
實際上等同於:
npm i -g vitepress
vitepress init
很好理解,先全域性安裝 vitepress,再執行 vitepress init
命令:
先透過 @clack/prompts
開啟命令列 UI 介面,使用者進行初始化配置:
// src/node/init/init.ts
import { group } from '@clack/prompts'
const options: ScaffoldOptions = await group(
{
root: () =>
text({
message: 'Where should VitePress initialize the config?',
initialValue: './',
validate(value) {
// TODO make sure directory is inside
}
}),
title: () =>
text({
message: 'Site title:',
placeholder: 'My Awesome Project'
}),
// ...以下省略
)
再根據配置項從 template 資料夾中拉取模板檔案,完成專案的初始化。
啟動服務
在 Vitepress 專案中,我們透過執行以下命令啟動文件服務:
vitepress 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>
) {
// 讀取 vitepress 配置
const config = await resolveConfig(root)
if (serverOptions.base) {
config.site.base = serverOptions.base
delete serverOptions.base
}
// 建立 vite 服務
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/index.js
),再將部分配置傳入 createViteServer
,建立並啟動 Vite 服務。
執行 Vite 外掛
看完上面的內容,你可能會有點疑惑,正常來說,Vite 需要一個 HTML 作為入口檔案,但我們找遍 Vitepress 也未發現我們想要的 HTML 檔案……其實這部分工作由 Vite 外掛完成,在上面的程式碼片段中,我們建立了 Vite 服務,同時配置了外掛:
// src/node/server.ts
return createViteServer({
// 省略程式碼
plugins: await createVitePressPlugin(config, false, {}, {}, recreateServer),
// 省略程式碼
})
createVitePressPlugin
函式返回了一個外掛列表,其中有一個名為 vitepress
的外掛:
// 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()
})
}
},
// 省略程式碼
}
vitepress 外掛中定義了 configureServer
生命週期,並在 configureServer
中返回一個 HTML 檔案,作為 Vite 服務的入口 HTML 檔案,當我們訪問服務時,瀏覽器渲染網頁,執行 HTML 中引入的 Script 檔案(<script type="module" src="/@fs/${APP_PATH}/index.js"></script>
,其中 APP_PATH
為 src/client/app/index.ts
),網頁正常展示在我們眼前,至此,服務正常啟動!
文件渲染
在上面的部分,我們整理了啟動服務的大致步驟,接下來我們將接著整理 Markdown 檔案和路由的對映關係!
建立路由
Vitepress 並沒有使用 Vuejs 的官方路由方案(Vue Router),而是自己實現了一個簡單的路由模組:首先透過監聽 window 的點選事件,當使用者點選超連結元素時,執行跳轉函式 go
:
// src/client/app/router.ts
async function go(href: string = inBrowser ? location.href : '/') {
href = normalizeHref(href)
if ((await router.onBeforeRouteChange?.(href)) === false) return
updateHistory(href)
await loadPage(href)
await router.onAfterRouteChanged?.(href)
}
function updateHistory(href: string) {
if (inBrowser && normalizeHref(href) !== normalizeHref(location.href)) {
// save scroll position before changing url
history.replaceState({ scrollPosition: window.scrollY }, document.title)
history.pushState(null, '', href)
}
}
透過執行 updateHistory
,先呼叫 history.replaceState
,將當前頁面的位置資訊 scrollY
儲存到 history state 中;再呼叫 history.pushState
,更新 url;最後再呼叫 loadPage
載入 url 對應的頁面,核心程式碼如下:
// src/client/app.ts
let pageFilePath = pathToFile(path)
let pageModule = null
// 省略程式碼
pageModule = import(/*@vite-ignore*/ pageFilePath + '?t=' + Date.now())
// 省略程式碼
return pageModule
pathToFile
函式將傳入的 url 轉成 md 字尾的路徑,也就是對應的 Markdown 檔案,再透過 import
匯入對應路徑的檔案;舉個例子,假設 url 為 /ruofee
,那麼最終結果為:import(/*@vite-ignore*/ 'ruofee.md?t=當前的時間戳')
;
同時監聽 popstate 事件,當使用者使用瀏覽器返回、前進等操作時,呼叫 loadPage
方法,載入 url 對應的 md 檔案,並根據 history state 中儲存的頁面位置資訊進行定位:
// src/client/app/router.ts
window.addEventListener('popstate', async (e) => {
await loadPage(
normalizeHref(location.href),
(e.state && e.state.scrollPosition) || 0
)
router.onAfterRouteChanged?.(location.href)
})
// 省略程式碼 - loadPage
window.scrollTo(0, scrollPosition)
建立 Vue 應用
// src/client/app.ts
import {
createApp,
type App
} from 'vue'
// 省略程式碼
function newApp(): App {
// 省略程式碼
return createApp(VitePressApp)
}
const app = newApp()
首先透過執行 createApp(VitePressApp)
建立 Vue 應用,VitePressApp
是當前主題的 Layout 元件(@theme
是別名配置,指向當前主題,若是沒有設定,則預設為 src/client/theme-default
):
// src/client/app.ts
import RawTheme from '@theme/index'
const Theme = resolveThemeExtends(RawTheme)
const VitePressApp = defineComponent({
name: 'VitePressApp',
setup() {
// 省略程式碼
return () => h(Theme.Layout!)
}
})
再將上面的路由物件註冊到 Vue 應用中,並註冊兩個全域性元件:Content
和 ClientOnly
:
// src/client/app.ts
// 將路由注入 app
app.provide(RouterSymbol, router)
const data = initData(router.route)
app.provide(dataSymbol, data)
// 註冊全域性元件
app.component('Content', Content)
app.component('ClientOnly', ClientOnly)
Markdown 渲染
直到目前為止,我們已經啟動了 Vite 服務,我們可以在瀏覽器中訪問 HTML,並執行 Script 建立 Vue 應用,實現了路由系統,當我們訪問對應連結時,便會載入對應的 Markdown 檔案,但你肯定會有疑惑:我們的 Markdown 檔案如何被解析渲染到頁面中呢?
其實在啟動服務的部分中,我們提到了一個名為 vitepress 的 vite 外掛,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
)
// 省略程式碼
const res = processClientJS(vueSrc, id)
return res
}
}
}
當我們使用 import
載入 md 檔案時,便會呼叫 transform
函式,對檔案內容進行轉換:執行 markdownToVue
,將 markdown 內容轉成 Vue SFC,再透過 @vitejs/plugin-vue
外掛將 Vue 元件渲染到頁面;那麼 markdownToVue
做了什麼工作呢?具體如下:
// src/node/markdownToVue.ts
const html = md.render(src, env)
const vueSrc = [
// 省略程式碼
`<template><div>${html}</div></template>`,
// 省略程式碼
].join('\n')
這部分比較簡單,md 是一個 markdown-it 物件,透過呼叫 md.render
函式,將 markdown 內容轉成 HTML 格式,再輸出到頁面;
值得一提的是,若是你在 markdown 中書寫 Vue 元件語法,由於是非 markdown 語法,因此 markdown-it 不會對其進行轉換,那麼 Vue 語法將在頁面中得以執行,官網中的例子便是利用這個原理!
總結
以上便是 Vitepress 大致的原理,Vitepress 是一個非常優秀的文件構建工具,其中有很多設計上的細節文章沒提到,具體大家可以自行去 Github 上檢視原始碼!