1. 程式人生 > >原來rollup這麼簡單之 tree shaking篇

原來rollup這麼簡單之 tree shaking篇

> 大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。 > 內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。 > 分享不易,希望能夠得到大家的支援和關注。 #### 計劃 rollup系列打算一章一章的放出,內容更精簡更專一更易於理解 目前打算分為以下幾章: - [rollup.rollup](https://juejin.im/post/5e6f820de51d452716052170) - [rollup.generate + rollup.write](https://juejin.im/post/5e735723e51d4526d326d9c9) - [rollup.watch](https://juejin.im/post/5e7c5012e51d455c0c18d427) - tree shaking <==== 當前文章 - plugins #### TL;DR es node: 各種語法塊的類,比如if塊,箭頭函式塊,函式呼叫塊等等 rollup()階段,分析原始碼,生成ast tree,對ast tree上的每個節點進行遍歷,判斷出是否include,是的話標記,然後生成chunks,最後匯出。 generate()或者write()階段根據rollup()階段做的標記,進行程式碼收集,最後生成真正用到的程式碼,這就是tree shaking的基本原理。 一句話就是,根據side effects的定義,設定es node的include 與 不同的es node生成不同的渲染(引入到magic string例項)函式,有magic string進行收集,最後寫入。 本文沒有具體分析各es node渲染方法和include設定的具體實現,不過有問題歡迎討論,拍磚~ #### 注意點 > !!!版本 => 筆者閱讀的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等)的構造基類 #### 流程解析 這次就不全流程解析了,咱們舉個最簡單的例子分析一下,更有助於理解。 比如我們有這麼一段簡單的程式碼: ```javascript function test() { var name = 'test'; console.log(123); } const name = '測試測試'; function fn() { console.log(name); } fn(); ``` 如你所見,打包的結果應該是不包含test函式的,像下面這樣: ```javascript 'use strict'; const name = '測試測試'; function fn() { console.log(name); } fn(); ``` 那rollup是怎麼處理這段程式碼的呢? - 模組解析 還得回到了[rollup()流程](https://juejin.im/post/5e6f820de51d452716052170),根據例子,我們可以把對`import`、`export`、`re-export`等相關的都幹掉,暫時不需要關注,瞭解最基本的流程後會水到渠成的。 關於外掛,我也不會使用任何外掛,只使用rollup內建的預設外掛。 對於這個例子來說,首先會根據解析檔案地址,獲取檔案真正的路徑: ```javascript function createResolveId(preserveSymlinks: boolean) { return function(source: string, importer: string) { if (importer !== undefined && !isAbsolute(source) && source[0] !== '.') return null; // 最終呼叫path.resolve,將合法路徑片段轉為絕對路徑 return addJsExtensionIfNecessary( resolve(importer ? dirname(importer) : resolve(), source), preserveSymlinks ); }; } ``` 然後建立rollup模組,設定快取等: ```javascript const module: Module = new Module( this.graph, id, moduleSideEffects, syntheticNamedExports, isEntry ); ``` 之後通過內建的`load`鉤子獲取檔案內容,當然咱也可以自定義該行為: ```javascript // 第二個引數是 傳給load鉤子函式的 引數,內部使用的apply return Promise.resolve(this.pluginDriver.hookFirst('load', [id])) ``` 之後經過`transform`(transform這裡可以理解為webpack的各種loader,處理不同型別檔案的)處理生成一段標準化的結構: ```javascript const source = { ast: undefined, code: 'function test() {\n var name = \'test\';\n console.log(123);\n}\nconst name = \'測試測試\';\nfunction fn() {\n console.log(name);\n}\n\nfn();\n', customTransformCache: false, moduleSideEffects: null, originalCode: 'function test() {\n var name = \'test\';\n console.log(123);\n}\nconst name = \'測試測試\';\nfunction fn() {\n console.log(name);\n}\n\nfn();\n', originalSourcemap: null, sourcemapChain: [], syntheticNamedExports: null, transformDependencies: [] } ``` 然後就到了比較關鍵的一步,將source解析並設定到當前module上: ```javascript // 生成 es tree ast this.esTreeAst = ast || tryParse(this, this.graph.acornParser, this.graph.acornOptions); // 呼叫 magic string,超方便操作字串的工具庫 this.magicString = new MagicString(code, options). // 搞一個ast上下文環境,包裝一些方法,比如動態匯入、匯出等等吧,之後會將分析到的模組或內容填充到當前module中,bind的this指向當前module this.astContext = { addDynamicImport: this.addDynamicImport.bind(this), // 動態匯入 addExport: this.addExport.bind(this), // 匯出 addImport: this.addImport.bind(this), // 匯入 addImportMeta: this.addImportMeta.bind(this), // importmeta annotations: (this.graph.treeshakingOptions && this.graph.treeshakingOptions.annotations)!, code, // Only needed for debugging deoptimizationTracker: this.graph.deoptimizationTracker, error: this.error.bind(this), fileName, // Needed for warnings getExports: this.getExports.bind(this), getModuleExecIndex: () => this.execIndex, getModuleName: this.basename.bind(this), getReexports: this.getReexports.bind(this), importDescriptions: this.importDescriptions, includeDynamicImport: this.includeDynamicImport.bind(this), includeVariable: this.includeVariable.bind(this), isCrossChunkImport: importDescription => (importDescription.module as Module).chunk !== this.chunk, magicString: this.magicString, module: this, moduleContext: this.context, nodeConstructors, preserveModules: this.graph.preserveModules, propertyReadSideEffects: (!this.graph.treeshakingOptions || this.graph.treeshakingOptions.propertyReadSideEffects)!, traceExport: this.getVariableForExportName.bind(this), traceVariable: this.traceVariable.bind(this), treeshake: !!this.graph.treeshakingOptions, tryCatchDeoptimization: (!this.graph.treeshakingOptions || this.graph.treeshakingOptions.tryCatchDeoptimization)!, unknownGlobalSideEffects: (!this.graph.treeshakingOptions || this.graph.treeshakingOptions.unknownGlobalSideEffects)!, usesTopLevelAwait: false, warn: this.warn.bind(this), warnDeprecation: this.graph.warnDeprecation.bind(this.graph) }; // 例項化Program,將結果賦給當前模組的ast屬性上以供後續使用 // !!! 注意例項中有一個included屬性,用於是否打包到最終輸出檔案中,也就是tree shaking。預設為false。!!! this.ast = new Program( this.esTreeAst, { type: 'Module', context: this.astContext }, // astContext裡包含了當前module和當前module的相關資訊,使用bind綁定當前上下文 this.scope ); // Program內部會將各種不同型別的 estree node type 的例項新增到例項上,以供後續遍歷使用 // 不同的node type繼承同一個NodeBase父類,比如箭頭函式表示式(ArrayExpression類),詳見[nodes目錄](https://github.com/FoxDaxian/rollup-analysis/tree/master/src/ast/nodes) function parseNode(esTreeNode: GenericEsTreeNode) { // 就是遍歷,然後new nodeType,然後掛載到例項上 for (const key of Object.keys(esTreeNode)) { // That way, we can override this function to add custom initialisation and then call super.parseNode // this 指向 Program構造類,通過new建立的 // 如果program上有的話,那麼跳過 if (this.hasOwnProperty(key)) continue; // ast tree上的每一個屬性 const value = esTreeNode[key]; // 不等於物件或者null或者key是annotations // annotations是type if (typeof value !== 'object' || value === null || key === 'annotations') { (this as GenericEsTreeNode)[key] = value; } else if (Array.isArray(value)) { // 如果是陣列,那麼建立陣列並遍歷上去 (this as GenericEsTreeNode)[key] = []; // this.context.nodeConstructors 針對不同的語法書型別,進行不同的操作,比如掛載依賴等等 for (const child of value) { // 迴圈然後各種new 各種型別的node,都是繼成的NodeBase (this as GenericEsTreeNode)[key].push( child === null ? null : new (this.context.nodeConstructors[child.type] || this.context.nodeConstructors.UnknownNode)(child, this, this.scope) // 處理各種ast型別 ); } } else { // 以上都不是的情況下,直接new (this as GenericEsTreeNode)[key] = new (this.context.nodeConstructors[value.type] || this.context.nodeConstructors.UnknownNode)(value, this, this.scope); } } } ``` 後面處理相關依賴模組,直接跳過咯~ ```javascript return this.fetchAllDependencies(module).then(); ``` 到目前為止,我們將檔案轉換成了模組,並解析出 es tree node 以及其內部包含的各型別的語法樹 - 使用PathTracker追蹤上下文關係 ```javascript for (const module of this.modules) { // 每個一個節點自己的實現,不是全都有 module.bindReferences(); } ``` 比如我們有箭頭函式,由於沒有`this`指向所以預設設定`UNKONW` ```javascript // ArrayExpression類,繼承與NodeBase bind() { super.bind(); for (const element of this.elements) { if (element !== null) element.deoptimizePath(UNKNOWN_PATH); } } ``` 如果有外包裹函式,就會加深一層path,最後會根據層級關係,進行程式碼的wrap - 標記模組是否可shaking 其中核心為根據isExecuted的狀態進行模組以及es tree node的引入,再次之前我們要知道includeMarked方式是獲取入口之後呼叫的。 也就是所有的入口模組(使用者定義的、動態引入、入口檔案依賴、入口檔案依賴的依賴..)都會module.isExecuted為true 之後才會呼叫下面的`includeMarked`方法,這時候module.isExecuted已經為true,即可呼叫include方法 ```javascript function includeMarked(modules: Module[]) { // 如果有treeshaking不為空 if (this.treeshakingOptions) { // 第一個tree shaking let treeshakingPass = 1; do { timeStart(`treeshaking pass ${treeshakingPass}`, 3); this.needsTreeshakingPass = false; for (const module of modules) { // 給ast node標記上include if (module.isExecuted) module.include(); } timeEnd(`treeshaking pass ${treeshakingPass++}`, 3); } while (this.needsTreeshakingPass); } else { // Necessary to properly replace namespace imports for (const module of modules) module.includeAllInBundle(); } } // 上面module.include()的實現。 include(context: InclusionContext, includeChildrenRecursively: IncludeChildren) { // 將當然程式塊的included設為true,再去遍歷當前程式塊中的所有es node,根據不同條件進行include的設定 this.included = true; for (const node of this.body) { if (includeChildrenRecursively || node.shouldBeIncluded(context)) { node.include(context, includeChildrenRecursively); } } } ``` module.include內部就涉及到es tree node了,由於NodeBase初始include為false,所以還有第二個判斷條件:當前node是否有副作用side effects。 這個是否有副作用是繼承與NodeBase的各類node子類自身的實現。目前就我看來,副作用也是有自身的協議規定的,比如修改了全域性變數這類就算是副作用,當然也有些是肯定無副作用的,比如export語句,rollup中就寫死為false了。 rollup內部不同型別的es node 實現了不同的hasEffects實現,可自身觀摩學習。可以通過[該篇文章](https://codesource.io/avoiding-side-effects-in-javascript-code/),簡單瞭解一些 side effects。 - chunks的生成 後面就是通過模組,生成chunks,當然其中還包含多chunk,少chunks等配置選項的區別,這裡不再贅述,有興趣的朋友可以參考本系列[第一篇文章](https://juejin.im/post/5e6f820de51d452716052170)或者直接檢視帶註釋的[原始碼](https://github.com/FoxDaxian/rollup-analysis) - 通過chunks生成程式碼(字串) 呼叫rollup方法後,會返回一個物件,其中包括了程式碼生成和寫入操作的write方法(已去掉一些warn等): ```javascript return { write: ((rawOutputOptions: GenericConfigObject) => { const { outputOptions, outputPluginDriver } = getOutputOptionsAndPluginDriver( rawOutputOptions ); // 這裡是關鍵 return generate(outputOptions, true, outputPluginDriver).then(async bundle => { await Promise.all( Object.keys(bundle).map(chunkId => writeOutputFile(result, bundle[chunkId], outputOptions, outputPluginDriver) // => 寫入操作 ) ); // 修改生成後的程式碼 await outputPluginDriver.hookParallel('writeBundle', [bundle]); // 目前看來是供之後快取用,提高構建速度 return createOutput(bundle); }); }) as any } ``` generate是就相對簡單些了,就是一些鉤子和方法的呼叫,比如: preRender方法將es node渲染為字串,呼叫的是各es node自身實現的render方法,具體參考程式碼哈。規則很多,這裡不贅述,我也沒細看~~ 那哪些需要渲染,哪些不需要渲染呢? 沒錯,就用到了之前定義的`include`欄位,做了一個簡單的判斷,比如: ```javascript // label node類中 function render(code: MagicString, options: RenderOptions) { // 諾~ if (this.label.included) { this.label.render(code, options); } else { code.remove( this.start, findFirstOccurrenceOutsideComment(code.original, ':', this.label.end) + 1 ); } this.body.render(code, options); } ``` 之後新增到chunks中,這樣chunks中不僅有ast,還有生成後的可執行程式碼。 之後根據format欄位獲取不同的[wrapper](https://github.com/FoxDaxian/rollup-analysis/tree/master/src/finalisers),對程式碼字串進行處理,然後傳遞給`renderChunk`方法,該方法主要為了呼叫`renderChunk`、`transformChunk`、`transformBundle`三個鉤子函式,對結果進行進一步處理。不過由於我分析的版本不是最新的,所以會與當前2.x有出入,改動詳見[changlog](https://github.com/rollup/rollup/blob/d18cb37d7c328a63c36761583ce456275f164462/CHANGELOG.md) 對了,還有sourceMap,這個能力是magic string提供的,可自行[查閱](https://github.com/Rich-Harris/magic-string#sgeneratedecodedmap-options-) 這樣我們就得到了最終想要的結果: ```javascript chunks.map(chunk => { // 通過id獲取之前設定到outputBundleWithPlaceholders上的一些屬性 const outputChunk = outputBundleWithPlaceholders[chunk.id!] as OutputChunk; return chunk .render(outputOptions, addons, outputChunk, outputPluginDriver) .then(rendered => { // 引用型別,outputBundleWithPlaceholders上的也變化了,所以outputBundle也變化了,最後返回outputBundle // 在這裡給outputBundle掛載上了code和map,後面直接返回 outputBundle 了 outputChunk.code = rendered.code; outputChunk.map = rendered.map; // 呼叫生成的鉤子函式 return outputPluginDriver.hookParallel('ongenerate', [ { bundle: outputChunk, ...outputOptions }, outputChunk ]); }); }) ``` 上面函式處理的是引用型別,所以最後可以直接返回結果。不在贅述。 - 檔案寫入 這部分沒啥好說的,大家自己看下下面的程式碼吧。其中`writeFile`方法呼叫的node fs模組提供的能力。 ```javascript function writeOutputFile( build: RollupBuild, outputFile: OutputAsset | OutputChunk, outputOptions: OutputOptions, outputPluginDriver: PluginDriver ): Promise { const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName); let writeSourceMapPromise: Promise; let source: string | Buffer; 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`; } } } return writeFile(fileName, source) .then(() => writeSourceMapPromise) .then( (): any => outputFile.type === 'chunk' && outputPluginDriver.hookSeq('onwrite', [ { bundle: build, ...outputOptions }, outputFile ]) ) .then(() => {}); } ``` - 其他 對於`import`、`export`、`re-export`這類ast node,rollup會解析生成的ast tree,獲取其中的value,也就是模組名,組合有用的資訊備用。然後就和上述流程類似了。 推薦使用[ast explorer](https://astexplorer.net/)解析一段code,然後看看裡面的結構,瞭解後會更容易理解。 ```javascript function addImport(node: ImportDeclaration) { // 比如引入了path模組 // source: { // type: 'Literal', // start: 'xx', // end: 'xx', // value: 'path', // raw: '"path"' // } const source = node.source.value; this.sources.add(source); for (const specifier of node.specifiers) { const localName = specifier.local.name; // 重複引入了 if (this.importDescriptions[localName]) { return this.error( { code: 'DUPLICATE_IMPORT', message: `Duplicated import '${localName}'` }, specifier.start ); } const isDefault = specifier.type === NodeType.ImportDefaultSpecifier; const isNamespace = specifier.type === NodeType.ImportNamespaceSpecifier; const name = isDefault ? 'default' : isNamespace ? '*' : (specifier as ImportSpecifier).imported.name; // 匯入的模組的相關描述 this.importDescriptions[localName] = { module: null as any, // filled in later name, source, start: specifier.start }; } } ``` #### 總結 感覺這次寫的不好,看下來可能會覺得只是標記與收集的這麼一個過程,但是其內部細節是非常複雜的。以至於你需要深入瞭解side effects的定義與影響。日後也許會專門整理一下。 rollup系列也快接近尾聲了,雖然一直在自嗨,但是也蠻爽的。 學習使我快樂,哈哈~~ ![](https://user-gold-cdn.xitu.io/2020/4/6/1714fc78404f1734?w=218&h=240&f=jpeg&s