全文 6000 字,咱們來聊聊打包閉環,歡迎點贊關注轉發。
回顧一下,在以前的文章《有點難的 webpack 知識點:Dependency Graph 深度解析》已經聊到,通過 構建(make)階段 後,Webpack 解析出:javascript
module
內容module
與 module
之間的依賴關係圖
而進入 生成(seal)階段 後,Webpack 首先根據模塊的依賴關係、模塊特性、entry配置等計算出 Chunk Graph,肯定最終產物的數量和內容,這部分原理在前文《有點難的知識點: Webpack Chunk 分包規則詳解》中也有較詳細的描述。前端
本文繼續聊聊 Chunk Graph 後面以後,模塊開始轉譯到模塊合併打包的過程,大致流程以下:java
爲了方便理解,我將打包過程橫向切分爲三個階段:webpack
compilation.codeGeneration
以前的全部前置操做modules
數組,完成全部模塊的轉譯操做,並將結果存儲到 compilation.codeGenerationResults
對象compilation.emitAsset
輸出產物這裏說的 業務模塊 是指開發者所編寫的項目代碼;runtime 模塊 是指 Webpack 分析業務模塊後,動態注入的用於支撐各項特性的運行時代碼,在上一篇文章 Webpack 原理系列六: 完全理解 Webpack 運行時 已經有詳細講解,這裏不贅述。web
能夠看到,Webpack 先將 modules
逐一轉譯爲模塊產物 —— 模塊轉譯,再將模塊產物拼接成 bundle —— 模塊合併打包,咱們下面會按照這個邏輯分開討論這兩個過程的原理。express
先回顧一下 Webpack 產物:bootstrap
上述示例由 index.js
/ name.js
兩個業務文件組成,對應的 Webpack 配置如上圖左下角所示;Webpack 構建產物如右邊 main.js
文件所示,包含三塊內容,從上到下分別爲:segmentfault
name.js
模塊對應的轉譯產物,函數形態index.js
模塊對應的轉譯產物,IIFE(當即執行函數) 形態其中,運行時代碼的做用與生成邏輯在上篇文章 Webpack 原理系列六: 完全理解 Webpack 運行時 已有詳盡介紹;另外兩塊分別爲 name.js
、index.js
構建後的產物,能夠看到產物與源碼語義、功能均相同,但表現形式發生了較大變化,例如 index.js
編譯先後的內容:數組
上圖右邊是 Webpack 編譯產物中對應的代碼,相對於左邊的源碼有以下變化:瀏覽器
__webpack_require__.r(__webpack_exports__);
語句,用於適配 ESM 規範import
語句被轉譯爲 __webpack_require__
函數調用console
語句所使用的 name
變量被轉譯爲 _name__WEBPACK_IMPORTED_MODULE_0__.default
那麼 Webpack 中如何執行這些轉換的呢?
模塊轉譯 操做從 module.codeGeneration
調用開始,對應到上述流程圖的:
總結一下關鍵步驟:
調用 JavascriptGenerator
的對象的 generate
方法,方法內部:
dependencies
與 presentationalDependencies
數組dependeny
對象的對應的 template.apply
方法,在 apply
內修改模塊代碼,或更新 initFragments
數組InitFragment.addToSource
靜態方法,將上一步操做產生的 source
對象與 initFragments
數組合併爲模塊產物簡單說就是遍歷依賴,在依賴對象中修改 module
代碼,最後再將全部變動合併爲最終產物。這裏面關鍵點:
Template.apply
函數中,如何更新模塊代碼InitFragment.addToSource
靜態方法中,如何將 Template.apply
所產生的 side effect 合併爲最終產物這兩部分邏輯比較複雜,下面分開講解。
上述流程中,JavascriptGenerator
類是毋庸置疑的C位角色,但它並不直接修改 module
的內容,而是繞了幾層後委託交由 Template
類型實現。
Webpack 5 源碼中,JavascriptGenerator.generate
函數會遍歷模塊的 dependencies
數組,調用依賴對象對應的 Template
子類 apply
方法更新模塊內容,提及來有點繞,原始代碼更饒,因此我將重要步驟抽取爲以下僞代碼:
class JavascriptGenerator { generate(module, generateContext) { // 先取出 module 的原始代碼內容 const source = new ReplaceSource(module.originalSource()); const { dependencies, presentationalDependencies } = module; const initFragments = []; for (const dependency of [...dependencies, ...presentationalDependencies]) { // 找到 dependency 對應的 template const template = generateContext.dependencyTemplates.get(dependency.constructor); // 調用 template.apply,傳入 source、initFragments // 在 apply 函數能夠直接修改 source 內容,或者更改 initFragments 數組,影響後續轉譯邏輯 template.apply(dependency, source, {initFragments}) } // 遍歷完畢後,調用 InitFragment.addToSource 合併 source 與 initFragments return InitFragment.addToSource(source, initFragments, generateContext); } } // Dependency 子類 class xxxDependency extends Dependency {} // Dependency 子類對應的 Template 定義 const xxxDependency.Template = class xxxDependencyTemplate extends Template { apply(dep, source, {initFragments}) { // 1. 直接操做 source,更改模塊代碼 source.replace(dep.range[0], dep.range[1] - 1, 'some thing') // 2. 經過添加 InitFragment 實例,補充代碼 initFragments.push(new xxxInitFragment()) } }
從上述僞代碼能夠看出,JavascriptGenerator.generate
函數的邏輯相對比較固化:
module
對象的依賴數組,找到每一個 dependency
對應的 template
對象,調用 template.apply
函數修改模塊內容InitFragment.addToSource
方法,合併 source
與 initFragments
數組,生成最終結果這裏的重點是 JavascriptGenerator.generate
函數並不操做 module
源碼,它僅僅提供一個執行框架,真正處理模塊內容轉譯的邏輯都在 xxxDependencyTemplate
對象的 apply
函數實現,如上例僞代碼中 24-28行。
每一個 Dependency
子類都會映射到一個惟一的 Template
子類,且一般這兩個類都會寫在同一個文件中,例如 ConstDependency
與 ConstDependencyTemplate
;NullDependency
與 NullDependencyTemplate
。Webpack 構建(make)階段,會經過 Dependency
子類記錄不一樣狀況下模塊之間的依賴關係;到生成(seal)階段再經過 Template
子類修改 module
代碼。
綜上 Module
、JavascriptGenerator
、Dependency
、Template
四個類造成以下交互關係:
Template
對象能夠經過兩種方法更新 module
的代碼:
source
對象,直接修改模塊代碼,該對象最初的內容等於模塊的源碼,通過多個 Template.apply
函數流轉後逐漸被替換成新的代碼形式initFragments
數組,在模塊源碼以外插入補充代碼片斷這兩種操做所產生的 side effect,最終都會被傳入 InitFragment.addToSource
函數,合成最終結果,下面簡單補充一些細節。
Source
是 Webpack 中編輯字符串的一套工具體系,提供了一系列字符串操做方法,包括:
Webpack 內部以及社區的不少插件、loader 都會使用 Source
庫編輯代碼內容,包括上文介紹的 Template.apply
體系中,邏輯上,在啓動模塊代碼生成流程時,Webpack 會先用模塊本來的內容初始化 Source
對象,即:
const source = new ReplaceSource(module.originalSource());
以後,不一樣 Dependency
子類按序、按需更改 source
內容,例如 ConstDependencyTemplate
中的核心代碼:
ConstDependency.Template = class ConstDependencyTemplate extends ( NullDependency.Template ) { apply(dependency, source, templateContext) { // ... if (typeof dep.range === "number") { source.insert(dep.range, dep.expression); return; } source.replace(dep.range[0], dep.range[1] - 1, dep.expression); } };
上述 ConstDependencyTemplate
中,apply 函數根據參數條件調用 source.insert
插入一段代碼,或者調用 source.replace
替換一段代碼。
除直接操做 source
外,Template.apply
中還能夠經過操做 initFragments
數組達成修改模塊產物的效果。initFragments
數組項一般爲 InitFragment
子類實例,它們一般帶有兩個函數: getContent
、getEndContent
,分別用於獲取代碼片斷的頭尾部分。
例如 HarmonyImportDependencyTemplate
的 apply
函數中:
HarmonyImportDependency.Template = class HarmonyImportDependencyTemplate extends ( ModuleDependency.Template ) { apply(dependency, source, templateContext) { // ... templateContext.initFragments.push( new ConditionalInitFragment( importStatement[0] + importStatement[1], InitFragment.STAGE_HARMONY_IMPORTS, dep.sourceOrder, key, runtimeCondition ) ); //... } }
上述 Template.apply
處理完畢後,產生轉譯後的 source
對象與代碼片斷 initFragments
數組,接着就須要調用 InitFragment.addToSource
函數將二者合併爲模塊產物。
addToSource
的核心代碼以下:
class InitFragment { static addToSource(source, initFragments, generateContext) { // 先排好順序 const sortedFragments = initFragments .map(extractFragmentIndex) .sort(sortFragmentWithIndex); // ... const concatSource = new ConcatSource(); const endContents = []; for (const fragment of sortedFragments) { // 合併 fragment.getContent 取出的片斷內容 concatSource.add(fragment.getContent(generateContext)); const endContent = fragment.getEndContent(generateContext); if (endContent) { endContents.push(endContent); } } // 合併 source concatSource.add(source); // 合併 fragment.getEndContent 取出的片斷內容 for (const content of endContents.reverse()) { concatSource.add(content); } return concatSource; } }
能夠看到,addToSource
函數的邏輯:
initFragments
數組,按順序合併 fragment.getContent()
的產物source
對象initFragments
數組,按順序合併 fragment.getEndContent()
的產物因此,模塊代碼合併操做主要就是用 initFragments
數組一層一層包裹住模塊代碼 source
,而二者都在 Template.apply
層面維護。
通過 Template.apply
轉譯與 InitFragment.addToSource
合併以後,模塊就完成了從用戶代碼形態到產物形態的轉變,爲加深對上述 模塊轉譯 流程的理解,接下來咱們嘗試開發一個 Banner 插件,實如今每一個模塊前自動插入一段字符串。
實現上,插件主要涉及 Dependency
、Template
、hooks
對象,代碼:
const { Dependency, Template } = require("webpack"); class DemoDependency extends Dependency { constructor() { super(); } } DemoDependency.Template = class DemoDependencyTemplate extends Template { apply(dependency, source) { const today = new Date().toLocaleDateString(); source.insert(0, `/* Author: Tecvan */ /* Date: ${today} */ `); } }; module.exports = class DemoPlugin { apply(compiler) { compiler.hooks.thisCompilation.tap("DemoPlugin", (compilation) => { // 調用 dependencyTemplates ,註冊 Dependency 到 Template 的映射 compilation.dependencyTemplates.set( DemoDependency, new DemoDependency.Template() ); compilation.hooks.succeedModule.tap("DemoPlugin", (module) => { // 模塊構建完畢後,插入 DemoDependency 對象 module.addDependency(new DemoDependency()); }); }); } };
示例插件的關鍵步驟:
DemoDependency
與 DemoDependencyTemplate
類,其中 DemoDependency
僅作示例用,沒有實際功能;DemoDependencyTemplate
則在其 apply
中調用 source.insert
插入字符串,如示例代碼第 10-14 行compilation.dependencyTemplates
註冊 DemoDependency
與 DemoDependencyTemplate
的映射關係thisCompilation
鉤子取得 compilation
對象succeedModule
鉤子訂閱 module
構建完畢事件,並調用 module.addDependency
方法添加 DemoDependency
依賴完成上述操做後,module
對象的產物在生成過程就會調用到 DemoDependencyTemplate.apply
函數,插入咱們定義好的字符串,效果如:
感興趣的讀者也能夠直接閱讀 Webpack 5 倉庫的以下文件,學習更多用例:
- lib/dependencies/ConstDependency.js,一個簡單示例,可學習
source
的更多操做方法- lib/dependencies/HarmonyExportSpecifierDependencyTemplate.js,一個簡單示例,可學習
initFragments
數組的更多用法- lib/dependencies/HarmonyImportDependencyTemplate.js,一個較複雜但使用率極高的示例,可綜合學習
source
、initFragments
數組的用法
講完單個模塊的轉譯過程後,咱們先回到這個流程圖:
流程圖中,compilation.codeGeneration
函數執行完畢 —— 也就是模塊轉譯階段完成後,模塊的轉譯結果會一一保存到 compilation.codeGenerationResults
對象中,以後會啓動一個新的執行流程 —— 模塊合併打包。
模塊合併打包 過程會將 chunk 對應的 module 及 runtimeModule 按規則塞進 模板框架 中,最終合併輸出成完整的 bundle 文件,例如上例中:
示例右邊 bundle 文件中,紅框框出來的部分爲用戶代碼文件及運行時模塊生成的產物,其他部分撐起了一個 IIFE 形式的運行框架即爲 模板框架,也就是:
(() => { // webpackBootstrap "use strict"; var __webpack_modules__ = ({ "module-a": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { // ! module 代碼, }), "module-b": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { // ! module 代碼, }) }); // The module cache var __webpack_module_cache__ = {}; // The require function function __webpack_require__(moduleId) { // ! webpack CMD 實現 } /************************************************************************/ // ! 各類 runtime /************************************************************************/ var __webpack_exports__ = {}; // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. (() => { // ! entry 模塊 })(); })();
捋一下這裏的邏輯,運行框架包含以下關鍵部分:
entry
外的其它模塊代碼的 __webpack_modules__
對象,對象的 key 爲模塊標誌符;值爲模塊轉譯後的代碼__webpack_require__
函數entry
代碼的 IIFE 函數模塊轉譯 是將 module
轉譯爲能夠在宿主環境如瀏覽器上運行的代碼形式;而 模塊合併 操做則串聯這些 modules
,使之總體符合開發預期,可以正常運行整個應用邏輯。接下來,咱們揭曉這部分代碼的生成原理。
在 compilation.codeGeneration
執行完畢,即全部用戶代碼模塊與運行時模塊都執行完轉譯操做後,seal
函數調用 compilation.createChunkAssets
函數,觸發 renderManifest
鉤子,JavascriptModulesPlugin
插件監聽到這個鉤子消息後開始組裝 bundle,僞代碼:
// Webpack 5 // lib/Compilation.js class Compilation { seal() { // 先把全部模塊的代碼都轉譯,準備好 this.codeGenerationResults = this.codeGeneration(this.modules); // 1. 調用 createChunkAssets this.createChunkAssets(); } createChunkAssets() { // 遍歷 chunks ,爲每一個 chunk 執行 render 操做 for (const chunk of this.chunks) { // 2. 觸發 renderManifest 鉤子 const res = this.hooks.renderManifest.call([], { chunk, codeGenerationResults: this.codeGenerationResults, ...others, }); // 提交組裝結果 this.emitAsset(res.render(), ...others); } } } // lib/javascript/JavascriptModulesPlugin.js class JavascriptModulesPlugin { apply() { compiler.hooks.compilation.tap("JavascriptModulesPlugin", (compilation) => { compilation.hooks.renderManifest.tap("JavascriptModulesPlugin", (result, options) => { // JavascriptModulesPlugin 插件中經過 renderManifest 鉤子返回組裝函數 render const render = () => // render 內部根據 chunk 內容,選擇使用模板 `renderMain` 或 `renderChunk` // 3. 監聽鉤子,返回打包函數 this.renderMain(options); result.push({ render /* arguments */ }); return result; } ); }); } renderMain() {/* */} renderChunk() {/* */} }
這裏的核心邏輯是,compilation
以 renderManifest
鉤子方式對外發布 bundle 打包需求; JavascriptModulesPlugin
監聽這個鉤子,按照 chunk 的內容特性,調用不一樣的打包函數。
上述僅針對 Webpack 5。在 Webpack 4 中,打包邏輯集中在
MainTemplate
完成。
JavascriptModulesPlugin
內置的打包函數有:
renderMain
:打包主 chunk 時使用renderChunk
:打包子 chunk ,如異步模塊 chunk 時使用兩個打包函數實現的邏輯接近,都是按順序拼接各個模塊,下面簡單介紹下 renderMain
的實現。
renderMain
函數renderMain
函數涉及比較多場景判斷,原始代碼很長很繞,我摘了幾個重點步驟:
class JavascriptModulesPlugin { renderMain(renderContext, hooks, compilation) { const { chunk, chunkGraph, runtimeTemplate } = renderContext; const source = new ConcatSource(); // ... // 1. 先計算出 bundle CMD 核心代碼,包含: // - "var __webpack_module_cache__ = {};" 語句 // - "__webpack_require__" 函數 const bootstrap = this.renderBootstrap(renderContext, hooks); // 2. 計算出當前 chunk 下,除 entry 外其它模塊的代碼 const chunkModules = Template.renderChunkModules( renderContext, inlinedModules ? allModules.filter((m) => !inlinedModules.has(m)) : allModules, (module) => this.renderModule( module, renderContext, hooks, allStrict ? "strict" : true ), prefix ); // 3. 計算出運行時模塊代碼 const runtimeModules = renderContext.chunkGraph.getChunkRuntimeModulesInOrder(chunk); // 4. 重點來了,開始拼接 bundle // 4.1 首先,合併核心 CMD 實現,即上述 bootstrap 代碼 const beforeStartup = Template.asString(bootstrap.beforeStartup) + "\n"; source.add( new PrefixSource( prefix, useSourceMap ? new OriginalSource(beforeStartup, "webpack/before-startup") : new RawSource(beforeStartup) ) ); // 4.2 合併 runtime 模塊代碼 if (runtimeModules.length > 0) { for (const module of runtimeModules) { compilation.codeGeneratedModules.add(module); } } // 4.3 合併除 entry 外其它模塊代碼 for (const m of chunkModules) { const renderedModule = this.renderModule(m, renderContext, hooks, false); source.add(renderedModule) } // 4.4 合併 entry 模塊代碼 if ( hasEntryModules && runtimeRequirements.has(RuntimeGlobals.returnExportsFromRuntime) ) { source.add(`${prefix}return __webpack_exports__;\n`); } return source; } }
核心邏輯爲:
__webpack_require__
函數chunkModules
開始執行合併操做,子步驟有:
chunkModules
變量,合併除 entry 外其它模塊代碼總結:先計算出不一樣組成部分的產物形態,以後按順序拼接打包,輸出合併後的版本。
至此,Webpack 完成 bundle 的轉譯、打包流程,後續調用 compilation.emitAsset
,按上下文環境將產物輸出到 fs 便可,Webpack 單次編譯打包過程就結束了。
本文深刻 Webpack 源碼,詳細討論了打包流程後半截 —— 從 chunk graph 生成一直到最終輸出產物的實現邏輯,重點:
回顧一下,咱們:
至此,Webpack 編譯打包的主體流程已經可以很好地串聯起來,相信讀者沿着這條文章脈絡,細心對照源碼耐心學習,一定對前端的打包與工程化有一個深度的理解,互勉。