本文由華為云云嶺團隊鬆塔同學分享~
江湖上一直流傳一種説法:Rollup 的插件系統設計,相比與 webpack,要更加科學順手。(網絡上對 webpack 插件編寫的吐槽不計其數)Talk is cheap,本文基於 unplugin 這個三方庫來對比研究一下二者的插件系統。Unplugin 是一個插件編寫工具,它可以讓開發者用一套代碼同時為主流 bundler 編寫插件,包括 webpack、Rollup、Vite、esbuild、Rspack。
Unplugin hooks
Unplugin 以 Rollup 的 hooks 為基礎,總共有 9 個生命週期鈎子函數,其中包含 6 個 build hooks,1 個 output generation hook,和 2 個獨立 hooks。藉此我們可以大致瞭解不同 bundler 之間的通用能力。下面將簡要介紹包含 unplugin 自身邏輯的鈎子函數,其餘請參考 Rollup 官方文檔。
buildStart 和 buildEnd
與 Rollup 的鈎子函數相同,分別代表一次 build 準備開始和 build 結束。不同的是 unplugin 將函數的 this 指向了自身定義的UnpluginBuildContext:
export interface UnpluginBuildContext {
addWatchFile: (id: string) => void
emitFile: (emittedFile: EmittedAsset) => void
getWatchFiles: () => string[]
parse: (input: string, options?: any) => AcornNode
}
該上下文提供了四個方法,unplugin 為每個 bundler 都實現了一遍,按需使用。
loadInclude 和 transformInclude
專門為 webpack 適配的鈎子函數,用來過濾需要 load 或者 transform 的模塊。由於 webpack loader 和 plugin 分離的設計,load 和 transform 的功能實際被 loader 所承載。如果沒有過濾函數,會導致所有模塊都被插件加載,影響 webpack 性能。
Unplugin Webpack模塊實現
深入看 unplugin 對 webpack 模塊的實現,可以觀察到 Rollup 類的鈎子函數是如何轉換到 webpack 系統中的。
首先了解 webpack plugin 的設計。官方文檔中給出的示例比較傳統:一個具有 apply 函數的 class,通過 constructor 接收用户對插件的自定義設置。實際上,webpack 只需要一個帶有 apply 方法的對象就夠了。Unplugin 還額外包了一層生成函數,將用户配置傳遞到每個 bundler 的插件定義函數中,此外還提供了meta參數表明它要為哪個 bunlder 生成插件:
export function getWebpackPlugin<UserOptions = {}>(
factory: UnpluginFactory<UserOptions>,
): UnpluginInstance<UserOptions>['webpack'] {
return (userOptions?: UserOptions) => {
return {
apply(compiler: WebpackCompiler) {
// implementation
}
}
}
代碼中factory就是定義插件在各生命週期中執行具體邏輯的函數,例如:
(options, meta) => {
return {
load() {
// load 鈎子函數
}
}
}
在執行鈎子函數之前,有一系列初始化工作。首先在 webpack compiler 中注入自身上下文。
const injected = compiler.$unpluginContext || {}
compiler.$unpluginContext = injected
接着調用factory函數拿到插件定義:
const rawPlugins = toArray(factory(userOptions!, meta))
unplugin 支持多個插件同時定義,所以這裏統一用toArray轉換成數組處理。然後遍歷數組,給插件增加公共屬性:
const plugin = Object.assign(
rawPlugin,
{
__unpluginMeta: meta,
__virtualModulePrefix: VIRTUAL_MODULE_PREFIX,
},
) as ResolvedUnpluginOptions
// inject context object to share with loaders
injected[plugin.name] = plugin
compiler.hooks.thisCompilation.tap(plugin.name, (compilation) => {
compilation.hooks.childCompiler.tap(plugin.name, (childCompiler) => {
childCompiler.$unpluginContext = injected
})
})
注意這裏給 childCompiler 也同樣注入了上下文。這一系列注入上下文的動作,是讓整個 webpack 都能拿到插件的定義。這在 webpack loader 中拿到 plugin 的定義是有作用的,因為 loader 定義中,它只是一個接受 source code 的函數,然後返回轉譯過的 source code。通過全局注入,我們就能在 loader 的定義函數中拿到 plugin 的load函數和transform函數。
接下來按照鈎子函數的執行順序,逐一解析其源碼。
buildStart
if (plugin.watchChange || plugin.buildStart) {
compiler.hooks.make.tapPromise(plugin.name, async (compilation) => {
const context = createContext(compilation)
if (plugin.watchChange && (compiler.modifiedFiles || compiler.removedFiles)) {
// implementation
}
if (plugin.buildStart)
return await plugin.buildStart.call(context)
})
}
buildStart與watchChange被放在一起處理,因為他們都要用到上下文。具體看buildStart,僅僅提供context並執行plugin.buildStart。對應到 webpack 插件生命週期是make。查閲 webpack 文檔我們可以發現,unplugin 略過了一系列 webpack 初始化的鈎子函數,例如讀取 config,初始化 compiler,調用插件等等。因為這些是 webpack 的自有邏輯,和 Rollup 也無法兼容。make會在一次 compliation 創建完後觸發,即將開始從 entry 讀取文件。符合 Rollup 的buildStart定義。
watchChange
watchChange是獨立於執行順序之外的鈎子函數。當 bundler 以 watch 模式運行時,當被監測的文件發生變化時觸發。在 webpack 中,unplugin 利用了 compiler 的modifiedFiles和removedFiles來獲取對應的文件。由於每次文件變化 Webpack 都會重新執行一次 compilation,因此modifiedFiles和removedFiles也對應更新。
modifiedFiles是 Webpack 5 新增的屬性。
resolveId
Rollup 的resolveId存在三個入參source, importer, options :
type ResolveIdHook = (
source: string,
importer: string | undefined,
options: {
assertions: Record<string, string>;
custom?: { [plugin: string]: any };
isEntry: boolean;
}
) => ResolveIdResult;
Webpack 中 resolve 相關概念位於 config 中的 resolve 對象,比較常見的設置如 alias。Webpack 對 resolve 專門提供了一個插件的設置,它不同於普通的 plugin,屬於ResolvePluginInstance,unplugin 利用這個設置傳入resolveId函數。
resolver
.getHook('resolve')
.tapAsync(plugin.name, async (request, resolveContext, callback) => {
if (!request.request)
return callback()
// filter out invalid requests
if (normalizeAbsolutePath(request.request).startsWith(plugin.__virtualModulePrefix))
return callback()
const id = normalizeAbsolutePath(request.request)
const requestContext = (request as unknown as { context: { issuer: string } }).context
const importer = requestContext.issuer !== '' ? requestContext.issuer : undefined
const isEntry = requestContext.issuer === ''
// call hook
const resolveIdResult = await plugin.resolveId!(id, importer, { isEntry })
// ...
}
// ...
compiler.options.resolve.plugins = compiler.options.resolve.plugins || []
compiler.options.resolve.plugins.push(resolverPlugin)
可以看到id和importer都來自於resolve這個鈎子函數傳入的參數,可惜在 webpack 文檔中缺乏相關説明。options參數中,只提供了isEntry屬性。最後我們看到resolverPlugin被手動創建出來後,放進了 compiler options 中。可見 webpack 插件的能力包括修改 config 文件,能力其實完全覆蓋了 loader,這在後續的load和transform函數中同樣能見到。
從源碼中我們會看到 virtual module 相關的代碼,本文為簡化場景會略過。下同。
load
Webpack 中 loader 定義在 config 中,例如:
module.exports = {
module: {
rules: [{ test: /.txt$/, use: 'raw-loader' }],
},
};
用正則表示文件類型,然後指定 loader。Unplugin 通過手動實現一個 loader,然後插入 rules 來實現load的功能:
if (plugin.load) {
compiler.options.module.rules.unshift({
include(id) {
if (id.startsWith(plugin.__virtualModulePrefix))
id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))
// load include filter
if (plugin.loadInclude && !plugin.loadInclude(id)) return false
// Don't run load hook for external modules
return !externalModules.has(id)
},
enforce: plugin.enforce,
use: [
{
loader: LOAD_LOADER,
options: {
unpluginName: plugin.name,
},
},
],
})
}
Loader 除了用test正則匹配外,也支持用函數過濾,以被 import 的資源 path 為入參,這正是loadInclude的設計來源。從上述代碼中我還會發現一個屬性enforce,它是用來控制 loader 執行時機的。這也是 unplugin 要用unshift插入 rules 數組的原因。(默認最後加載 unplugin 插件)
具體看下LOAD_LOADER實現:
export default async function load(this: LoaderContext<any>, source: string, map: any) {
const callback = this.async()
const { unpluginName } = this.query
const plugin = this._compiler?.$unpluginContext[unpluginName]
let id = this.resource
if (!plugin?.load || !id)
return callback(null, source, map)
const context: UnpluginContext = {
error: error => this.emitError(typeof error === 'string' ? new Error(error) : error),
warn: error => this.emitWarning(typeof error === 'string' ? new Error(error) : error),
}
if (id.startsWith(plugin.__virtualModulePrefix))
id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))
const res = await plugin.load.call(
Object.assign(this._compilation && createContext(this._compilation) as any, context),
normalizeAbsolutePath(id),
)
if (res == null)
callback(null, source, map)
else if (typeof res !== 'string')
callback(null, res.code, res.map ?? map)
else
callback(null, res, map)
}
通過 webpack 上下文,可以拿到資源 id、找到對應 unplugin 插件。接着就是提供 unplugin 自身上下文然後調用load函數。根據 Rollup 對load返回結果的定義,調用callback傳參。
transform
transform 函數和load函數類似,同樣是自定義一個 loader,然後插入rules。唯一的區別是處理 transform 邏輯時,沒有用到 include 函數,而是在 use 函數中再執行transformInclude進行過濾。(這是令人困惑的地方,因為這和前文所述 unplugin 設計transformInclude的理由矛盾。沒有 include 函數會導致所有模塊都被插件加載)
Unplugin會先處理 transform 邏輯,由於用 unshift 插入 rules,會導致load生成的 rule 在transform 之前,按照 webpack 默認的加載 loader 順序,transform 會先於 load 被觸發。不知是 bug 還是 unplugin 的預期行為。
buildEnd
buildEnd對應 webpack 的emit鈎子函數。
if (plugin.buildEnd) {
compiler.hooks.emit.tapPromise(plugin.name, async (compilation) => {
await plugin.buildEnd!.call(createContext(compilation))
})
}
writeBundle
writeBundle對應 webpack 的afterEmit鈎子函數。沒有任何傳參和上下文的調用,意味着拿不到所有 bundler 創建出的文件。
if (plugin.writeBundle) {
compiler.hooks.afterEmit.tap(plugin.name, () => {
plugin.writeBundle!()
})
}
總結
我們可以發現 unplugin 實際用到的 webpack hooks 只有三個:make, emit,afterEmit。load和transform的功能由 webpack loader 所承載。make對 webpack 是一個很關鍵鈎子函數,它表明了 webpack 一系列初始化的工作已完成,開始從入口文件出發編譯每一個模塊。
webpack 的插件系統對比 rollup 來説,一大特點是鈎子函數特別多。除了本文提到的 compiler 具有鈎子函數外,包括 compilation、ContextModuleFactory、 NormalModuleFactory、甚至 JavaScript 的 parser 都有一系列鈎子函數。同時 plugin、loader、resolver 分離的設計也增加了系統的複雜度。因此網絡上的觀點更偏愛於 rollup 也不無道理。複雜的系統不代表不好,但是無疑增加了用户的學習成本。
關於 OpenTiny
OpenTiny 是一套企業級組件庫解決方案,適配 PC 端 / 移動端等多端,涵蓋 Vue2 / Vue3 / Angular 多技術棧,擁有主題配置系統 / 中後台模板 / CLI 命令行等效率提升工具,可幫助開發者高效開發 Web 應用。
核心亮點:
跨端跨框架:使用 Renderless 無渲染組件設計架構,實現了一套代碼同時支持 Vue2 / Vue3,PC / Mobile 端,並支持函數級別的邏輯定製和全模板替換,靈活性好、二次開發能力強。組件豐富:PC 端有80+組件,移動端有30+組件,包含高頻組件 Table、Tree、Select 等,內置虛擬滾動,保證大數據場景下的流暢體驗,除了業界常見組件之外,我們還提供了一些獨有的特色組件,如:Split 面板分割器、IpAddress IP地址輸入框、Calendar 日曆、Crop 圖片裁切等配置式組件:組件支持模板式和配置式兩種使用方式,適合低代碼平台,目前團隊已經將 OpenTiny 集成到內部的低代碼平台,針對低碼平台做了大量優化周邊生態齊全:提供了基於 Angular + TypeScript 的 TinyNG 組件庫,提供包含 10+ 實用功能、20+ 典型頁面的 TinyPro 中後台模板,提供覆蓋前端開發全流程的 TinyCLI 工程化工具,提供強大的在線主題配置平台 TinyTheme
聯繫我們:
- 官方公眾號:
OpenTiny - OpenTiny 官網:https://opentiny.design/
- OpenTiny 代碼倉庫:https://github.com/opentiny/
- Vue 組件庫:https://github.com/opentiny/tiny-vue (歡迎 Star)
- Angluar組件庫:https://github.com/opentiny/ng (歡迎 Star)
- CLI工具:https://github.com/opentiny/tiny-cli (歡迎 Star)
更多視頻內容也可以關注OpenTiny社區,B站/抖音/小紅書/視頻號。