Vitepress 的實現原理

發表於2024-02-21

什麼是 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

執行完命令,我們便可以在瀏覽器訪問檔案網站!

啟動服務主要分為兩步:

  1. 建立 Vite 服務;
  2. 執行 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_PATHsrc/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 應用中,並註冊兩個全域性元件:ContentClientOnly

// 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 上檢視原始碼!

相關文章