淺析 Vite 插件機制
前言
對於 Vite 來說,它是基於esbuild
與rollup
雙引擎設計的,在開發階段使用esbuild
進行依賴預構建,然後基於瀏覽器原生支持的ESM
完成開發預覽,而在生產環境打包時,直接使用的rollup
構建。那麼在這種背景下,Vite
的插件機制應該如何設計?
在源碼中,我們能夠經常看到PluginContainer
的身影,Vite
正是通過它來模擬rollup
的行爲
pluginContainer
PluginContainer 的 實現 基於借鑑於 WMR 中的 rollup-plugin-container.js ,主要功能有兩個:
-
管理插件的生命週期
-
實現插件鉤子內部的 Context 上下文對象
插件生命週期
在開發階段,vite 會模擬rollup
的行爲,所以插件的執行機制也與rollup
相同
-
調用
options
鉤子進行配置的轉換,得到處理後的配置對象。 -
調用
buildStart
鉤子,正式開始構建流程。 -
調用
resolveId
鉤子中解析文件路徑。(從input
配置指定的入口文件開始)。 -
調用
load
鉤子加載模塊內容。 -
緊接着 Rollup 執行所有的
transform
鉤子來對模塊內容進行進行自定義的轉換(比如 babel 轉譯) -
Rollup 拿到最後的模塊內容,進行 AST 分析,得到所有的 import 內容,調用
moduleParsed
鉤子 -
直到所有的 import 都解析完畢,Rollup 執行
buildEnd
鉤子,Build 階段結束。
❝
這裏需要注意的是:在 vite 中由於 AST 分析是通過 esbuild 進行的,所有沒有模擬
moduleParsed
鉤子
傳遞上下文對象
上下文對象通過 Context 實現 PluginContext 接口定義,PluginContext 實際上是 Rollup 內部定義的類型,可以在源碼中看到 vite 實現了 Rollup 上下文對象
class Context implements PluginContext {
//... 具體實現
}
type PluginContext = Omit<
RollupPluginContext, // Rollup 定義插件上下文接口
// not documented
| 'cache'
// deprecated
| 'moduleIds'
>
Context 上下文對象一共有 14 個核心方法,其中有 3 個方法是比較核心的方法
-
parse:使用
Rollup
的內部基於SWC
的解析器將代碼解析爲 AST -
resolve:將相對路徑解析爲絕對路徑,從而正確地處理模塊之間的引用
-
load:加載並解析與給定 ID 對應的模塊,並在提供的情況下附加附加的元信息到模塊
更多內容可以查看 rollup 文檔
rollup 插件
❝
rollup 構建流程主要分爲兩大類:build 和 output,build 階段主要負責創建模塊依賴圖,初始化各個模塊的 AST 以及模塊之間的依賴關係。output 階段纔是真正的打包構建過程
插件 hook 類型(構建階段)
通過構建流程rollup
的 hook 類型可以分爲:build hook 和 output hook 兩大類
-
Build Hook
即在Build
階段執行的鉤子函數,在這個階段主要進行模塊代碼的轉換、AST 解析以及模塊依賴的解析,那麼這個階段的 Hook 對於代碼的操作粒度一般爲模塊
級別,也就是單文件級別。 -
Ouput Hook
(官方稱爲Output Generation Hook
),則主要進行代碼的打包,對於代碼而言,操作粒度一般爲chunk
級別 (一個 chunk 通常指很多文件打包到一起的產物)。
插件 hook 類型(執行方式)
除了上面這種分類,rollup
插件還可以根據各自的執行方式來進行分類:
-
Build Hook
即在Build
階段執行的鉤子函數,在這個階段主要進行模塊代碼的轉換、AST 解析以及模塊依賴的解析,那麼這個階段的 Hook 對於代碼的操作粒度一般爲模塊
級別,也就是單文件級別。 -
Ouput Hook
(官方稱爲Output Generation Hook
),則主要進行代碼的打包,對於代碼而言,操作粒度一般爲chunk
級別 (一個 chunk 通常指很多文件打包到一起的產物)。
除了根據構建階段可以將 Rollup 插件進行分類,根據不同的 Hook 執行方式也會有不同的分類,主要包括Async
、Sync
、Parallel
、Squential
、First
這五種。在後文中我們將接觸各種各樣的插件 Hook,但無論哪個 Hook 都離不開這五種執行方式。
1. Async & Sync
首先是Async
和Sync
鉤子函數,兩者其實是相對的,分別代表異步
和同步
的鉤子函數,這個很好理解。
2. Parallel
這裏指並行的鉤子函數。如果有多個插件實現了這個鉤子的邏輯,一旦有鉤子函數是異步邏輯,則併發執行鉤子函數,不會等待當前鉤子完成 (底層使用 Promise.all
)。
比如對於Build
階段的buildStart
鉤子,它的執行時機其實是在構建剛開始的時候,各個插件可以在這個鉤子當中做一些狀態的初始化操作,但其實插件之間的操作並不是相互依賴的,也就是可以併發執行,從而提升構建性能。反之,對於需要依賴其他插件處理結果的情況就不適合用 Parallel
鉤子了,比如 transform
。
3. Sequential
Sequential 指串行的鉤子函數。這種 Hook 往往適用於插件間處理結果相互依賴的情況,前一個插件 Hook 的返回值作爲後續插件的入參,這種情況就需要等待前一個插件執行完 Hook,獲得其執行結果,然後才能進行下一個插件相應 Hook 的調用,如transform
。
4. First
如果有多個插件實現了這個 Hook,那麼 Hook 將依次運行,直到返回一個非 null 或非 undefined 的值爲止。比較典型的 Hook 是 resolveId
,一旦有插件的 resolveId 返回了一個路徑,將停止執行後續插件的 resolveId 邏輯。
通用 hook
以下鉤子在服務器啓動時被調用:
-
options
-
buildStart
以下鉤子會在每個傳入模塊請求時被調用:
-
resolveId
-
load
-
transform
它們還有一個擴展的 options
參數,包含其他特定於 Vite 的屬性。
以下鉤子在服務器關閉時被調用:
-
buildEnd
-
closeBundle
請注意 moduleParsed
鉤子在開發中是 不會 被調用的,因爲 Vite 爲了性能會避免完整的 AST 解析。
output
階段的 hook(除了 closeBundle
) 在開發中是 不會 被調用的。你可以認爲 Vite 的開發服務器只調用了 rollup.rollup()
而沒有調用 bundle.generate()
。
Vite 獨有 hook
Vite 插件也可以提供鉤子來服務於特定的 Vite 目標。當然這些鉤子會被 Rollup 忽略。
config
❝
在解析 Vite 配置前調用。鉤子接收原始用戶配置(命令行選項指定的會與配置文件合併)和一個描述配置環境的變量,包含正在使用的
mode
和command
。它可以返回一個將被深度合併到現有配置中的部分配置對象,或者直接改變配置(如果默認的合併不能達到預期的結果)。
// 返回部分配置(推薦)
const partialConfigPlugin = () => ({
name: 'nanjiu-plugin',
config(config, { command }) {
console.log('config', config, command)
}
})
需要注意的是:用戶插件在運行這個鉤子之前會被解析,因此在 config
鉤子中注入其他插件不會有任何效果。
configResolved
❝
在解析 Vite 配置後調用。使用這個鉤子讀取和存儲最終解析的配置。
const examplePlugin = () => {
let config
return {
name: 'read-config',
configResolved(resolvedConfig) {
// 存儲最終解析的配置
config = resolvedConfig
},
// 在其他鉤子中使用存儲的配置
transform(code, id) {
if (config.command === 'serve') {
// dev: 由開發服務器調用的插件
} else {
// build: 由 Rollup 調用的插件
}
},
}
}
configureServer
❝
是用於配置開發服務器的鉤子。最常見的用例是在內部 connect 應用程序中添加自定義中間件
const myPlugin = () => ({
name: 'configure-server',
configureServer(server) {
server.middlewares.use((req, res, next) => {
// 自定義請求處理...
})
},
})
configurePreviewServer
❝
與
configureServer
相同,但用於預覽服務器。configurePreviewServer
這個鉤子與configureServer
類似,也是在其他中間件安裝前被調用。如果你想要在其他中間件 之後 安裝一個插件,你可以從configurePreviewServer
返回一個函數,它將會在內部中間件被安裝之後再調用
const myPlugin = () => ({
name: 'configure-preview-server',
configurePreviewServer(server) {
// 返回一個鉤子,會在其他中間件安裝完成後調用
return () => {
server.middlewares.use((req, res, next) => {
// 自定義處理請求 ...
})
}
},
})
transformIndexHtml
❝
轉換
index.html
的專用鉤子。鉤子接收當前的 HTML 字符串和轉換上下文。上下文在開發期間暴露ViteDevServer
實例,在構建期間暴露 Rollup 輸出的包。
這個鉤子可以是異步的,並且可以返回以下其中之一:
-
經過轉換的 HTML 字符串
-
注入到現有 HTML 中的標籤描述符對象數組(
{ tag, attrs, children }
)。每個標籤也可以指定它應該被注入到哪裏(默認是在<head>
之前) -
一個包含
{ html, tags }
的對象
默認情況下 order
是 undefined
,這個鉤子會在 HTML 被轉換後應用。爲了注入一個應該通過 Vite 插件管道的腳本, order: 'pre'
指將在處理 HTML 之前應用。order: 'post'
是在所有未定義的 order
的鉤子函數被應用後才應用。
const htmlPlugin = () => {
return {
name: 'nanjiu-plugin',
transformIndexHtml(html) {
return html.replace(/<title>(.*?)<\/title>/,
`<title> nanjiu plugin </title>`)
},
}
}
handleHotUpdate
❝
執行自定義 HMR 更新處理。鉤子接收一個帶有以下簽名的上下文對象
interface HmrContext {
file: string
timestamp: number
modules: Array<ModuleNode>
read: () => string | Promise<string>
server: ViteDevServer
}
-
modules
是受更改文件影響的模塊數組。它是一個數組,因爲單個文件可能映射到多個服務模塊(例如 Vue 單文件組件)。 -
read
這是一個異步讀函數,它返回文件的內容。之所以這樣做,是因爲在某些系統上,文件更改的回調函數可能會在編輯器完成文件更新之前過快地觸發,並fs.readFile
直接會返回空內容。傳入的read
函數規範了這種行爲。
const hotPlugin = () => {
return {
name: 'nanjiu-plugin',
handleHotUpdate({ server, modules, timestamp}) {
console.log('handleHotUpdate', modules)
},
}
}
當我修改App.vue
文件時,modules
可以獲取到如下信息:
插件順序
一個 Vite 插件可以額外指定一個 enforce
屬性(類似於 webpack 加載器)來調整它的應用順序。enforce
的值可以是pre
或 post
。解析後的插件將按照以下順序排列:
-
Alias
-
帶有
enforce: 'pre'
的用戶插件 -
Vite 核心插件
-
沒有 enforce 值的用戶插件
-
Vite 構建用的插件
-
帶有
enforce: 'post'
的用戶插件 -
Vite 後置構建插件
請注意,這與鉤子的排序是分開的,鉤子的順序仍然會受到它們的 order
屬性的影響,這一點 和 Rollup 鉤子的表現一樣
總結
vite 在 開發環境中,會使用 createPluginContainer
方法創建插件容器,插件容器有兩個核心功能:管理插件生命週期、傳遞插件上下文
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1gVnUIHUI9X2VkS3ci0RQw