­

原來rollup這麼簡單之插件篇

大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。

內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。

大家的支持是我創作的動力。

計劃

rollup系列打算一章一章的放出,內容更精簡更專一更易於理解

這是rollup系列的最後一篇文章,以下是所有文章鏈接。

TL;DR

rollup的插件和其他大型框架大同小異,都是提供統一的標準接口,通過約定大於配置定義公共配置,注入當前構建結果相關的屬性與方法,供開發者進行增刪改查操作。為穩定可持續增長提供了強而有力的鋪墊!

但不想webpack區分loader和plugin,rollup的plugin既可以擔任loader的角色,也可以勝任傳統plugin的角色。rollup提供的鉤子函數是核心,比如load、transform對chunk進行解析更改,resolveFileUrl可以對加載模塊進行合法解析,options對配置進行動態更新等等~

注意點

所有的注釋都在這裡,可自行閱讀

!!!提示 => 標有TODO為具體實現細節,會視情況分析。

!!!注意 => 每一個子標題都是父標題(函數)內部實現

!!!強調 => rollup中模塊(文件)的id就是文件地址,所以類似resolveID這種就是解析文件地址的意思,我們可以返回我們想返回的文件id(也就是地址,相對路徑、決定路徑)來讓rollup加載

rollup是一個核心,只做最基礎的事情,比如提供默認模塊(文件)加載機制, 比如打包成不同風格的內容,我們的插件中提供了加載文件路徑,解析文件內容(處理ts,sass等)等操作,是一種插拔式的設計,和webpack類似
插拔式是一種非常靈活且可長期迭代更新的設計,這也是一個中大型框架的核心,人多力量大嘛~

主要通用模塊以及含義

  1. Graph: 全局唯一的圖,包含入口以及各種依賴的相互關係,操作方法,緩存等。是rollup的核心
  2. PathTracker: 引用(調用)追蹤器
  3. PluginDriver: 插件驅動器,調用插件和提供插件環境上下文等
  4. FileEmitter: 資源操作器
  5. GlobalScope: 全局作用局,相對的還有局部的
  6. ModuleLoader: 模塊加載器
  7. NodeBase: ast各語法(ArrayExpression、AwaitExpression等)的構造基類

插件機制分析

rollup的插件其實一個普通的函數,函數返回一個對象,該對象包含一些基礎屬性(如name),和不同階段的鉤子函數,像這個樣子:

function plugin(options = {}) {
  return {
    name: 'rollup-plugin',
    transform() {
      return {
        code: 'code',
        map: { mappings: '' }
      };
    }
  };
}

這裡是官方建議遵守的約定.

我們平常書寫rollup插件的時候,最關注的就是鉤子函數部分了,鉤子函數的調用時機有三類:

  1. const chunks = rollup.rollup執行期間的Build Hooks
  2. chunks.generator(write)執行期間的Output Generation Hooks
  3. 監聽文件變化並重新執行構建的rollup.watch執行期間的watchChange鉤子函數

除了類別不同,rollup也提供了幾種鉤子函數的執行方式,每種方式都又分為同步或異步,方便內部使用:

  1. async: 處理promise的異步鉤子,也有同步版本
  2. first: 如果多個插件實現了相同的鉤子函數,那麼會串式執行,從頭到尾,但是,如果其中某個的返回值不是null也不是undefined的話,會直接終止掉後續插件。
  3. sequential: 如果多個插件實現了相同的鉤子函數,那麼會串式執行,按照使用插件的順序從頭到尾執行,如果是異步的,會等待之前處理完畢,在執行下一個插件。
  4. parallel: 同上,不過如果某個插件是異步的,其後的插件不會等待,而是並行執行。

文字表達比較蒼白,咱們看幾個實現:

  • 鉤子函數: hookFirst
    使用場景:resolveId、resolveAssetUrl等
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();
    // this.plugins在初始化Graph的時候,進行了初始化
    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);
        });
    }
    // 最後一個promise執行的結果
    return promise;
}
  • 鉤子函數: hookFirstSync
    使用場景:resolveFileUrl、resolveImportMeta等
// hookFirst的同步版本,也就是並行執行
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;
}
  • 鉤子函數: hookSeq
    使用場景:onwrite、generateBundle等
// 和hookFirst的區別就是不能中斷
async function hookSeq<H extends keyof PluginHooks>(
    hookName: H,
    args: Args<PluginHooks[H]>,
    replaceContext?: ReplaceContext
): 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;
}
  • 鉤子函數: hookParallel
    使用場景:buildStart、buildEnd、renderStart等
// 同步進行,利用的Promise.all
function hookParallel<H extends keyof PluginHooks>(
    hookName: H,
    args: Args<PluginHooks[H]>,
    replaceContext?: ReplaceContext
): Promise<void> {
    // 創建promise.all容器
    const promises: Promise<void>[] = [];
    // 遍歷每一個plugin
    for (let i = 0; i < this.plugins.length; i++) {
        // 執行hook返回promise
        const hookPromise = this.runHook<void>(hookName, args as any[], i, false, replaceContext);
        // 如果沒有那麼不push
        if (!hookPromise) continue;
        promises.push(hookPromise);
    }
    // 返回promise
    return Promise.all(promises).then(() => {});
}
  • 鉤子函數: hookReduceArg0
    使用場景: outputOptions、renderChunk等
// 對arg第一項進行reduce操作
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;
}

通過觀察上面幾種鉤子函數的調用方式,我們可以發現,其內部有一個調用鉤子函數的方法: runHook(Sync),該函數執行插件中提供的鉤子函數。

實現很簡單:

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(() => {
            // permit values allows values to be returned instead of a functional hook
            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本身也提供了幾個必需的鉤子函數供我們使用,在Graph實例化的時候與用戶自定義插件進行concat操作:

import { getRollupDefaultPlugin } from './defaultPlugin';

this.plugins = userPlugins.concat(
    // 採用內置默認插件或者graph的插件驅動器的插件,不管怎麼樣,內置默認插件是肯定有的
    // basePluginDriver是上一個PluginDriver初始化的插件
    // preserveSymlinks: 軟連標誌
    basePluginDriver ? basePluginDriver.plugins : [getRollupDefaultPlugin(preserveSymlinks)]
);

那rollup提供了哪些必需的鉤子函數呢:

export function getRollupDefaultPlugin(preserveSymlinks: boolean): Plugin {
	return {
        // 插件名
		name: 'Rollup Core',
		// 默認的模塊(文件)加載機制,內部主要使用path.resolve
		resolveId: createResolveId(preserveSymlinks) as ResolveIdHook,
        // this.pluginDriver.hookFirst('load', [id])為異步調用,readFile內部用promise包裝了fs.readFile,並返回該promise
		load(id) {
			return readFile(id);
		},
        // 用來處理通過emitFile添加的urls或文件
		resolveFileUrl({ relativePath, format }) {
			// 不同format會返回不同的文件解析地址
			return relativeUrlMechanisms[format](relativePath);
		},
        // 處理import.meta.url,參考地址://nodejs.org/api/esm.html#esm_import_meta)
		resolveImportMeta(prop, { chunkId, format }) {
			// 改變 獲取import.meta的信息 的行為
			const mechanism = importMetaMechanisms[format] && importMetaMechanisms[format](prop, chunkId);
			if (mechanism) {
				return mechanism;
			}
		}
	};
}

過一眼發現都是最基本處理路徑解析內容的鉤子函數。

不僅如此,rollup給鉤子函數注入了context,也就是上下文環境,用來方便對chunks和其他構建信息進行增刪改查。

文檔中也寫得很清楚,比如:

  • 使用this.parse,調用rollup內部中的acron實例解析出ast
  • 使用this.emitFile來增加產出的文件,看這個例子.

我們通過transform操作來簡單看下,之前對ast進行transform的時候,調用了transform鉤子:


graph.pluginDriver
    .hookReduceArg0<any, string>(
        'transform',
        [curSource, id], // source.code 和 模塊id
        transformReducer,
    	// 第四個參數是一個函數,用來聲明某些鉤子上下文中需要的方法
        (pluginContext, plugin) => {
            // 這一大堆是插件利用的,通過this.xxx調用
            curPlugin = plugin;
            if (curPlugin.cacheKey) customTransformCache = true;
            else trackedPluginCache = getTrackedPluginCache(pluginContext.cache);
            return {
                ...pluginContext,
                cache: trackedPluginCache ? trackedPluginCache.cache : pluginContext.cache,
                warn(warning: RollupWarning | string, pos?: number | { column: number; line: number }) {
                    if (typeof warning === 'string') warning = { message: warning } as RollupWarning;
                    if (pos) augmentCodeLocation(warning, pos, curSource, id);
                    warning.id = id;
                    warning.hook = 'transform';
                    pluginContext.warn(warning);
                },
                error(err: RollupError | string, pos?: number | { column: number; line: number }): never {
                    if (typeof err === 'string') err = { message: err };
                    if (pos) augmentCodeLocation(err, pos, curSource, id);
                    err.id = id;
                    err.hook = 'transform';
                    return pluginContext.error(err);
                },
                emitAsset(name: string, source?: string | Buffer) {
                    const emittedFile = { type: 'asset' as const, name, source };
                    emittedFiles.push({ ...emittedFile });
                    return graph.pluginDriver.emitFile(emittedFile);
                },
                emitChunk(id, options) {
                    const emittedFile = { type: 'chunk' as const, id, name: options && options.name };
                    emittedFiles.push({ ...emittedFile });
                    return graph.pluginDriver.emitFile(emittedFile);
                },
                emitFile(emittedFile: EmittedFile) {
                    emittedFiles.push(emittedFile);
                    return graph.pluginDriver.emitFile(emittedFile);
                },
                addWatchFile(id: string) {
                    transformDependencies.push(id);
                    pluginContext.addWatchFile(id);
                },
                setAssetSource(assetReferenceId, source) {
                    pluginContext.setAssetSource(assetReferenceId, source);
                    if (!customTransformCache && !setAssetSourceErr) {
                        try {
                            return this.error({
                                code: 'INVALID_SETASSETSOURCE',
                                message: `setAssetSource cannot be called in transform for caching reasons. Use emitFile with a source, or call setAssetSource in another hook.`
                            });
                        } catch (err) {
                            setAssetSourceErr = err;
                        }
                    }
                },
                getCombinedSourcemap() {
                    const combinedMap = collapseSourcemap(
                        graph,
                        id,
                        originalCode,
                        originalSourcemap,
                        sourcemapChain
                    );
                    if (!combinedMap) {
                        const magicString = new MagicString(originalCode);
                        return magicString.generateMap({ includeContent: true, hires: true, source: id });
                    }
                    if (originalSourcemap !== combinedMap) {
                        originalSourcemap = combinedMap;
                        sourcemapChain.length = 0;
                    }
                    return new SourceMap({
                        ...combinedMap,
                        file: null as any,
                        sourcesContent: combinedMap.sourcesContent!
                    });
                }
            };
        }
    )

runHook中有一句判斷,就是對上下文環境的使用:

function runHook<T>(
		hookName: string,
		args: any[],
		pluginIndex: number,
		permitValues: boolean,
		hookContext?: ReplaceContext | null
) {
    // ...
    const plugin = this.plugins[pluginIndex];
    // 獲取默認的上下文環境
    let context = this.pluginContexts[pluginIndex];
    // 如果提供了,就替換
    if (hookContext) {
        context = hookContext(context, plugin);
    }
    // ...
}

至於rollup是什麼時機調用插件提供的鉤子函數的,這裡就不啰嗦了,代碼中分佈很清晰,一看便知.

還有 rollup 為了方便咱們變化插件,還提供了一個工具集,可以非常方便的進行模塊的操作以及判斷,有興趣的自行查看。

總結

rollup系列到此也就告一段落了,從開始閱讀時的一臉懵逼,到讀到依賴收集、各工具類的十臉懵逼,到現在的輕車熟路,真是一段難忘的經歷~

學習大佬們的操作並取其精華,去其糟粕就像打怪升級一樣,你品,你細品。哈哈

在這期間也是誤導一些東西,看得多了,就會發現,其實套路都一樣,摸索出它們的核心框架,再對功能縫縫補補,不斷更新迭代,或許我們也可以成為開源大作的作者。

如果用幾句話來描述rollup的話:

讀取併合並配置 -> 創建依賴圖 -> 讀取入口模塊內容 -> 借用開源estree規範解析器進行源碼分析,獲取依賴,遞歸此操作 -> 生成模塊,掛載模塊對應文件相關信息 -> 分析ast,構建各node實例 -> 生成chunks -> 調用各node重寫的render -> 利用magic-string進行字符串拼接和wrap操作 -> 寫入

精簡一下就是:

字符串 -> AST -> 字符串

如果改系列能對你一絲絲幫忙,還請動動手指,鼓勵一下~

拜了個拜~

Tags: