Vite 實戰:手把手教你寫一個 Vite 外掛

曬兜斯發表於2022-03-16

哈嘍,很高興你能點開這篇部落格,本部落格是針對 Vite 的體驗系列文章之實戰篇,認真看完後相信你也能如法炮製寫一個屬於自己的 vite 外掛。

Vite 是一種新型的前端構建工具,能夠顯著提升前端開發體驗。

我將會從 0 到 1 完成一個 vite:markdown 外掛,該外掛可以讀取專案目錄中的 markdown 檔案並解析成 html,最終渲染到頁面中。

如果你還沒有使用過 Vite,那麼你可以看看我的前兩篇文章,我也是剛體驗沒兩天呢。(如下)

本系列檔案還對 Vite 原始碼進行了解讀,往期文章可以看這裡:

實現思路

其實 vite 外掛的實現思路就是 webpackloader + plugin,我們這次要實現的 markdown 外掛其實更像是 loader 的部分,但是也會利用到 vite 外掛的一些鉤子函式(比如熱過載)。

我需要先準備一個對 markdown 檔案進行轉換,轉換成 html 的外掛,這裡我使用的是 markdown-it,這是一個很流行的 markdown 解析器。

其次,我需要識別程式碼中的 markdown 標籤,並讀取標籤中指定的 markdown 檔案,這一步可以使用正則加上 nodefs 模組做到。

好,實現思路都理清了,我們現在可以來實現這個外掛了。

初始化外掛目錄

我們使用 npm init 命令來初始化外掛,外掛名稱命名為 @vitejs/plugin-markdown

為了方便除錯,該外掛目錄我直接建立在我的 Vite Demo 專案 中。

本次外掛實戰的倉庫地址為 @vitejs/plugin-markdown,感興趣的同學也可以直接下載程式碼來看。

package.json 中,我們先不用著急設定入口檔案,我們可以先把我們的功能實現。

建立測試檔案

這裡,我們在測試專案中建立一個測試檔案 TestMd.vueREADME.md,檔案內容和最終效果如下圖所示。

image

在建立好了測試檔案後,我們現在就要來研究怎麼實現了。

建立外掛入口檔案 —— index.ts

下面,我們來建立外掛入口檔案 —— index.ts

vite 的外掛支援 ts,所以這裡我們直接使用 typescript 來編寫這個外掛。

該檔案的內容主要是包含了 nameenforcetransform 三個屬性。

  • name: 外掛名稱;
  • enforce: 該外掛在 plugin-vue 外掛之前執行,這樣就可以直接解析到原模板檔案;
  • transform: 程式碼轉譯,這個函式的功能類似於 webpackloader
export default function markdownPlugin(): Plugin {
  return {
    // 外掛名稱
    name: 'vite:markdown',

    // 該外掛在 plugin-vue 外掛之前執行,這樣就可以直接解析到原模板檔案
    enforce: 'pre',

    // 程式碼轉譯,這個函式的功能類似於 `webpack` 的 `loader`
    transform(code, id, opt) {}
  }
}

module.exports = markdownPlugin
markdownPlugin['default'] = markdownPlugin

過濾非目標檔案

接下來,我們要對檔案進行過濾,將非 vue 檔案、未使用 g-markdown 標籤的 vue 檔案進行過濾,不做轉換。

transform 函式的開頭,加入下面這行正則程式碼進行判斷即可。

const vueRE = /\.vue$/;
const markdownRE = /\<g-markdown.*\/\>/g;
if (!vueRE.test(id) || !markdownRE.test(code)) return code;

markdown 標籤替換成 html 文字

接下來,我們要分三步走:

  1. 匹配 vue 檔案中的所有 g-markdown 標籤
  2. 載入對應的 markdown 檔案內容,將 markdown 文字轉換為瀏覽器可識別的 html 文字
  3. markdown 標籤替換成 html 文字,引入 style 檔案,輸出檔案內容

我們先來匹配 vue 檔案中所有的 g-markdown 標籤,依舊是使用上面的那個正則:

const mdList = code.match(markdownRE);

然後對匹配到的標籤列表進行一個遍歷,將每個標籤內的 markdown 文字讀取出來:

const filePathRE = /(?<=file=("|')).*(?=('|"))/;

mdList?.forEach(md => {
  // 匹配 markdown 檔案目錄
  const fileRelativePaths = md.match(filePathRE);
  if (!fileRelativePaths?.length) return;

  // markdown 檔案的相對路徑
  const fileRelativePath = fileRelativePaths![0];
  // 找到當前 vue 的目錄
  const fileDir = path.dirname(id);
  // 根據當前 vue 檔案的目錄和引入的 markdown 檔案相對路徑,拼接出 md 檔案的絕對路徑
  const filePath = path.resolve(fileDir, fileRelativePath);
  // 讀取 markdown 檔案的內容
  const mdText = file.readFileSync(filePath, 'utf-8');

  //...
});

mdText 就是我們讀取的 markdown 文字(如下圖)

image

接下來,我們需要實現一個函式,來對這一段文字進行轉換,這裡我們使用之前提到的外掛 markdown-it,我們新建一個 transformMarkdown 函式來完成這項工作,實現如下:

const MarkdownIt = require('markdown-it');

const md = new MarkdownIt();
export const transformMarkdown = (mdText: string): string => {
  // 加上一個 class 名為 article-content 的 wrapper,方便我們等下新增樣式
  return `
    <section class='article-content'>
      ${md.render(mdText)}
    </section>
  `;
}

然後,我們在上面的遍歷流程中,加入這個轉換函式,再將原來的標籤替換成轉換後的文字即可,實現如下:

mdList?.forEach(md => {
  //...
  // 讀取 markdown 檔案的內容
  const mdText = file.readFileSync(filePath, 'utf-8');

  // 將 g-markdown 標籤替換成轉換後的 html 文字
  transformCode = transformCode.replace(md, transformMarkdown(mdText));
});

在得到了轉換後的文字後,此時頁面已經可以正常顯示了,我們最後在 transform 函式中新增一份掘金的樣式檔案,實現如下:

transform(code, id, opt) {
  //...
  // style 是一段樣式文字,文字內容很長,這裡就不貼出來了,感興趣的可以在原倉庫找到
  transformCode = `
    ${transformCode}
    <style scoped>
      ${style}
    </style>
  `

  // 將轉換後的程式碼返回
  return transformCode;
}
@vitejs/plugin-markdown 實戰外掛地址

引用外掛

我們需要在測試專案中引入外掛,我們在 vite.config.ts 中進行配置即可,程式碼實現如下:

在實際開發中,這一步應該早做,因為提前引入外掛,外掛程式碼的變更可以實時看到最新效果。

在引入外掛後,可能會報某些依賴丟失,此時需要在測試專案中先安裝這些依賴進行除錯(生產環境不需要),例如 markdown-it

import { defineConfig } from 'vite'
import path from 'path';
import vue from '@vitejs/plugin-vue'
import markdown from './plugin-markdown/src/index';

// https://vitejs.dev/config/
export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "src"),
    }
  },
  plugins: [
    vue(),
    // 引用 @vitejs/plugin-markdown 外掛
    markdown()
  ]
});

然後,使用 vite 命令,啟動我們的專案(別忘了在 App.vue 中引入測試檔案 TestMd.vue),就可以看到下面這樣的效果圖啦!(如下圖)

image

配置熱過載

此時,我們的外掛還缺一個熱過載功能,沒有配置該功能的話,修改 md 檔案是無法觸發熱過載的,每次都需要重啟專案。

我們需要在外掛的 handleHotUpdate 鉤子函式中,對我們的 md 型別檔案進行監聽,再將依賴該 md 檔案的 vue 檔案進行熱過載。

在此之前,我們需要先在 transform 的遍歷迴圈中,儲存引入了 md 檔案的 vue 檔案吧。

在外掛頂部建立一個 map 用於儲存依賴關係,實現如下

const mdRelationMap = new Map<string, string>();

然後在 transform 中儲存依賴關係。

mdList?.forEach(md => {
  //...
  // 根據當前 vue 檔案的目錄和引入的 markdown 檔案相對路徑,拼接出 md 檔案的絕對路徑
  const mdFilePath = path.resolve(fileDir, fileRelativePath);
  // 記錄引入當前 md 檔案的 vue 檔案 id
  mdRelationMap.set(mdFilePath, id);
});

然後,我們配置新的熱過載鉤子 —— handleHotUpdate 就可以了,程式碼實現如下:

handleHotUpdate(ctx) {
  const { file, server, modules } = ctx;
  
  // 過濾非 md 檔案
  if (path.extname(file) !== '.md') return;

  // 找到引入該 md 檔案的 vue 檔案
  const relationId = mdRelationMap.get(file) as string;
  // 找到該 vue 檔案的 moduleNode
  const relationModule = [...server.moduleGraph.getModulesByFile(relationId)!][0];
  // 傳送 websocket 訊息,進行單檔案熱過載
  server.ws.send({
    type: 'update',
    updates: [
      {
        type: 'js-update',
        path: relationModule.file!,
        acceptedPath: relationModule.file!,
        timestamp: new Date().getTime()
      }
    ]
  });

  // 指定需要重新編譯的模組
  return [...modules, relationModule]
},

此時,我們修改我們的 md 檔案,就可以看到頁面實時更新啦!(如下圖)

image

順便吐槽一下,關於 handleHotUpdate 處理的文件內容很少,server.moduleGraph.getModulesByFile 這個 API 還是在 vite issue 裡面的程式碼片段裡找到的,如果大家發現有相關的文件資源,也請分享給我,謝謝。

到這裡,我們的外掛開發工作就完成啦。

釋出外掛

在上面的步驟中,我們都是使用本地除錯模式,這樣的包分享起來會比較麻煩。

接下來,我們把我們的包構建出來,然後傳到 npm 上,供大家安裝體驗。

我們在 package.json 中,新增下面幾行命令。

  "main": "dist/index.js", // 入口檔案
  "scripts": {
    // 清空 dist 目錄,將檔案構建到 dist 目錄中
    "build": "rimraf dist && run-s build-bundle",
    "build-bundle": "esbuild src/index.ts --bundle --platform=node --target=node12 --external:@vue/compiler-sfc --external:vue/compiler-sfc --external:vite --outfile=dist/index.js"
  },

然後,別忘了安裝 rimrafrun-sesbuild 相關依賴,安裝完依賴後,我們執行 npm run build,就可以看到我們的程式碼被編譯到了 dist 目錄中。

當所有都準備就緒後,我們使用 npm publish 命令釋出我們的包就可以啦。(如下圖)

image

然後,我們可以將 vue.config.ts 中的依賴換成我們構建後的版本,實現如下:

// 由於我本地網路問題,我這個包傳不上去,這裡我直接引入本地包,和引用線上 npm 包是同理的
import markdown from './plugin-markdown';

然後我們執行專案,成功解析 markdown 檔案即可!(如下圖)

image

小結

到這裡,我們本期教程就結束了。

想要更好的掌握 vite 外掛的開發,還是要對下面幾個生命週期鉤子的作用和職責有清晰的認識。

欄位說明所屬
name外掛名稱viterollup 共享
handleHotUpdate執行自定義 HMR(模組熱替換)更新處理vite 獨享
config在解析 Vite 配置前呼叫。可以自定義配置,會與 vite 基礎配置進行合併vite 獨享
configResolved在解析 Vite 配置後呼叫。可以讀取 vite 的配置,進行一些操作vite 獨享
configureServer是用於配置開發伺服器的鉤子。最常見的用例是在內部 connect 應用程式中新增自定義中介軟體。vite 獨享
transformIndexHtml轉換 index.html 的專用鉤子。vite 獨享
options在收集 rollup 配置前,vite (本地)服務啟動時呼叫,可以和 rollup 配置進行合併viterollup 共享
buildStartrollup 構建中,vite (本地)服務啟動時呼叫,在這個函式中可以訪問 rollup 的配置viterollup 共享
resolveId在解析模組時呼叫,可以返回一個特殊的 resolveId 來指定某個 import 語句載入特定的模組viterollup 共享
load在解析模組時呼叫,可以返回程式碼塊來指定某個 import 語句載入特定的模組viterollup 共享
transform在解析模組時呼叫,將原始碼進行轉換,輸出轉換後的結果,類似於 webpackloaderviterollup 共享
buildEndvite 本地服務關閉前,rollup 輸出檔案到目錄前呼叫viterollup 共享
closeBundlevite 本地服務關閉前,rollup 輸出檔案到目錄前呼叫viterollup 共享

如果大家發現有什麼比較好的文章或者文件對這些鉤子函式有更詳細的介紹,也歡迎分享出來。

到這篇文章位置,總共 6 期的 Vite 系列文章也圓滿畫上了句號,謝謝大家的支援。

最後一件事

如果您已經看到這裡了,希望您還是點個贊再走吧~

您的點贊是對作者的最大鼓勵,也可以讓更多人看到本篇文章!

如果覺得本文對您有幫助,請幫忙在 github 上點亮 star 鼓勵一下吧!

相關文章