從零到一解讀Rollup Plugin
rollup plugin 這篇文章讀讀改改,終于和大家見面啦~~~
盡管對于 rollup 的插件編寫,官網(wǎng)上對于 rolup 插件的介紹幾乎都是英文,學習起來不是很友好, 例子也相對較少,但目前針對 rollup 插件的分析與開發(fā)指南的文章已經(jīng)不少見,以關于官方英文文檔的翻譯與函數(shù)鉤子的分析為主。
講道理,稀里糊涂直接看源碼分析只會分分鐘勸退我,而我只想分分鐘寫個 rollup 插件而已~~
rollup 為什么需要 Plugin
rollup -c 打包流程
在 rollup 的打包流程中,通過相對路徑,將一個入口文件和一個模塊創(chuàng)建成了一個簡單的 bundle。隨著構建更復雜的 bundle,通常需要更大的靈活性——引入 npm 安裝的模塊、通過 Babel 編譯代碼、和 JSON 文件打交道等。通過 rollup -c 打包的實現(xiàn)流程可以參考下面的流程圖理解。
為此,我們可以通過 插件(plugins) 在打包的關鍵過程中更改 Rollup 的行為。
這其實和 webpack 的插件相類似,不同的是,webpack 區(qū)分 loader 和 plugin,而 rollup 的 plugin 既可以擔任 loader 的角色,也可以勝任傳統(tǒng) plugin 的角色。
理解 rollup plugin
引用官網(wǎng)的解釋:Rollup 插件是一個具有下面描述的一個或多個屬性、構建鉤子和輸出生成鉤子的對象,它遵循我們的約定。一個插件應該作為一個包來分發(fā),該包導出一個可以用插件特定選項調(diào)用的函數(shù),并返回這樣一個對象。插件允許你定制 Rollup 的行為,例如,在捆綁之前編譯代碼,或者在你的 node_modules 文件夾中找到第三方模塊。
簡單來說,rollup 的插件是一個普通的函數(shù),函數(shù)返回一個對象,該對象包含一些屬性(如 name),和不同階段的鉤子函數(shù)(構建 build 和輸出 output 階段),此處應該回顧下上面的流程圖。
關于約定
- 插件應該有一個帶有 rollup-plugin-前綴的明確名稱。
- 在 package.json 中包含 rollup-plugin 關鍵字。
- 插件應該支持測試,推薦 mocha 或者 ava 這類開箱支持 promises 的庫。
- 盡可能使用異步方法。
- 用英語記錄你的插件。
- 確保你的插件輸出正確的 sourcemap。
- 如果你的插件使用 'virtual modules'(比如幫助函數(shù)),給模塊名加上 \0 前綴。這可以阻止其他插件執(zhí)行它。
分分鐘寫個 rollup 插件
為了保持學習下去的熱情與動力,先舉個栗子壓壓驚,如果看到插件實現(xiàn)的各種源碼函數(shù)鉤子部分覺得腦子不清醒了,歡迎隨時回來重新看這一小節(jié),重拾勇氣與信心!
插件其實很簡單
可以打開rollup 插件列表,隨便找個你感興趣的插件,看下源代碼。
有不少插件都是幾十行,不超過 100 行的。比如圖片文件多格式支持插件@rollup/plugin-image 的代碼甚至不超過 50 行,而將 json 文件轉(zhuǎn)換為 ES6 模塊的插件@rollup/plugin-json 源代碼更少。
一個例子
- // 官網(wǎng)的一個例子
- export default function myExample () {
- return {
- name: 'my-example', // 名字用來展示在警告和報錯中
- resolveId ( source ) {
- if (source === 'virtual-module') {
- return source; // rollup 不應該查詢其他插件或文件系統(tǒng)
- }
- 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(/正則/, '替換內(nèi)容');
- },
- };
- }
- // 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 功能的實現(xiàn)
我們要講的 rollup plugin 也不可能就這么簡單啦~~~
接下來當然是結(jié)合例子分析實現(xiàn)原理~~
其實不難發(fā)現(xiàn),rollup 的插件配置與 webpack 等框架中的插件使用大同小異,都是提供配置選項,注入當前構建結(jié)果相關的屬性與方法,供開發(fā)者進行增刪改查操作。
那么插件寫好了,rollup 是如何在打包過程中調(diào)用它并實現(xiàn)它的功能的呢?
相關概念
首先還是要了解必備的前置知識,大致瀏覽下 rollup 中處理 plugin 的方法,基本可以定位到 PluginContext.ts(上下文相關)、PluginDriver.ts(驅(qū)動相關)、PluginCache.ts(緩存相關)和 PluginUtils.ts(警告錯誤異常處理)等文件,其中最關鍵的就在 PluginDriver.ts 中了。
首先要清楚插件驅(qū)動的概念,它是實現(xiàn)插件提供功能的的核心 -- PluginDriver,插件驅(qū)動器,調(diào)用插件和提供插件環(huán)境上下文等。
鉤子函數(shù)的調(diào)用時機
大家在研究 rollup 插件的時候,最關注的莫過于鉤子函數(shù)部分了,鉤子函數(shù)的調(diào)用時機有三類:
- const chunks = rollup.rollup 執(zhí)行期間的構建鉤子函數(shù) - Build Hooks
- chunks.generator(write)執(zhí)行期間的輸出鉤子函數(shù) - Output Generation Hooks
- 監(jiān)聽文件變化并重新執(zhí)行構建的 rollup.watch 執(zhí)行期間的 watchChange 鉤子函數(shù)
鉤子函數(shù)處理方式分類
除了以調(diào)用時機來劃分鉤子函數(shù)以外,我們還可以以鉤子函數(shù)處理方式來劃分,這樣來看鉤子函數(shù)就主要有以下四種版本:
- async: 處理 promise 的異步鉤子,即這類 hook 可以返回一個解析為相同類型值的 promise,同步版本 hook 將被標記為 sync。
- first: 如果多個插件實現(xiàn)了相同的鉤子函數(shù),那么會串式執(zhí)行,從頭到尾,但是,如果其中某個的返回值不是 null 也不是 undefined 的話,會直接終止掉后續(xù)插件。
- sequential: 如果多個插件實現(xiàn)了相同的鉤子函數(shù),那么會串式執(zhí)行,按照使用插件的順序從頭到尾執(zhí)行,如果是異步的,會等待之前處理完畢,在執(zhí)行下一個插件。
- parallel: 同上,不過如果某個插件是異步的,其后的插件不會等待,而是并行執(zhí)行,這個也就是我們在 rollup.rollup() 階段看到的處理方式。
構建鉤子函數(shù)
為了與構建過程交互,你的插件對象需要包含一些構建鉤子函數(shù)。構建鉤子是構建的各個階段調(diào)用的函數(shù)。構建鉤子函數(shù)可以影響構建執(zhí)行方式、提供構建的信息或者在構建完成后修改構建。rollup 中有不同的構建鉤子函數(shù),在構建階段執(zhí)行時,它們被 [rollup.rollup(inputOptions)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/cli/run/build.ts#L34) 觸發(fā)。
構建鉤子函數(shù)主要關注在 Rollup 處理輸入文件之前定位、提供和轉(zhuǎn)換輸入文件。構建階段的第一個鉤子是 options,最后一個鉤子總是 buildEnd,除非有一個構建錯誤,在這種情況下 closeBundle 將在這之后被調(diào)用。
順便提一下,在觀察模式下,watchChange 鉤子可以在任何時候被觸發(fā),以通知新的運行將在當前運行產(chǎn)生其輸出后被觸發(fā)。當 watcher 關閉時,closeWatcher 鉤子函數(shù)將被觸發(fā)。
輸出鉤子函數(shù)
輸出生成鉤子函數(shù)可以提供關于生成的包的信息并在構建完成后立馬執(zhí)行。它們和構建鉤子函數(shù)擁有一樣的工作原理和相同的類型,但是不同的是它們分別被 ·[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) 調(diào)用。只使用輸出生成鉤子的插件也可以通過輸出選項傳入,因為只對某些輸出運行。
輸出生成階段的第一個鉤子函數(shù)是 outputOptions,如果輸出通過 bundle.generate(...) 成功生成則第一個鉤子函數(shù)是 generateBundle,如果輸出通過 [bundle.write(...)](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/src/watch/watch.ts#L200) 生成則最后一個鉤子函數(shù)是 [writeBundle](https://github.com/rollup/rollup/blob/master/src/rollup/rollup.ts#L176),另外如果輸出生成階段發(fā)生了錯誤的話,最后一個鉤子函數(shù)則是 renderError。
另外,closeBundle 可以作為最后一個鉤子被調(diào)用,但用戶有責任手動調(diào)用 bundle.close() 來觸發(fā)它。CLI 將始終確保這種情況發(fā)生。
以上就是必須要知道的概念了,讀到這里好像還是看不明白這些鉤子函數(shù)到底是干啥的!那么接下來進入正題!
鉤子函數(shù)加載實現(xiàn)
[PluginDriver](https://github.com/rollup/rollup/blob/07b3a02069594147665daa95d3fa3e041a82b2d0/src/utils/PluginDriver.ts#L124) 中有 9 個 hook 加載函數(shù)。主要是因為每種類別的 hook 都有同步和異步的版本。
接下來先康康 9 個 hook 加載函數(shù)及其應用場景(看完第一遍不知所以然,但是別人看了咱也得看,先看了再說,看不懂就多看幾遍 QAQ~)
排名不分先后,僅參考它們在 PluginDriver.ts 中出現(xiàn)的順序🌠。
1. hookFirst
加載 first 類型的鉤子函數(shù),場景有 resolveId、resolveAssetUrl 等,在實例化 Graph 的時候,初始化初始化 promise 和 this.plugins,并通過覆蓋之前的 promise,實現(xiàn)串行執(zhí)行鉤子函數(shù)。當多個插件實現(xiàn)了相同的鉤子函數(shù)時從頭到尾串式執(zhí)行,如果其中某個的返回值不是 null 也不是 undefined 的話,就會直接終止掉后續(xù)插件。
- 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,即串行執(zhí)行鉤子函數(shù)
- promise = promise.then((result: any) => {
- // 返回非 null 或 undefined 的時候,停止運行,返回結(jié)果
- if (result != null) return result;
- // 執(zhí)行鉤子函數(shù)
- 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 的時候,停止運行,返回結(jié)果
- if (result != null) return result as any;
- }
- // 否則返回 null
- return null as any;
- }
3. hookParallel
并行執(zhí)行 hook,不會等待當前 hook 完成。也就是說如果某個插件是異步的,其后的插件不會等待,而是并行執(zhí)行。使用場景 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[], // 取出傳入的數(shù)組的第一個參數(shù),將剩余的置于一個數(shù)組中
- reduce: Reduce<V, R>,
- replaceContext?: ReplaceContext // 替換當前 plugin 調(diào)用時候的上下文環(huán)境
- ) {
- let promise = Promise.resolve(arg0); // 默認返回 source.code
- for (let i = 0; i < this.plugins.length; i++) {
- // 第一個 promise 的時候只會接收到上面?zhèn)鬟f的 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 代表插件執(zhí)行完成的返回值
- 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 類型的鉤子函數(shù),和 hookFirst 的區(qū)別就是不能中斷,使用場景有 onwrite、generateBundle 等。
- async function hookSeq<H extends keyof PluginHooks>(
- hookName: H,
- args: Args<PluginHooks[H]>,
- replaceContext?: ReplaceContext,
- // hookFirst 通過 skip 參數(shù)決定是否跳過某個鉤子函數(shù)
- ): 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 執(zhí)行鉤子函數(shù)。使用場景有 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);
- }
- }
通過觀察上面幾種鉤子函數(shù)的調(diào)用方式,我們可以發(fā)現(xiàn),其內(nèi)部有一個調(diào)用鉤子函數(shù)的方法: runHook(Sync)(當然也分同步和異步版本),該函數(shù)真正執(zhí)行插件中提供的鉤子函數(shù)。
也就是說,之前介紹了那么多的鉤子函數(shù),僅僅決定了我們插件的調(diào)用時機和調(diào)用方式(比如同步/異步),而真正調(diào)用并執(zhí)行插件函數(shù)(前面提到插件本身是個「函數(shù)」)的鉤子其實是 runHook 。
runHook(Sync)
真正執(zhí)行插件的鉤子函數(shù),同步版本和異步版本的區(qū)別是有無 permitValues 許可標識允許返回值而不是只允許返回函數(shù)。
- 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];
- // 找到當前執(zhí)行的在 plugin 中定義的 hooks 鉤子函數(shù)
- const hook = (plugin as any)[hookName];
- if (!hook) return undefined as any;
- // pluginContexts 在初始化 plugin 驅(qū)動器類的時候定義,是個數(shù)組,數(shù)組保存對應著每個插件的上下文環(huán)境
- let context = this.pluginContexts[pluginIndex];
- // 用于區(qū)分對待不同鉤子函數(shù)的插件上下文
- if (hookContext) {
- context = hookContext(context, plugin);
- }
- return Promise.resolve()
- .then(() => {
- // 允許返回值,而不是一個函數(shù)鉤子,使用 hookReduceValue 或 hookReduceValueSync 加載。
- // 在 sync 同步版本鉤子函數(shù)中,則沒有 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.`,
- });
- }
- // 傳入插件上下文和參數(shù),返回插件執(zhí)行結(jié)果
- return hook.apply(context, args);
- })
- .catch(err => throwPluginError(err, plugin.name, { hook: hookName }));
- }
看完這些鉤子函數(shù)介紹,我們清楚了插件的調(diào)用時機、調(diào)用方式以及執(zhí)行輸出鉤子函數(shù)。但你以為這就結(jié)束了??當然沒有結(jié)束我們還要把這些鉤子再帶回 rollup 打包流程康康一下調(diào)用時機和調(diào)用方式的實例~~
rollup.rollup()
又回到最初的起點~~~
前面提到過,構建鉤子函數(shù)在 Rollup 處理輸入文件之前定位、提供和轉(zhuǎn)換輸入文件。那么當然要先從輸入開始看起咯~
build 階段
處理 inputOptions
- // 從處理 inputOptions 開始,你的插件鉤子函數(shù)已到達!
- const { options: inputOptions, unsetOptions: unsetInputOptions } = await getInputOptions(
- rawInputOptions,
- watcher !== null
- );
朋友們,把 async、first、sequential 和 parallel 以及 9 個鉤子函數(shù)帶上開搞!
- // 處理 inputOptions 的應用場景下調(diào)用了 options 鉤子
- function applyOptionHook(watchMode: boolean) {
- return async ( // 異步串行執(zhí)行
- 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);里面就調(diào)用了我們上面介紹的一些關鍵鉤子函數(shù)了~
- // 不止處理緩存
- 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); // 生成一個插件驅(qū)動對象
- ...
- this.moduleLoader = new ModuleLoader(this, this.modulesById, this.options, this.pluginDriver); // 初始化模塊加載對象
到目前為止,處理inputOptions生成了graph對象,還記不記得!我們前面講過_graph 包含入口以及各種依賴的相互關系,操作方法,緩存等,在實例內(nèi)部實現(xiàn) AST 轉(zhuǎn)換,是 rollup 的核心。
我們還講過!在解析入口文件路徑階段,為了從入口文件的絕對路徑出發(fā)找到它的模塊定義,并獲取這個入口模塊所有的依賴語句,我們要先通過 resolveId()方法解析文件地址,拿到文件絕對路徑。這個過程就是通過在 ModuleLoader 中調(diào)用 resolveId 完成的。resolveId() 我們在 tree-shaking 時講到基本構建流程時已經(jīng)介紹過的,下面看調(diào)用了鉤子函數(shù)的具體方法~
- 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 被調(diào)用,通過插件處理獲取就絕對路徑,first 類型,如果有插件返回了值,那么后續(xù)所有插件的 resolveId 都不會被執(zhí)行。
- 'resolveId',
- [source, importer, { custom: customOptions }],
- replaceContext,
- skipped
- );
- }
拿到resolveId hook處理過返回的絕對路徑后,就要從入口文件的絕對路徑出發(fā)找到它的模塊定義,并獲取這個入口模塊所有的依賴語句并返回所有內(nèi)容。在這里,我們收集配置并標準化、分析文件并編譯源碼生成 AST、生成模塊并解析依賴,最后生成 chunks,總而言之就是讀取并修改文件!要注意的是,每個文件只會被一個插件的load Hook處理,因為它是以hookFirst來執(zhí)行的。另外,如果你沒有返回值,rollup 會自動讀取文件。接下來進入 fetchModule 階段~
- const module: Module = new Module(...)
- ...
- await this.pluginDriver.hookParallel('moduleParsed', [module.info]); // 并行執(zhí)行 hook,moduleParsed 場景
- ...
- await this.addModuleSource(id, importer, module);
- ...// addModuleSource
- source = (await this.pluginDriver.hookFirst('load', [id])) ?? (await readFile(id)); // 在 load 階段對代碼進行轉(zhuǎn)換、生成等操作
- ...// resolveDynamicImport
- const resolution = await this.pluginDriver.hookFirst('resolveDynamicImport', [
- specifier,
- importer
- ]);
bundle 處理代碼
生成的 graph 對象準備進入 build 階段~~build 開始與結(jié)束中的插件函數(shù)鉤子
- await graph.pluginDriver.hookParallel('buildStart', [inputOptions]); // 并行執(zhí)行 hook,buildStart 場景
- ...
- await graph.build();
- ...
- await graph.pluginDriver.hookParallel('buildEnd', []); // 并行執(zhí)行 hook,buildEnd 場景
如果在 buildStart 和 build 階段出現(xiàn)異常,就會提前觸發(fā)處理 closeBundle 的 hookParallel 鉤子函數(shù):
- 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]);
- ... // 該鉤子函數(shù)執(zhí)行過程中不能中斷
- await this.pluginDriver.hookSeq('generateBundle', [
- this.outputOptions,
- outputBundle as OutputBundle,
- isWrite
- ]);
最后并行執(zhí)行處理生成的代碼~
- await outputPluginDriver.hookParallel('writeBundle', [outputOptions, generated]);
小結(jié)
不難看出插件函數(shù)鉤子貫穿了整個 rollup 的打包過程,并扮演了不同角色,支撐起了相應功能實現(xiàn)。我們目前做的就是梳理并理解這個過程,再回過頭來看這張圖,是不是就清晰多了。
最后再來講講 rollup 插件的兩個周邊叭~
插件上下文
rollup 給鉤子函數(shù)注入了 context,也就是上下文環(huán)境,用來方便對 chunks 和其他構建信息進行增刪改查。也就是說,在插件中,可以在各個 hook 中直接通過 this.xxx 來調(diào)用上面的方法。
- 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) {}
- };
插件的緩存
插件還提供緩存的能力,利用了閉包實現(xiàn)的非常巧妙。
- export function createPluginCache(cache: SerializablePluginCache): PluginCache {
- // 利用閉包將 cache 緩存
- return {
- has(id: string) {
- const item = cache[id];
- if (!item) return false;
- item[0] = 0; // 如果訪問了,那么重置訪問過期次數(shù),猜測:就是說明用戶有意向主動去使用
- return true;
- },
- get(id: string) {
- const item = cache[id];
- if (!item) return undefined;
- item[0] = 0; // 如果訪問了,那么重置訪問過期次數(shù)
- return item[1];
- },
- set(id: string, value: any) {
- // 存儲單位是數(shù)組,第一項用來標記訪問次數(shù)
- cache[id] = [0, value];
- },
- delete(id: string) {
- return delete cache[id];
- }
- };
- }
然后創(chuàng)建緩存后,會添加在插件上下文中:
- import createPluginCache from 'createPluginCache';
- const cacheInstance = createPluginCache(pluginCache[cacheKey] || (pluginCache[cacheKey] = Object.create(null)));
- const context = {
- // ...
- cache: cacheInstance,
- // ...
- }
之后我們就可以在插件中就可以使用 cache 進行插件環(huán)境下的緩存,進一步提升打包效率:
- function testPlugin() {
- return {
- name: "test-plugin",
- buildStart() {
- if (!this.cache.has("prev")) {
- this.cache.set("prev", "上一次插件執(zhí)行的結(jié)果");
- } else {
- // 第二次執(zhí)行 rollup 的時候會執(zhí)行
- console.log(this.cache.get("prev"));
- }
- },
- };
- }
- let cache;
- async function build() {
- const chunks = await rollup.rollup({
- input: "src/main.js",
- plugins: [testPlugin()],
- // 需要傳遞上次的打包結(jié)果
- cache,
- });
- cache = chunks.cache;
- }
- build().then(() => {
- build();
- });
總結(jié)
恭喜你,把 rollup 那么幾種鉤子函數(shù)都熬著看過來了,并且又梳理了一遍 rollup.rollup() 打包流程??偨Y(jié)幾點輸出,康康我們學到了什么:
rollup 的插件本質(zhì)是一個處理函數(shù),返回一個對象。返回的對象包含一些屬性(如 name),和不同階段的鉤子函數(shù)(構建 build 和輸出 output 階段),以實現(xiàn)插件內(nèi)部的功能;
關于返回的對象,在插件返回對象中的鉤子函數(shù)中,大多數(shù)的鉤子函數(shù)定義了 插件的調(diào)用時機和調(diào)用方式,只有 runHook(Sync)鉤子真正執(zhí)行了插件;
關于插件調(diào)用時機和調(diào)用方法的觸發(fā)取決于打包流程,在此我們通過圖 1 流程圖也梳理了一遍 rollup.rollup() 打包流程;
插件原理都講完了,插件調(diào)用當然 so easy,一個函數(shù)誰還不會用呢?而對于簡單插件函數(shù)的開發(fā)頁也不僅僅是單純模仿,也可以做到心中有數(shù)了!
在實際的插件開發(fā)中,我們會進一步用到這些知識并一一掌握,至少寫出 bug 的時候,梳理一遍插件原理,再進一步內(nèi)化吸收,就能更快的定位問題了。在開發(fā)中如果有想法,就可以著手編寫自己的 rollup 插件啦!