歡迎大家前往雲+社區,獲取更多騰訊海量技術實踐干貨哦~
作者介紹:陳柏信,騰訊前端開發,目前主要負責手Q游戲中心業務開發,以及項目相關的技術升級、架構優化等工作。
前言
webpack 是一個強大的模塊打包工具,之所以強大的一個原因在於它擁有靈活、豐富的插件機制。但是 webpack 的文檔不太友好,就我自己的學習經歷來說,官方的文檔並不詳細,網上的學習資料又少有完整的概述和例子。所以,在研究了一段時間的 webpack 源碼之后,自己希望寫個系列文章,結合自己的實踐一起來談談 webpack 插件這個主題,也希望能夠幫助其他人更全面地了解 webpack。
這篇文章是系列文章的第二篇,將會從對象的角度來講解 webpack。如果你想從整體角度了解 webpack,可以先閱讀系列文章的第一篇:
P.S. 以下的分析都基於 webpack 3.6.0
P.S. 本文將繼續沿用第一篇文章的名詞,任務點表示通過 plugin 方法注冊的名稱
webpack中的核心對象
跟第一篇文章類似,我們不會將所有 webpack 中的對象都拿出來講解,而是整理了一些比較核心的概念。我們可以先看看下面的類圖:
下面的論述將會逐一講述類圖中的對象,首先我們先來看一下最頂層的類 Tapable。
Tapable
Tapable 提供了 webpack 中基於任務點的架構基礎,它將提供任務點注冊的方法以及觸發的方法。
一個簡單的例子,使用 plugin 方法來注冊一個任務點,然后使用 applyPlugins 方法觸發:
Tapable 里面注冊任務點只有 plugin 方法,但是觸發任務點的方法是提供了很多,可以分為同步和異步執行兩類:
同步執行:
- applyPlugins(name,...params)
- applyPlugins0(name)
- applyPlugins1(name,param)
- applyPlugins2(name,param1,param2)
- applyPluginsWaterfall(name,init,...params)
- applyPluginsWaterfall0(name,init)
- applyPluginsWaterfall1(name,init,param)
- applyPluginsWaterfall2(name,init,param1,param2)
- applyPluginsBailResult(name,...params)
- applyPluginsBailResult0(name)
- applyPluginsBailResult1(name,param)
- applyPluginsBailResult2(name,param1,param2)
- applyPluginsBailResult3(name,param1,param2,param3)
- applyPluginsBailResult4(name,param1,param2,param3,param4)
- applyPluginsBailResult5(name,param1,param2,param3,param4,param5)
異步執行:
- applyPluginsAsync(name,...params,callback)
- applyPluginsAsyncSeries(name,...params,callback)
- applyPluginsAsyncSeries1(name,param,callback)
- applyPluginsAsyncSeriesBailResult(name,...params,callback)
- applyPluginsAsyncSeriesBailResult1(name,param,callback)
- applyPluginsAsyncWaterfall(name,init,...params,callback)
- applyPluginsParallel(name,...params,callback)
- applyPluginsParallelBailResult(name,...params,callback)
- applyPluginsParallelBailResult1(name,param,callback)
雖然上面的方法看起來很多,但從函數名就聯想到函數的實際功能:
- *Waterfall 的方法會將上一個監聽器的執行結果傳給下一個
- *BailResult 的方法只會執行到第一個返回結果不為undefined的監聽器
- *Series 的方法會嚴格線性來執行異步監聽器,只有上一個結束下一個才會開始
- *Parallel 的方法會並行執行異步監聽器
- 函數名稱最后如果帶有數字,那么會按照實際的參數傳給監聽器。如果有數字,則嚴格按照數字來傳遞參數個數。
最后 Tapable 類還提供了一個方法 apply,它的作用是提供了外部插件注冊任務點的統一接口,要求都在 apply 方法內部進行任務點注冊邏輯:
webpack 中自定義插件就是調用 Compiler 實例對象(繼承於 Tapable)的 apply 方法:
webpack 源碼中隨處可以見 Tapable 的身影,在了解其工作原理對理解源碼很有幫助。 Compiler 繼承了 Tapable,同時也作為構建的入口對象,下面我們來看一下。
Compiler
Compiler 是一個編譯器實例,在 webpack 的每個進程中只會創建一個對象,它用來創建構建對象 Compilation,本身需要注意的屬性和方法並不是很多。下面我們找幾個主要的屬性來說一下。
options屬性
當 webpack 開始運行時,第一件事就是解析我們傳入的配置,然后將配置賦值給 Compiler 實例:
因此,我們可以直接通過這個屬性來獲取到解析后的 webpack 配置:
如果你不滿足於官網給出的配置文檔,想要了解更多配置解析,可以看看 WebpackOptionsDefaulter.js 這個文件,這里不再贅述。
輸入輸出
Compiler 實例在一開始也會初始化輸入輸出,分別是 inputFileSystem 和 outputFileSystem 屬性,一般情況下這兩個屬性都是對應的 nodejs 中拓展后的 fs 對象。但是有一點要注意,當 Compiler 實例以 watch模式運行時, outputFileSystem 會被重寫成內存輸出對象。也就是說,實際上在 watch 模式下,webpack 構建后的文件並不會生成真正的文件,而是保存在內存中。
我們可以使用 inputFileSystem 和 outputFileSystem 屬性來幫助我們實現一些文件操作,如果你希望自定義插件的一些輸入輸出行為能夠跟 webpack 盡量同步,那么最好使用 Compiler 提供的這兩個變量:
webpack 的 inputFileSystem 會相對更復雜一點,它內部實現了一些緩存的機制,使得性能效率更高。如果對這部分有興趣,可以從這個 NodeEnvironmentPlugin 插件開始看起,它是內部初始化了 inputFileSystem 和 outputFileSystem:
創建子編譯器
在第一篇文章講解 Compilation 實例化的時候,有略微提及到創建子編譯器的內容:
這里為什么會有 compilation 和 this-compilation 兩個任務點?其實是跟子編譯器有關, Compiler 實例通過
createChildCompiler 方法可以創建子編譯器實例 childCompiler,創建時 childCompiler 會復制
compiler 實例的任務點監聽器。任務點 compilation 的監聽器會被復制,而任務點 this-compilation
的監聽器不會被復制。 更多關於子編譯器的內容,將在其他文章中討論。
這里我們來仔細看一下子編譯器是如何創建的, Compiler 實例通過 createChildCompiler 的方法來創建:
上面的代碼看起來很多,但其實主要邏輯基本都是在拷貝父編譯器的屬性到子編譯器上面。值得注意的一點是第9行,子編譯器在拷貝父編譯器的任務點時,會過濾掉 make, compile, emit, after-emit, invalid, done, this-compilation這些任務點。
如果你閱讀過第一篇文章(如果沒有,推薦先看一下),應該會知道上面任務點在整個構建流程中的位置。從這里我們也可以看出來,子編譯器跟父編譯器的一個差別在於,子編譯器並沒有完整的構建流程。 比如子編譯器沒有文件生成階段( emit任務點),它的文件生成必須掛靠在父編譯器下面來實現。
另外需要注意的是,子編譯器的運行入口並非 run 方法 ,而是有單獨的 runAsChild 方法來運行,從代碼上面也能夠直接看出來,它馬上調用了 compile 方法,跳過了 run, make等任務點:
那么子編譯器有什么作用呢?從上面功能和流程來看,子編譯器仍然擁有完整的模塊解析和chunk生成階段。也就是說我們可以利用子編譯器來獨立(於父編譯器)跑完一個核心構建流程,額外生成一些需要的模塊或者chunk。
事實上一些外部的 webpack 插件就是這么做的,比如常用的插件 html-webpack-plugin 中,就是利用子編譯器來獨立完成 html 文件的構建,為什么不能直接讀取 html 文件?因為 html 文件中可能依賴其他外部資源(比如 img 的src屬性),所以加載 html 文件時仍然需要一個額外的完整的構建流程來完成這個任務,子編譯器的作用在這里就體現出來了:
在下一篇文章中我們將親自實現一個插件,關於子編譯器的具體實踐到時再繼續討論。
Compilation
接下來我們來看看最重要的 Compilation 對象,在上一篇文章中,我們已經說明過部分屬性了,比如我們簡單回顧一下
- modules 記錄了所有解析后的模塊
- chunks 記錄了所有chunk
- assets記錄了所有要生成的文件
上面這三個屬性已經包含了 Compilation 對象中大部分的信息,但是我們也只是有個大致的概念,特別是 modules 中每個模塊實例到底是什么東西,我們並不太清楚。所以下面的內容將會比較細地講解。
但如果你對這部分內容不感興趣也可以直接跳過,因為能真正使用的場景不會太多,但它能加深對 webpack 構建的理解。
所謂的模塊
Compilation 在解析過程中,會將解析后的模塊記錄在 modules 屬性中,那么每一個模塊實例又是什么呢?
首先我們先回顧一下最開始的類圖,我們會發現跟模塊相關的類非常多,看起來類之間的關系也十分復雜,但其實只要記住下面的公式就很好理解:
這個公式的解讀是: 一個依賴對象(Dependency)經過對應的工廠對象(Factory)創建之后,就能夠生成對應的模塊實例(Module)。
首先什么是 Dependency?我個人的理解是,還未被解析成模塊實例的依賴對象。比如我們運行 webpack 時傳入的入口模塊,或者一個模塊依賴的其他模塊,都會先生成一個 Dependency 對象。作為基類的 Dependency 十分簡單,內部只有一個 module 屬性來記錄最終生成的模塊實例。但是它的派生類非常多,webpack 中有單獨的文件夾( webpack/lib/dependencies)來存放所有的派生類,這里的每一個派生類都對應着一種依賴的場景。比如從 CommonJS 中require一個模塊,那么會先生成 CommonJSRequireDependency。
有了 Dependency 之后,如何找到對應的工廠對象呢? Dependecy 的每一個派生類在使用前,都會先確定對應的工廠對象,比如 SingleEntryDependency 對應的工廠對象是 NormalModuleFactory。這些信息全部是記錄在 Compilation 對象的 dependencyFactories 屬性中,這個屬性是 ES6 中的 Map 對象。直接看下面的代碼可能更容易理解:
一種工廠對象只會生成一種模塊,所以不同的模塊實例都會有不同的工廠對象來生成。模塊的生成過程我們在第一篇文章有討論過,無非就是解析模塊的 request, loaders等信息然后實例化。
模塊對象有哪些特性呢?同樣在第一篇文章中,我們知道一個模塊在實例化之后並不意味着構建就結束了,它還有一個內部構建的過程。所有的模塊實例都有一個 build 方法,這個方法的作用是開始加載模塊源碼(並應用loaders),並且通過 js 解析器來完成依賴解析。這里要兩個點要注意:
- 模塊源碼最終是保存在 _source 屬性中,可以通過 _source.source() 來得到。注意在 build 之前 _source是不存在的。
- js 解析器解析之后會記錄所有的模塊依賴,這些依賴其實會分為三種,分別記錄在 variables, dependencies, blocks屬性。模塊構建之后的遞歸構建過程,其實就是讀取這三個屬性來重復上面的過程:依賴 => 工廠 => 模塊
我們再來看看這些模塊類,從前面的類圖看,它們是繼承於 Module 類。這個類實際上才是我們平常用來跟 chunk 打交道的類對象,它內部有 _chunks 屬性來記錄后續所在的 chunk 信息,並且提供了很多相關的方法來操作這個對象: addChunk, removeChunk, isInChunk, mapChunks等。后面我們也會看到, Chunk 類與之對應。
Module 類往上還會繼承於 DependenciesBlock,這個是所有模塊的基類,它包含了處理依賴所需要的屬性和方法。上面所說的 variables, dependencies, blocks 也是這個基類擁有的三個屬性。它們分別是:
- variables 對應需要對應的外部變量,比如 __filename, __dirname, process 等node環境下特有的變量
- dependencies 對應需要解析的其他普通模塊,比如 require("./a") 中的 a 模塊會先生成一個 CommonJSRequireDependency
- blocks 對應需要解析的代碼塊(最終會對應成一個 chunk),比如 require.ensure("./b"),這里的 b 會生成一個 DependenciesBlock 對象
經過上面的討論之后,我們基本將 webpack 中於模塊相關的對象、概念都涉及到了,剩下還有模塊渲染相關的模板,會在下面描述 Template 時繼續討論。
Chunk
討論完 webpack 的模塊之后,下面需要說明的是 Chunk 對象。關於 chunk 的生成,在第一篇文章中有涉及,這里不再贅述。 chunk 只有一個相關類,而且並不復雜。 Chunk 類內部的主要屬性是 _modules,用來記錄包含的所有模塊對象,並且提供了很多方法來操作: addModule, removeModule, mapModules 等。 另外有幾個方法可能比較實用,這里也列出來:
- integrate 用來合並其他chunk
- split 用來生成新的子 chunk
- hasRuntime 判斷是否是入口 chunk 其他關於 chunk 的內容,有興趣的同學可以直接查看源碼。
Template
Compilation 實例在生成最終文件時,需要將所有的 chunk 渲染(生成代碼)出來,這個時候需要用到下面幾個屬性:
- mainTemplate 對應 MainTemplate 類,用來渲染入口 chunk
- chunkTemplate 對應 ChunkTemplate 類,用來傳染非入口 chunk
- moduleTemplate 對應 ModuleTemplate,用來渲染 chunk 中的模塊
- dependencyTemplates 記錄每一個依賴類對應的模板
在第一篇文章時,有略微描述過 chunk 渲染的過程,這里再仔細地過一遍,看看這幾個屬性是如何應用在渲染過程中的:
首先 chunk 的渲染入口是 mainTemplate 和 chunkTemplate 的 render 方法。根據 chunk 是否是入口 chunk 來區分使用哪一個:
兩個類的 render 方法將生成不同的"包裝代碼", MainTemplate 對應的入口 chunk 需要帶有 webpack 的啟動代碼,所以會有一些函數的聲明和啟動。 這兩個類都只負責這些"包裝代碼"的生成,包裝代碼中間的每個模塊代碼,是通過調用 renderChunkModules 方法來生成的。這里的 renderChunkModules 是由他們的基類 Template 類提供,方法會遍歷 chunk 中的模塊,然后使用 ModuleTemplate 來渲染。
ModuleTemplate 做的事情跟 MainTemplate 類似,它同樣只是生成"包裝代碼"來封裝真正的模塊代碼,而真正的模塊代碼,是通過模塊實例的 source 方法來提供。該方法會先讀取 _source 屬性,即模塊內部構建時應用loaders之后生成的代碼,然后使用 dependencyTemplates 來更新模塊源碼。
dependencyTemplates 是 Compilation 對象的一個屬性,它跟 dependencyFactories 同樣是個 Map 對象,記錄了所有的依賴類對應的模板類。
上面用文字來描述這個過程可能十分難懂,所以我們直接看實際的例子。比如下面這個文件:
let a = require("./a")
我們來看看使用 webpack 構建后最終的文件:
其中,從 1-113 行都是 MainTemplate 生成的啟動代碼,剩余的代碼生成如下圖所示:
總結
通過這篇文章,我們將 webpack 中的一些核心概念和對象都進行了不同程度的討論,這里再總結一下他們主要的作用和意義:
- Tapable 為 webpack 的整體構建流程提供了基礎,利用事件機制來分離龐大的構建任務,保證了 webpack 強大的配置能力。
- Compiler 對象作為構建入口對象,負責解析全局的 webpack 配置,再將配置應用到 Compilation 對象中。
- Compilation 對象是每一次構建的核心對象,包含了一次構建過程的全部信息。理清楚 Compilation 對象核心的任務點和相關數據,是理解 webpack 構建過程的關鍵。
以上內容,希望能夠幫助大家進一步了解 webpack ,感謝大家閱讀~
相關閱讀
玩轉webpack(一)上篇:webpack的基本架構和構建流程
玩轉webpack(一)下篇:webpack的基本架構和構建流程
利用神經網絡算法的C#手寫數字識別
此文已由作者授權雲加社區發布,轉載請注明文章出處