從 0 到 1 解讀 rollup Plugin
左琳,微醫前端技術部前端開發工程師。超~能喫,喜歡游泳健身和跳舞,熱愛生活與技術。
rollup plugin 這篇文章讀讀改改,終於和大家見面啦~~~
儘管對於 rollup 的插件編寫,官網上對於 rolup 插件的介紹幾乎都是英文,學習起來不是很友好, 例子也相對較少,但目前針對 rollup 插件的分析與開發指南的文章已經不少見,以關於官方英文文檔的翻譯與函數鉤子的分析爲主。
講道理,稀裏糊塗直接看源碼分析只會分分鐘勸退我,而我只想分分鐘寫個 rollup 插件而已~~
rollup 爲什麼需要 Plugin
rollup -c 打包流程
在 rollup 的打包流程中,通過相對路徑,將一個入口文件和一個模塊創建成了一個簡單的 bundle。隨着構建更復雜的 bundle,通常需要更大的靈活性——引入 npm 安裝的模塊、通過 Babel 編譯代碼、和 JSON 文件打交道等。通過 rollup -c 打包的實現流程可以參考下面的流程圖理解。
爲此,我們可以通過 插件 (plugins) 在打包的關鍵過程中更改 Rollup 的行爲。
這其實和 webpack 的插件相類似,不同的是,webpack 區分 loader 和 plugin,而 rollup 的 plugin 既可以擔任 loader 的角色,也可以勝任傳統 plugin 的角色。
理解 rollup plugin
引用官網的解釋:
Rollup 插件是一個具有下面描述的一個或多個屬性、構建鉤子和輸出生成鉤子的對象,它遵循我們的約定。一個插件應該作爲一個包來分發,該包導出一個可以用插件特定選項調用的函數,並返回這樣一個對象。插件允許你定製 Rollup 的行爲,例如,在捆綁之前編譯代碼,或者在你的 node_modules 文件夾中找到第三方模塊。
簡單來說,rollup 的插件是一個普通的函數,函數返回一個對象,該對象包含一些屬性 (如 name),和不同階段的鉤子函數(構建 build 和輸出 output 階段),此處應該回顧下上面的流程圖。
關於約定
-
插件應該有一個帶有 rollup-plugin - 前綴的明確名稱。
-
在 package.json 中包含 rollup-plugin 關鍵字。
-
插件應該支持測試,推薦 mocha 或者 ava 這類開箱支持 promises 的庫。
-
儘可能使用異步方法。
-
用英語記錄你的插件。
-
確保你的插件輸出正確的 sourcemap。
-
如果你的插件使用'virtual modules'(比如幫助函數),給模塊名加上
\0
前綴。這可以阻止其他插件執行它。
分分鐘寫個 rollup 插件
爲了保持學習下去的熱情與動力,先舉個栗子壓壓驚,如果看到插件實現的各種源碼函數鉤子部分覺得腦子不清醒了,歡迎隨時回來重新看這一小節,重拾勇氣與信心!
插件其實很簡單
可以打開 rollup 插件列表,隨便找個你感興趣的插件,看下源代碼。
有不少插件都是幾十行,不超過 100 行的。比如圖片文件多格式支持插件 @rollup/plugin-image 的代碼甚至不超過 50 行,而將 json 文件轉換爲 ES6 模塊的插件 @rollup/plugin-json 源代碼更少。
一個例子
// 官網的一個例子
export default function myExample () {
return {
name: 'my-example', // 名字用來展示在警告和報錯中
resolveId ( source ) {
if (source === 'virtual-module') {
return source; // rollup 不應該查詢其他插件或文件系統
}
return null; // other ids 正常處理
},
load ( id ) {
if (id === 'virtual-module') {
return 'export default "This is virtual!"'; // source code for "virtual-module"
}
return null; // other ids
}
};
}
// rollup.config.js
import myExample from './rollup-plugin-my-example.js';
export default ({
input: 'virtual-module', // 配置 virtual-module 作爲入口文件滿足條件通過上述插件處理
plugins: [myExample()],
output: [{
file: 'bundle.js',
format: 'es'
}]
});
光看不練假把式,模仿寫一個:
// 自己編的一個例子 QAQ
export default function bundleReplace () {
return {
name: 'bundle-replace', // 名字用來展示在警告和報錯中
transformBundle(bundle) {
return bundle
.replace('key_word', 'replace_word')
.replace(/正則/, '替換內容');
},
};
}
// rollup.config.js
import bundleReplace from './rollup-plugin-bundle-replace.js';
export default ({
input: 'src/main.js', // 通用入口文件
plugins: [bundleReplace()],
output: [{
file: 'bundle.js',
format: 'es'
}]
});
嘿!這也不難嘛~~~
rollup plugin 功能的實現
我們要講的 rollup plugin 也不可能就這麼簡單啦~~~
接下來當然是結合例子分析實現原理~~
其實不難發現,rollup 的插件配置與 webpack 等框架中的插件使用大同小異,都是提供配置選項,注入當前構建結果相關的屬性與方法,供開發者進行增刪改查操作。
那麼插件寫好了,rollup 是如何在打包過程中調用它並實現它的功能的呢?
相關概念
首先還是要了解必備的前置知識,大致瀏覽下 rollup 中處理 plugin 的方法,基本可以定位到 PluginContext.ts(上下文相關)、PluginDriver.ts(驅動相關)、PluginCache.ts(緩存相關)和 PluginUtils.ts(警告錯誤異常處理)等文件,其中最關鍵的就在 PluginDriver.ts 中了。
首先要清楚插件驅動的概念,它是實現插件提供功能的的核心 -- PluginDriver,插件驅動器,調用插件和提供插件環境上下文等。
鉤子函數的調用時機
大家在研究 rollup 插件的時候,最關注的莫過於鉤子函數部分了,鉤子函數的調用時機有三類:
-
const chunks = rollup.rollup 執行期間的構建鉤子函數 - Build Hooks
-
chunks.generator(write) 執行期間的輸出鉤子函數 - Output Generation Hooks
-
監聽文件變化並重新執行構建的 rollup.watch 執行期間的 watchChange 鉤子函數
鉤子函數處理方式分類
除了以調用時機來劃分鉤子函數以外,我們還可以以鉤子函數處理方式來劃分,這樣來看鉤子函數就主要有以下四種版本:
-
async: 處理 promise 的異步鉤子,即這類 hook 可以返回一個解析爲相同類型值的 promise,同步版本 hook 將被標記爲
sync
。 -
first: 如果多個插件實現了相同的鉤子函數,那麼會串式執行,從頭到尾,但是,如果其中某個的返回值不是 null 也不是 undefined 的話,會直接終止掉後續插件。
-
sequential: 如果多個插件實現了相同的鉤子函數,那麼會串式執行,按照使用插件的順序從頭到尾執行,如果是異步的,會等待之前處理完畢,在執行下一個插件。
-
parallel: 同上,不過如果某個插件是異步的,其後的插件不會等待,而是並行執行,這個也就是我們在 rollup.rollup() 階段看到的處理方式。
構建鉤子函數
爲了與構建過程交互,你的插件對象需要包含一些構建鉤子函數。構建鉤子是構建的各個階段調用的函數。構建鉤子函數可以影響構建執行方式、提供構建的信息或者在構建完成後修改構建。rollup 中有不同的構建鉤子函數,在構建階段執行時,它們被 [rollup.rollup(inputOptions)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/cli/run/build.ts#L34)
觸發。
構建鉤子函數主要關注在 Rollup 處理輸入文件之前定位、提供和轉換輸入文件。構建階段的第一個鉤子是 options
,最後一個鉤子總是 buildEnd
,除非有一個構建錯誤,在這種情況下 closeBundle
將在這之後被調用。
順便提一下,在觀察模式下,watchChange
鉤子可以在任何時候被觸發,以通知新的運行將在當前運行產生其輸出後被觸發。當 watcher 關閉時,closeWatcher 鉤子函數將被觸發。
輸出鉤子函數
輸出生成鉤子函數可以提供關於生成的包的信息並在構建完成後立馬執行。它們和構建鉤子函數擁有一樣的工作原理和相同的類型,但是不同的是它們分別被 ·[bundle.generate(output)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/cli/run/build.ts#L44)
或 [bundle.write(outputOptions)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/cli/run/build.ts#L64)
調用。只使用輸出生成鉤子的插件也可以通過輸出選項傳入,因爲只對某些輸出運行。
輸出生成階段的第一個鉤子函數是 outputOptions,如果輸出通過 bundle.generate(...) 成功生成則第一個鉤子函數是 generateBundle,如果輸出通過 [bundle.write(...)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/src/watch/watch.ts#L200)
生成則最後一個鉤子函數是 [writeBundle](https://github.com/rollup/rollup/blob/master/src/rollup/rollup.ts#L176)
,另外如果輸出生成階段發生了錯誤的話,最後一個鉤子函數則是 renderError。
另外,closeBundle 可以作爲最後一個鉤子被調用,但用戶有責任手動調用 bundle.close()
來觸發它。CLI 將始終確保這種情況發生。
以上就是必須要知道的概念了,讀到這裏好像還是看不明白這些鉤子函數到底是幹啥的!那麼接下來進入正題!
鉤子函數加載實現
[PluginDriver](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/src/utils/PluginDriver.ts#L124)
中有 9 個 hook 加載函數。主要是因爲每種類別的 hook 都有同步和異步的版本。
接下來先康康 9 個 hook 加載函數及其應用場景(看完第一遍不知所以然,但是別人看了咱也得看,先看了再說,看不懂就多看幾遍 QAQ~)
排名不分先後,僅參考它們在 PluginDriver.ts 中出現的順序🌠。
1. hookFirst
加載 first
類型的鉤子函數,場景有 resolveId
、resolveAssetUrl
等,在實例化 Graph 的時候,初始化初始化 promise 和 this.plugins,並通過覆蓋之前的 promise,實現串行執行鉤子函數。當多個插件實現了相同的鉤子函數時從頭到尾串式執行,如果其中某個的返回值不是 null 也不是 undefined 的話,就會直接終止掉後續插件。
function hookFirst<H extends keyof PluginHooks, R = ReturnType<PluginHooks[H]>>(
hookName: H,
args: Args<PluginHooks[H]>,
replaceContext?: ReplaceContext | null,
skip?: number | null
): EnsurePromise<R> {
// 初始化 promise
let promise: Promise<any> = Promise.resolve();
// 實例化 Graph 的時候,初始化 this.plugins
for (let i = 0; i < this.plugins.length; i++) {
if (skip === i) continue;
// 覆蓋之前的 promise,即串行執行鉤子函數
promise = promise.then((result: any) => {
// 返回非 null 或 undefined 的時候,停止運行,返回結果
if (result != null) return result;
// 執行鉤子函數
return this.runHook(hookName, args as any[], i, false, replaceContext);
});
}
// 返回 hook 過的 promise
return promise;
}
2. hookFirstSync
hookFirst 的同步版本,使用場景有 resolveFileUrl
、resolveImportMeta
等。
function hookFirstSync<H extends keyof PluginHooks, R = ReturnType<PluginHooks[H]>>(
hookName: H,
args: Args<PluginHooks[H]>,
replaceContext?: ReplaceContext
): R {
for (let i = 0; i < this.plugins.length; i++) {
// runHook 的同步版本
const result = this.runHookSync(hookName, args, i, replaceContext);
// 返回非 null 或 undefined 的時候,停止運行,返回結果
if (result != null) return result as any;
}
// 否則返回 null
return null as any;
}
3. hookParallel
並行執行 hook,不會等待當前 hook 完成。也就是說如果某個插件是異步的,其後的插件不會等待,而是並行執行。使用場景 buildEnd
、buildStart
、moduleParsed
等。
hookParallel<H extends AsyncPluginHooks & ParallelPluginHooks>(
hookName: H,
args: Parameters<PluginHooks[H]>,
replaceContext?: ReplaceContext
): Promise<void> {
const promises: Promise<void>[] = [];
for (const plugin of this.plugins) {
const hookPromise = this.runHook(hookName, args, plugin, false, replaceContext);
if (!hookPromise) continue;
promises.push(hookPromise);
}
return Promise.all(promises).then(() => {});
}
4.hookReduceArg0
對 arg 第一項進行 reduce 操作。使用場景: options
、renderChunk
等。
function hookReduceArg0<H extends keyof PluginHooks, V, R = ReturnType<PluginHooks[H]>>(
hookName: H,
[arg0, ...args]: any[], // 取出傳入的數組的第一個參數,將剩餘的置於一個數組中
reduce: Reduce<V, R>,
replaceContext?: ReplaceContext // 替換當前 plugin 調用時候的上下文環境
) {
let promise = Promise.resolve(arg0); // 默認返回 source.code
for (let i = 0; i < this.plugins.length; i++) {
// 第一個 promise 的時候只會接收到上面傳遞的 arg0
// 之後每一次 promise 接受的都是上一個插件處理過後的 source.code 值
promise = promise.then(arg0 => {
const hookPromise = this.runHook(hookName, [arg0, ...args], i, false, replaceContext);
// 如果沒有返回 promise,那麼直接返回 arg0
if (!hookPromise) return arg0;
// result 代表插件執行完成的返回值
return hookPromise.then((result: any) =>
reduce.call(this.pluginContexts[i], arg0, result, this.plugins[i])
);
});
}
return promise;
}
5.hookReduceArg0Sync
hookReduceArg0
同步版本,使用場景 transform
、generateBundle
等,不做贅述。
6. hookReduceValue
將返回值減少到類型 T,分別處理減少的值。允許鉤子作爲值。
hookReduceValue<H extends PluginValueHooks, T>(
hookName: H,
initialValue: T | Promise<T>,
args: Parameters<AddonHookFunction>,
reduce: (
reduction: T,
result: ResolveValue<ReturnType<AddonHookFunction>>,
plugin: Plugin
) => T,
replaceContext?: ReplaceContext
): Promise<T> {
let promise = Promise.resolve(initialValue);
for (const plugin of this.plugins) {
promise = promise.then(value => {
const hookPromise = this.runHook(hookName, args, plugin, true, replaceContext);
if (!hookPromise) return value;
return hookPromise.then(result =>
reduce.call(this.pluginContexts.get(plugin), value, result, plugin)
);
});
}
return promise;
}
7. hookReduceValueSync
hookReduceValue 的同步版本。
8. hookSeq
加載 sequential
類型的鉤子函數,和 hookFirst 的區別就是不能中斷,使用場景有 onwrite
、generateBundle
等。
async function hookSeq<H extends keyof PluginHooks>(
hookName: H,
args: Args<PluginHooks[H]>,
replaceContext?: ReplaceContext,
// hookFirst 通過 skip 參數決定是否跳過某個鉤子函數
): Promise<void> {
let promise: Promise<void> = Promise.resolve();
for (let i = 0; i < this.plugins.length; i++)
promise = promise.then(() =>
this.runHook<void>(hookName, args as any[], i, false, replaceContext),
);
return promise;
}
9.hookSeqSync
hookSeq 同步版本,不需要構造 promise,而是直接使用 runHookSync
執行鉤子函數。使用場景有 closeWatcher
、watchChange
等。
hookSeqSync<H extends SyncPluginHooks & SequentialPluginHooks>(
hookName: H,
args: Parameters<PluginHooks[H]>,
replaceContext?: ReplaceContext
): void {
for (const plugin of this.plugins) {
this.runHookSync(hookName, args, plugin, replaceContext);
}
}
通過觀察上面幾種鉤子函數的調用方式,我們可以發現,其內部有一個調用鉤子函數的方法: runHook(Sync)(當然也分同步和異步版本),該函數真正執行插件中提供的鉤子函數。
也就是說,之前介紹了那麼多的鉤子函數,僅僅決定了我們插件的調用時機和調用方式 (比如同步 / 異步),而真正調用並執行插件函數(前面提到插件本身是個「函數」) 的鉤子其實是 runHook 。
runHook(Sync)
真正執行插件的鉤子函數,同步版本和異步版本的區別是有無 permitValues 許可標識允許返回值而不是隻允許返回函數。
function runHook<T>(
hookName: string,
args: any[],
pluginIndex: number,
permitValues: boolean,
hookContext?: ReplaceContext | null,
): Promise<T> {
this.previousHooks.add(hookName);
// 找到當前 plugin
const plugin = this.plugins[pluginIndex];
// 找到當前執行的在 plugin 中定義的 hooks 鉤子函數
const hook = (plugin as any)[hookName];
if (!hook) return undefined as any;
// pluginContexts 在初始化 plugin 驅動器類的時候定義,是個數組,數組保存對應着每個插件的上下文環境
let context = this.pluginContexts[pluginIndex];
// 用於區分對待不同鉤子函數的插件上下文
if (hookContext) {
context = hookContext(context, plugin);
}
return Promise.resolve()
.then(() => {
// 允許返回值,而不是一個函數鉤子,使用 hookReduceValue 或 hookReduceValueSync 加載。
// 在 sync 同步版本鉤子函數中,則沒有 permitValues 許可標識允許返回值
if (typeof hook !== 'function') {
if (permitValues) return hook;
return error({
code: 'INVALID_PLUGIN_HOOK',
message: `Error running plugin hook ${hookName} for ${plugin.name}, expected a function hook.`,
});
}
// 傳入插件上下文和參數,返回插件執行結果
return hook.apply(context, args);
})
.catch(err => throwPluginError(err, plugin.name, { hook: hookName }));
}
看完這些鉤子函數介紹,我們清楚了插件的調用時機、調用方式以及執行輸出鉤子函數。但你以爲這就結束了??當然沒有結束我們還要把這些鉤子再帶回 rollup 打包流程康康一下調用時機和調用方式的實例~~
rollup.rollup()
又回到最初的起點~~~
前面提到過,構建鉤子函數在 Rollup 處理輸入文件之前定位、提供和轉換輸入文件。那麼當然要先從輸入開始看起咯~
build 階段
處理 inputOptions
// 從處理 inputOptions 開始,你的插件鉤子函數已到達!
const { options: inputOptions, unsetOptions: unsetInputOptions } = await getInputOptions(
rawInputOptions,
watcher !== null
);
朋友們,把 async、first、sequential 和 parallel 以及 9 個鉤子函數帶上開搞!
// 處理 inputOptions 的應用場景下調用了 options 鉤子
function applyOptionHook(watchMode: boolean) {
return async ( // 異步串行執行
inputOptions: Promise<GenericConfigObject>,
plugin: Plugin
): Promise<GenericConfigObject> => {
if (plugin.options) { // plugin 配置存在
return (
((await plugin.options.call(
{ meta: { rollupVersion, watchMode } }, // 上下文
await inputOptions
)) as GenericConfigObject) || inputOptions
);
}
return inputOptions;
};
}
接着標準化插件
// 標準化插件
function normalizePlugins(plugins: Plugin[], anonymousPrefix: string): void {
for (let pluginIndex = 0; pluginIndex < plugins.length; pluginIndex++) {
const plugin = plugins[pluginIndex];
if (!plugin.name) {
plugin.name = `${anonymousPrefix}${pluginIndex + 1}`;
}
}
}
生成 graph 對象處理
重點來了!const graph = new Graph(inputOptions, watcher);
裏面就調用了我們上面介紹的一些關鍵鉤子函數了~
// 不止處理緩存
this.pluginCache = options.cache?.plugins || Object.create(null);
// 還有 WatchChangeHook 鉤子
if (watcher) {
this.watchMode = true;
const handleChange: WatchChangeHook = (...args) => this.pluginDriver.hookSeqSync('watchChange', args); // hookSeq 同步版本,watchChange 使用場景下
const handleClose = () => this.pluginDriver.hookSeqSync('closeWatcher', []); // hookSeq 同步版本, closeWatcher 使用場景下
watcher.on('change', handleChange);
watcher.on('close', handleClose);
watcher.once('restart', () => {
watcher.removeListener('change', handleChange);
watcher.removeListener('close', handleClose);
});
}
this.pluginDriver = new PluginDriver(this, options, options.plugins, this.pluginCache); // 生成一個插件驅動對象
...
this.moduleLoader = new ModuleLoader(this, this.modulesById, this.options, this.pluginDriver); // 初始化模塊加載對象
到目前爲止,處理inputOptions
生成了graph
對象,還記不記得!我們前面講過_graph 包含入口以及各種依賴的相互關係,操作方法,緩存等,在實例內部實現 AST 轉換,是 rollup 的核心。
我們還講過!在解析入口文件路徑階段,爲了從入口文件的絕對路徑出發找到它的模塊定義,並獲取這個入口模塊所有的依賴語句,我們要先通過 resolveId() 方法解析文件地址,拿到文件絕對路徑。這個過程就是通過在 ModuleLoader 中調用 resolveId 完成的。resolveId() 我們在 tree-shaking 時講到基本構建流程時已經介紹過的,下面看調用了鉤子函數的具體方法~
export function resolveIdViaPlugins(
source: string,
importer: string | undefined,
pluginDriver: PluginDriver,
moduleLoaderResolveId: (
source: string,
importer: string | undefined,
customOptions: CustomPluginOptions | undefined,
skip: { importer: string | undefined; plugin: Plugin; source: string }[] | null
) => Promise<ResolvedId | null>,
skip: { importer: string | undefined; plugin: Plugin; source: string }[] | null,
customOptions: CustomPluginOptions | undefined
) {
let skipped: Set<Plugin> | null = null;
let replaceContext: ReplaceContext | null = null;
if (skip) {
skipped = new Set();
for (const skippedCall of skip) {
if (source === skippedCall.source && importer === skippedCall.importer) {
skipped.add(skippedCall.plugin);
}
}
replaceContext = (pluginContext, plugin): PluginContext => ({
...pluginContext,
resolve: (source, importer, { custom, skipSelf } = BLANK) => {
return moduleLoaderResolveId(
source,
importer,
custom,
skipSelf ? [...skip, { importer, plugin, source }] : skip
);
}
});
}
return pluginDriver.hookFirst( // hookFirst 被調用,通過插件處理獲取就絕對路徑,first 類型,如果有插件返回了值,那麼後續所有插件的 resolveId 都不會被執行。
'resolveId',
[source, importer, { custom: customOptions }],
replaceContext,
skipped
);
}
拿到resolveId hook
處理過返回的絕對路徑後,就要從入口文件的絕對路徑出發找到它的模塊定義,並獲取這個入口模塊所有的依賴語句並返回所有內容。在這裏,我們收集配置並標準化、分析文件並編譯源碼生成 AST、生成模塊並解析依賴,最後生成 chunks,總而言之就是讀取並修改文件!要注意的是,每個文件只會被一個插件的load Hook
處理,因爲它是以hookFirst
來執行的。另外,如果你沒有返回值,rollup 會自動讀取文件。接下來進入 fetchModule 階段~
const module: Module = new Module(...)
...
await this.pluginDriver.hookParallel('moduleParsed', [module.info]); // 並行執行 hook,moduleParsed 場景
...
await this.addModuleSource(id, importer, module);
...// addModuleSource
source = (await this.pluginDriver.hookFirst('load', [id])) ?? (await readFile(id)); // 在 load 階段對代碼進行轉換、生成等操作
...// resolveDynamicImport
const resolution = await this.pluginDriver.hookFirst('resolveDynamicImport', [
specifier,
importer
]);
bundle 處理代碼
生成的 graph 對象準備進入 build 階段~~build 開始與結束中的插件函數鉤子
await graph.pluginDriver.hookParallel('buildStart', [inputOptions]); // 並行執行 hook,buildStart 場景
...
await graph.build();
...
await graph.pluginDriver.hookParallel('buildEnd', []); // 並行執行 hook,buildEnd 場景
如果在 buildStart 和 build 階段出現異常,就會提前觸發處理 closeBundle 的 hookParallel 鉤子函數:
await graph.pluginDriver.hookParallel('closeBundle', []);
generate 階段
outputOptions
在 handleGenerateWrite() 階段,獲取處理後的 outputOptions。
outputPluginDriver.hookReduceArg0Sync(
'outputOptions',
[rawOutputOptions.output || rawOutputOptions] as [OutputOptions],
(outputOptions, result) => result || outputOptions,
pluginContext => {
const emitError = () => pluginContext.error(errCannotEmitFromOptionsHook());
return {
...pluginContext,
emitFile: emitError,
setAssetSource: emitError
};
}
)
將處理後的 outputOptions 作爲傳參生成 bundle 對象:
const bundle = new Bundle(outputOptions, unsetOptions, inputOptions, outputPluginDriver, graph);
生成代碼
在 const generated = await bundle.generate(isWrite);
bundle 生成代碼階段,
... // render 開始
await this.pluginDriver.hookParallel('renderStart', [this.outputOptions, this.inputOptions]);
... // 該鉤子函數執行過程中不能中斷
await this.pluginDriver.hookSeq('generateBundle', [
this.outputOptions,
outputBundle as OutputBundle,
isWrite
]);
最後並行執行處理生成的代碼~
await outputPluginDriver.hookParallel('writeBundle', [outputOptions, generated]);
小結
不難看出插件函數鉤子貫穿了整個 rollup 的打包過程,並扮演了不同角色,支撐起了相應功能實現。我們目前做的就是梳理並理解這個過程,再回過頭來看這張圖,是不是就清晰多了。
最後再來講講 rollup 插件的兩個周邊叭~
插件上下文
rollup 給鉤子函數注入了 context,也就是上下文環境,用來方便對 chunks 和其他構建信息進行增刪改查。也就是說,在插件中,可以在各個 hook 中直接通過 this.xxx 來調用上面的方法。
const context: PluginContext = {
addWatchFile(id) {},
cache: cacheInstance,
emitAsset: getDeprecatedContextHandler(...),
emitChunk: getDeprecatedContextHandler(...),
emitFile: fileEmitter.emitFile,
error(err)
getAssetFileName: getDeprecatedContextHandler(...),
getChunkFileName: getDeprecatedContextHandler(),
getFileName: fileEmitter.getFileName,
getModuleIds: () => graph.modulesById.keys(),
getModuleInfo: graph.getModuleInfo,
getWatchFiles: () => Object.keys(graph.watchFiles),
isExternal: getDeprecatedContextHandler(...),
meta: { // 綁定 graph.watchMode
rollupVersion,
watchMode: graph.watchMode
},
get moduleIds() { // 綁定 graph.modulesById.keys();
const moduleIds = graph.modulesById.keys();
return wrappedModuleIds();
},
parse: graph.contextParse, // 綁定 graph.contextParse
resolve(source, importer, { custom, skipSelf } = BLANK) { // 綁定 graph.moduleLoader 上方法
return graph.moduleLoader.resolveId(source, importer, custom, skipSelf ? pidx : null);
},
resolveId: getDeprecatedContextHandler(...),
setAssetSource: fileEmitter.setAssetSource,
warn(warning) {}
};
插件的緩存
插件還提供緩存的能力,利用了閉包實現的非常巧妙。
export function createPluginCache(cache: SerializablePluginCache): PluginCache {
// 利用閉包將 cache 緩存
return {
has(id: string) {
const item = cache[id];
if (!item) return false;
item[0] = 0; // 如果訪問了,那麼重置訪問過期次數,猜測:就是說明用戶有意向主動去使用
return true;
},
get(id: string) {
const item = cache[id];
if (!item) return undefined;
item[0] = 0; // 如果訪問了,那麼重置訪問過期次數
return item[1];
},
set(id: string, value: any) {
// 存儲單位是數組,第一項用來標記訪問次數
cache[id] = [0, value];
},
delete(id: string) {
return delete cache[id];
}
};
}
然後創建緩存後,會添加在插件上下文中:
import createPluginCache from 'createPluginCache';
const cacheInstance = createPluginCache(pluginCache[cacheKey] || (pluginCache[cacheKey] = Object.create(null)));
const context = {
// ...
cache: cacheInstance,
// ...
}
之後我們就可以在插件中就可以使用 cache 進行插件環境下的緩存,進一步提升打包效率:
function testPlugin() {
return {
name: "test-plugin",
buildStart() {
if (!this.cache.has("prev")) {
this.cache.set("prev", "上一次插件執行的結果");
} else {
// 第二次執行 rollup 的時候會執行
console.log(this.cache.get("prev"));
}
},
};
}
let cache;
async function build() {
const chunks = await rollup.rollup({
input: "src/main.js",
plugins: [testPlugin()],
// 需要傳遞上次的打包結果
cache,
});
cache = chunks.cache;
}
build().then(() => {
build();
});
總結
恭喜你,把 rollup 那麼幾種鉤子函數都熬着看過來了,並且又梳理了一遍 rollup.rollup() 打包流程。總結幾點輸出,康康我們學到了什麼:
-
rollup 的插件本質是一個處理函數,返回一個對象。返回的對象包含一些屬性 (如 name),和不同階段的鉤子函數(構建 build 和輸出 output 階段),以實現插件內部的功能;
-
關於返回的對象,在插件返回對象中的鉤子函數中,大多數的鉤子函數定義了 插件的調用時機和調用方式,只有 runHook(Sync) 鉤子真正執行了插件;
-
關於插件調用時機和調用方法的觸發取決於打包流程,在此我們通過圖 1 流程圖也梳理了一遍 rollup.rollup() 打包流程;
-
插件原理都講完了,插件調用當然 so easy,一個函數誰還不會用呢?而對於簡單插件函數的開發頁也不僅僅是單純模仿,也可以做到心中有數了!
在實際的插件開發中,我們會進一步用到這些知識並一一掌握,至少寫出 bug 的時候,梳理一遍插件原理,再進一步內化吸收,就能更快的定位問題了。在開發中如果有想法,就可以着手編寫自己的 rollup 插件啦!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wTXhkH1zuD3oMNrr2bvzug