原來rollup這麼簡單之外掛篇
大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。
內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。
大家的支援是我創作的動力。
計劃
rollup系列打算一章一章的放出,內容更精簡更專一更易於理解
這是rollup系列的最後一篇文章,以下是所有文章連結。
- rollup.rollup
- rollup.generate + rollup.write
- rollup.watch
- tree shaking
- plugins <==== 當前文章
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類似
插拔式是一種非常靈活且可長期迭代更新的設計,這也是一箇中大型框架的核心,人多力量大嘛~
主要通用模組以及含義
- Graph: 全域性唯一的圖,包含入口以及各種依賴的相互關係,操作方法,快取等。是rollup的核心
- PathTracker: 引用(呼叫)追蹤器
- PluginDriver: 外掛驅動器,呼叫外掛和提供外掛環境上下文等
- FileEmitter: 資源操作器
- GlobalScope: 全域性作用局,相對的還有區域性的
- ModuleLoader: 模組載入器
- NodeBase: ast各語法(ArrayExpression、AwaitExpression等)的構造基類
外掛機制分析
rollup的外掛其實一個普通的函式,函式返回一個物件,該物件包含一些基礎屬性(如name),和不同階段的鉤子函式,像這個樣子:
function plugin(options = {}) { return { name: 'rollup-plugin', transform() { return { code: 'code', map: { mappings: '' } }; } };}
這裡是官方建議遵守的約定.
我們平常書寫rollup外掛的時候,最關注的就是鉤子函式部分了,鉤子函式的呼叫時機有三類:
- const chunks = rollup.rollup執行期間的Build Hooks
- chunks.generator(write)執行期間的Output Generation Hooks
- 監聽檔案變化並重新執行構建的rollup.watch執行期間的watchChange鉤子函式
除了類別不同,rollup也提供了幾種鉤子函式的執行方式,每種方式都又分為同步或非同步,方便內部使用:
- async: 處理promise的非同步鉤子,也有同步版本
- first: 如果多個外掛實現了相同的鉤子函式,那麼會串式執行,從頭到尾,但是,如果其中某個的返回值不是null也不是undefined的話,會直接終止掉後續外掛。
- sequential: 如果多個外掛實現了相同的鉤子函式,那麼會串式執行,按照使用外掛的順序從頭到尾執行,如果是非同步的,會等待之前處理完畢,在執行下一個外掛。
- 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.allfunction 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.resolveresolveId: createResolveId(preserveSymlinks) as ResolveIdHook, // this.pluginDriver.hookFirst('load', [id])為非同步呼叫,readFile內部用promise包裝了fs.readFile,並返回該promiseload(id) {return readFile(id);}, // 用來處理通過emitFile新增的urls或檔案resolveFileUrl({ relativePath, format }) {// 不同format會返回不同的檔案解析地址return relativeUrlMechanisms[format](relativePath);}, // 處理import.meta.url,參考地址:https://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 為了方便咱們變化外掛,還提供了一個工具集,可以非常方便的進行模組的操作以及判斷,有興趣的自行檢視。
外掛的快取
外掛還提供快取的能力,實現的非常巧妙:
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();});
不過需要注意的一點是options
鉤子函式是沒有注入上下文環境的,它的呼叫方式也和其他鉤子不一樣:
function applyOptionHook(inputOptions: InputOptions, plugin: Plugin) {if (plugin.options){ // 指定this和經過處理的input配置,並未傳入context return plugin.options.call({ meta: { rollupVersion } }, inputOptions) || inputOptions; }return inputOptions;}
總結
rollup系列到此也就告一段落了,從開始閱讀時的一臉懵逼,到讀到依賴收集、各工具類的十臉懵逼,到現在的輕車熟路,真是一段難忘的經歷~
學習大佬們的操作並取其精華,去其糟粕就像打怪升級一樣,你品,你細品。哈哈
在這期間也是誤導一些東西,看得多了,就會發現,其實套路都一樣,摸索出它們的核心框架
,再對功能縫縫補補,不斷更新迭代,或許我們也可以成為開源大作的作者。
如果用幾句話來描述rollup的話:
讀取併合並配置 -> 建立依賴圖 -> 讀取入口模組內容 -> 借用開源estree規範解析器進行原始碼分析,獲取依賴,遞迴此操作 -> 生成模組,掛載模組對應檔案相關資訊 -> 分析ast,構建各node例項 -> 生成chunks -> 呼叫各node重寫的render -> 利用magic-string進行字串拼接和wrap操作 -> 寫入
精簡一下就是:
字串 -> AST -> 字串
如果改系列能對你一絲絲幫忙,還請動動手指,鼓勵一下~
拜了個拜~