1. 程式人生 > >原來rollup這麼簡單之 rollup.generate + rollup.write篇

原來rollup這麼簡單之 rollup.generate + rollup.write篇

> 大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。 > 內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。 > 分享不易,希望能夠得到大家的支援和關注。 #### 計劃 rollup系列打算一章一章的放出,內容更精簡更專一更易於理解 目前打算分為一下幾章: - [rollup.rollup](https://juejin.im/post/5e6f820de51d452716052170) - rollup.generate + rollup.write <==== 當前文章 - rollup.watch - 外掛的實現 #### TL;DR 書接上文,我們知道rollup.rollup對配置中的入口進行了解析、依賴掛載、資料化這些操作,最終返回了一個chunks,然後返回了一些方法: ```javascript rollup() { const chunks = await graph.build(); return { generate, // ... } } 這其中利用了閉包的原理,以便後續方法可以訪問到rollup結果 ``` 這期我們就深入generate方法,來看看它的`內心世界` 還是老套路,在看程式碼前,先大白話說下整個過程,rollup.generate()主要分為以下幾步: 1. 配置標準化、建立外掛驅動器 2. chunks、assets收集 3. preserveModules模式處理 4. 預渲染 5. chunk優化 6. 原始碼render 7. 產出過濾、排序 ----- 最近看到這麼一句話: > '將者,智、信、仁、勇、嚴也' 指的是將者的素養,順序代表著每個能力的重要性: 智: 智略、謀略 信:信義、信用 仁:仁義、聲譽 勇:勇武、果斷 嚴:鐵律、公證 時至今日,仍然奏效,哪怕是放到it領域。雖然不能直接拿過來,但內涵都是一樣的。 想要做好it這一行,先要自身硬(智),然後是產出質量(信),同事間的默契合作(仁),對事情的判斷(勇)和對團隊的要求以及獎懲制度(嚴)。 #### 注意點 >
!!!版本 => 筆者閱讀的rollup版本為: 1.32.0 > !!!提示 => 標有TODO為具體實現細節,會視情況分析。 > !!!注意 => 每一個子標題都是父標題(函式)內部實現 > !!!強調 => rollup中模組(檔案)的id就是檔案地址,所以類似resolveID這種就是解析檔案地址的意思,我們可以返回我們想返回的檔案id(也就是地址,相對路徑、決定路徑)來讓rollup載入 > rollup是一個核心,只做最基礎的事情,比如提供[預設模組(檔案)載入機制](https://github.com/FoxDaxian/rollup-analysis/blob/master/src/utils/defaultPlugin.ts#L6), 比如打包成不同風格的內容,我們的外掛中提供了載入檔案路徑,解析檔案內容(處理ts,sass等)等操作,是一種插拔式的設計,和webpack類似 >
插拔式是一種非常靈活且可長期迭代更新的設計,這也是一箇中大型框架的核心,人多力量大嘛~ #### 主要通用模組以及含義 1. Graph: 全域性唯一的圖,包含入口以及各種依賴的相互關係,操作方法,快取等。是rollup的核心 2. PathTracker: 無副作用模組依賴路徑追蹤 3. PluginDriver: 外掛驅動器,呼叫外掛和提供外掛環境上下文等 4. FileEmitter: 資源操作器 5. GlobalScope: 全域性作用局,相對的還有區域性的 6. ModuleLoader: 模組載入器 7. NodeBase: ast各語法(ArrayExpression、AwaitExpression等)的構造基類 #### 主流程解析 - generate方法: 呼叫封裝好的內建私有方法,返回promise,一個一個的來,先來看`getOutputOptionsAndPluginDriver`; ```javascript generate: ((rawOutputOptions: GenericConfigObject) =>
{ // 過濾output配置選項,並建立output的外掛驅動器 const { outputOptions, outputPluginDriver } = getOutputOptionsAndPluginDriver( rawOutputOptions ); const promise = generate(outputOptions, false, outputPluginDriver).then(result => createOutput(result) ); // 丟棄老版本欄位 Object.defineProperty(promise, 'code', throwAsyncGenerateError); Object.defineProperty(promise, 'map', throwAsyncGenerateError); return promise; }) ``` - getOutputOptionsAndPluginDriver: 該方法通過output配置生成標準化配置和output外掛驅動器 PluginDriver類暴露了createOutputPluginDriver方法 ```javascript class PluginDriver { // ... public createOutputPluginDriver(plugins: Plugin[]): PluginDriver { return new PluginDriver( this.graph, plugins, this.pluginCache, this.preserveSymlinks, this.watcher, this ); } // ... } ``` 引用該方法,建立output的外掛驅動器: `graph.pluginDriver.createOutputPluginDriver` ```javascript const outputPluginDriver = graph.pluginDriver.createOutputPluginDriver( // 統一化外掛 normalizePlugins(rawOutputOptions.plugins, ANONYMOUS_OUTPUT_PLUGIN_PREFIX) ); ``` 生成標準output配置更簡單了,呼叫之前在rollup.rollup方法中用到的,用來提取input配置的mergeOptions(參考mergeOptions.ts)方法,獲取處理後的配置,呼叫`outputOptions`鉤子函式,該鉤子可以讀取到即將傳遞給generate/write的配置,進行更改,但是rollup更推薦在renderStart中進行更改等操作。之後進行一些列校驗判斷最終返回`ourputOptions` ```javascript function normalizeOutputOptions( inputOptions: GenericConfigObject, rawOutputOptions: GenericConfigObject, hasMultipleChunks: boolean, outputPluginDriver: PluginDriver ): OutputOptions { const mergedOptions = mergeOptions({ config: { output: { ...rawOutputOptions, // 可以用output裡的覆蓋 ...(rawOutputOptions.output as object), // 不過input裡的output優先順序最高,但是不是每個地方都返回,有的不會使用 ...(inputOptions.output as object) } } }); // 如果merge過程中出錯了 if (mergedOptions.optionError) throw new Error(mergedOptions.optionError); // 返回的是陣列,但是rollup不支援陣列,所以獲取第一項,目前也只會有一項 const mergedOutputOptions = mergedOptions.outputOptions[0]; const outputOptionsReducer = (outputOptions: OutputOptions, result: OutputOptions) => result || outputOptions; // 觸發鉤子函式 const outputOptions = outputPluginDriver.hookReduceArg0Sync( 'outputOptions', [mergedOutputOptions], outputOptionsReducer, pluginContext => { const emitError = () => pluginContext.error(errCannotEmitFromOptionsHook()); return { ...pluginContext, emitFile: emitError, setAssetSource: emitError }; } ); // 檢查經過外掛處理過的output配置 checkOutputOptions(outputOptions); // output.file 和 output.dir是互斥的 if (typeof outputOptions.file === 'string') { if (typeof outputOptions.dir === 'string') return error({ code: 'INVALID_OPTION', message: 'You must set either "output.file" for a single-file build or "output.dir" when generating multiple chunks.' }); if (inputOptions.preserveModules) { return error({ code: 'INVALID_OPTION', message: 'You must set "output.dir" instead of "output.file" when using the "preserveModules" option.' }); } if (typeof inputOptions.input === 'object' && !Array.isArray(inputOptions.input)) return error({ code: 'INVALID_OPTION', message: 'You must set "output.dir" instead of "output.file" when providing named inputs.' }); } if (hasMultipleChunks) { if (outputOptions.format === 'umd' || outputOptions.format === 'iife') return error({ code: 'INVALID_OPTION', message: 'UMD and IIFE output formats are not supported for code-splitting builds.' }); if (typeof outputOptions.file === 'string') return error({ code: 'INVALID_OPTION', message: 'You must set "output.dir" instead of "output.file" when generating multiple chunks.' }); } return outputOptions; } ``` - generate內部的`generate`方法 獲取到標準化之後的output配合和外掛驅動器後,到了內建的generate方法了,該方法接受三個引數,其中第二個引數標識是否寫入,也就是說該方法同時用於generate和下一篇write中。 首先獲取使用者定義的資源名,沒有的話取預設值 ```javascript const assetFileNames = outputOptions.assetFileNames || 'assets/[name]-[hash][extname]'; ``` 獲取chunks的目錄交集,也就是公共的根目錄 ```javascript const inputBase = commondir(getAbsoluteEntryModulePaths(chunks)); ``` getAbsoluteEntryModulePaths獲取所有絕對路徑的chunks id,commondir參考的[node-commondir](https://github.com/substack/node-commondir)模組,原理是先獲取第一個檔案的路徑,進行split轉成陣列(設為a),然後遍歷剩餘所有檔案id,進行比對,找到不相等的那個索引,然後重新賦值給a,進行下一次迴圈,直到結束,就得到了公共的目錄。 ```javascript function commondir(files: string[]) { if (files.length === 0) return '/'; if (files.length === 1) return path.dirname(files[0]); const commonSegments = files.slice(1).reduce((commonSegments, file) => { const pathSegements = file.split(/\/+|\\+/); let i; for ( i = 0; commonSegments[i] === pathSegements[i] && i < Math.min(commonSegments.length, pathSegements.length); i++ ); return commonSegments.slice(0, i); }, files[0].split(/\/+|\\+/)); // Windows correctly handles paths with forward-slashes return commonSegments.length > 1 ? commonSegments.join('/') : '/'; } ``` 建立一個包含所有chunks和assets資訊的物件 ```javascript const outputBundleWithPlaceholders: OutputBundleWithPlaceholders = Object.create(null); ``` 呼叫外掛驅動器上的setOutputBundle將output設定到上面建立的`outputBundleWithPlaceholders`上。 ```javascript outputPluginDriver.setOutputBundle(outputBundleWithPlaceholders, assetFileNames); ``` setOutputBundle在FileEmitter類上實現,在外掛驅動器類(PluginDriver)上例項化,並將公共方法賦給外掛驅動器。 reserveFileNameInBundle方法為outputBundleWithPlaceholders上掛載檔案chunks。 finalizeAsset方法只處理資源,將資源格式化後,新增到outputBundleWithPlaceholders上。格式為: ```javascript { fileName, get isAsset(): true { graph.warnDeprecation( 'Accessing "isAsset" on files in the bundle is deprecated, please use "type === \'asset\'" instead', false ); return true; }, source, type: 'asset' }; ``` ```javascript class FileEmitter { // ... setOutputBundle = ( outputBundle: OutputBundleWithPlaceholders, assetFileNames: string ): void => { this.output = { // 打包出來的命名 assetFileNames, // 新建的空物件 => Object.create(null) bundle: outputBundle }; // filesByReferenceId是通過rollup.rollup中emitChunks的時候設定的,代表已使用的chunks // 處理檔案 for (const emittedFile of this.filesByReferenceId.values()) { if (emittedFile.fileName) { // 檔名掛在到this.output上,作為key,值為: FILE_PLACEHOLDER reserveFileNameInBundle(emittedFile.fileName, this.output.bundle, this.graph); } } // 遍歷set 處理資源 for (const [referenceId, consumedFile] of this.filesByReferenceId.entries()) { // 外掛中定義了source的情況 if (consumedFile.type === 'asset' && consumedFile.source !== undefined) { // 給this.output上繫結資源 this.finalizeAsset(consumedFile, consumedFile.source, referenceId, this.output); } } }; // ... } ``` 呼叫`renderStart`鉤子函式,用來訪問output和input配置,可能大家看到了很多呼叫鉤子函式的方法,比如hookParallel、hookSeq等等,這些都是用來觸發外掛裡提供的鉤子函式,不過是執行方式不同,有的是並行的,有的是序列的,有的只能執行通過一個等等,這會單獨抽出來說。 ```javascript await outputPluginDriver.hookParallel('renderStart', [outputOptions, inputOptions]); ``` 執行footer banner intro outro鉤子函式,內部就是執行這幾個鉤子函式,預設值為option[footer|banner|intro|outro],最後返回字串結果待拼接。 ```javascript const addons = await createAddons(outputOptions, outputPluginDriver); ``` 處理preserveModules模式,也就是是否儘可能少的打包,而不是每個模組都是一個chunk 如果是儘可能少的打包的話,就將chunks的匯出多掛載到chunks的exportNames屬性上,供之後使用 如果每個模組都是一個chunk的話,推匯出匯出模式 ```javascript for (const chunk of chunks) { // 儘可能少的打包模組 // 設定chunk的exportNames if (!inputOptions.preserveModules) chunk.generateInternalExports(outputOptions); // 儘可能多的打包模組 if (inputOptions.preserveModules || (chunk.facadeModule && chunk.facadeModule.isEntryPoint)) // 根據匯出,去推斷chunk的匯出模式 chunk.exportMode = getExportMode(chunk, outputOptions, chunk.facadeModule!.id); } ``` 預渲染chunks。 使用[magic-string](https://github.com/Rich-Harris/magic-string)模組進行source管理,初始化render配置,對依賴進行解析,新增到當前chunks的dependencies屬性上,按照執行順序對依賴們進行排序,處理準備動態引入的模組,設定唯一標誌符(?) ```javascript for (const chunk of chunks) { chunk.preRender(outputOptions, inputBase); } ``` 優化chunks ```javascript if (!optimized && inputOptions.experimentalOptimizeChunks) { optimizeChunks(chunks, outputOptions, inputOptions.chunkGroupingSize!, inputBase); optimized = true; } ``` 將chunkId賦到上文建立的outputBundleWithPlaceholders上 ```javascript assignChunkIds( chunks, inputOptions, outputOptions, inputBase, addons, outputBundleWithPlaceholders, outputPluginDriver ); ``` 設定好chunks的物件,也就是將chunks依照id設定到outputBundleWithPlaceholders上,這時候outputBundleWithPlaceholders上已經有完整的chunk資訊了 ```javascript outputBundle = assignChunksToBundle(chunks, outputBundleWithPlaceholders); ``` 語法樹解析生成code操作,最後返回outputBundle。 ```javascript await Promise.all( chunks.map(chunk => { const outputChunk = outputBundleWithPlaceholders[chunk.id!] as OutputChunk; return chunk .render(outputOptions, addons, outputChunk, outputPluginDriver) .then(rendered => { // 引用型別,outputBundleWithPlaceholders上的也變化了,所以outputBundle也變化了,最後返回outputBundle outputChunk.code = rendered.code; outputChunk.map = rendered.map; return outputPluginDriver.hookParallel('ongenerate', [ { bundle: outputChunk, ...outputOptions }, outputChunk ]); }); }) ); return outputBundle; ``` - generate內部的`createOutput`方法 createOutput接受generate的返回值,並對生成的OutputBundle進行過濾和排序 ```javascript function createOutput(outputBundle: Record): RollupOutput { return { output: (Object.keys(outputBundle) .map(fileName => outputBundle[fileName]) .filter(outputFile => Object.keys(outputFile).length > 0) as ( | OutputChunk | OutputAsset )[]).sort((outputFileA, outputFileB) => { const fileTypeA = getSortingFileType(outputFileA); const fileTypeB = getSortingFileType(outputFileB); if (fileTypeA === fileTypeB) return 0; return fileTypeA < fileTypeB ? -1 : 1; }) as [OutputChunk, ...(OutputChunk | OutputAsset)[]] }; } ``` - rollup.write write方法和generate方法幾乎一致,只不過是generate方法的第二個引數為true,供generateBundle鉤子函式中使用,已表明當前是wirte還是generate階段。 之後是獲取當前的chunks數,多出口的時候會檢測配置的file和sourcemapFile進而丟擲錯誤提示 ```javascript let chunkCount = 0; //計數 for (const fileName of Object.keys(bundle)) { const file = bundle[fileName]; if (file.type === 'asset') continue; chunkCount++; if (chunkCount > 1) break; } if (chunkCount > 1) { // sourcemapFile配置 if (outputOptions.sourcemapFile) return error({ code: 'INVALID_OPTION', message: '"output.sourcemapFile" is only supported for single-file builds.' }); // file欄位 if (typeof outputOptions.file === 'string') return error({ code: 'INVALID_OPTION', message: 'When building multiple chunks, the "output.dir" option must be used, not "output.file".' + (typeof inputOptions.input !== 'string' || inputOptions.inlineDynamicImports === true ? '' : ' To inline dynamic imports, set the "inlineDynamicImports" option.') }); } ``` 之後呼叫寫入方法: writeOutputFile ```javascript await Promise.all( Object.keys(bundle).map(chunkId => writeOutputFile(result, bundle[chunkId], outputOptions, outputPluginDriver) ) ); ``` writeOutputFile方法就很直觀了,解析路徑 ```javascript const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName); ``` 根據chunk型別進行不同的處理,assets直接獲取程式碼即可,chunks的話還需根據sourcemap選項將sourcemp追加到程式碼之後。 ```javascript if (outputFile.type === 'asset') { source = outputFile.source; } else { source = outputFile.code; if (outputOptions.sourcemap && outputFile.map) { let url: string; if (outputOptions.sourcemap === 'inline') { url = outputFile.map.toUrl(); } else { url = `${basename(outputFile.fileName)}.map`; writeSourceMapPromise = writeFile(`${fileName}.map`, outputFile.map.toString()); } if (outputOptions.sourcemap !== 'hidden') { source += `//# ${SOURCEMAPPING_URL}=${url}\n`; } } } ``` 最後呼叫fs模組進行檔案建立和內容寫入即可 ```javascript function writeFile(dest: string, data: string | Buffer) { return new Promise((fulfil, reject) => { mkdirpath(dest); fs.writeFile(dest, data, err => { if (err) { reject(err); } else { fulfil(); } }); }); } ``` 以上就是程式碼流程的解析部分,具體細節參考[程式碼庫註釋](https://github.com/FoxDaxian/rollup-analysis) #### 部分功能的具體解析 - 略 #### 總結 隨著深入閱讀發現rollup細節操作很多,很複雜,需要話更多的時間去打磨,暫時先分析了下主流程,具體的實現細節比如優化chunks、prerender等之後視情況再說吧。 不過也學到了一些東西,rollup將所有的ast型別分成了一個個的類,一個類專門處理一個ast型別,呼叫的時候只需要遍歷ast body,獲取每一項的型別,然後動態呼叫就可以了,很使用。對於ast沒有畫面感的同學可以看這裡 => [ast線上解析](https://astexplorer.net/) rollup從構建到打包,經歷了三個大步驟: 載入、解析 => 分析(依賴分析、引用次數、無用模組分析、型別分析等) => 生成 看似簡單,實則龐雜。為rollup點個