細說 webpack 之流程篇
0.1. 引言
目前,幾乎所有業務的開發構建都會用到 webpack 。的確,作為模組載入和打包神器,只需配置幾個檔案,載入各種 loader 就可以享受無痛流程化開發。但對於 webpack 這樣一個複雜度較高的外掛集合,它的整體流程及思想對我們來說還是很透明的。那麼接下來我會帶你瞭解 webpack 這樣一個構建黑盒,首先來談談它的流程。
0.2. 準備工作
1. webstorm 中配置 webpack-webstorm-debugger-script
在開始瞭解之前,必須要能對 webpack 整個流程進行 debug ,配置過程比較簡單。
先將 webpack-webstorm-debugger-script
webstorm-debugger.js
置於 webpack.config.js
的同一目錄下,搭建好你的腳手架後就可以直接 Debug 這個 webstorm-debugger.js 檔案了。
2. webpack.config.js 配置
估計大家對 webpack.config.js 的配置也嘗試過不少次了,這裡就大致對這個配置檔案進行個分析。
var path = require('path'); var node_modules = path.resolve(__dirname, 'node_modules');var pathToReact = path.resolve(node_modules, 'react/dist/react.min.js'); module.exports = { // 入口檔案,是模組構建的起點,同時每一個入口檔案對應最後生成的一個 chunk。 entry: { bundle: [ 'webpack/hot/dev-server', 'webpack-dev-server/client?http://localhost:8080', path.resolve(__dirname, 'app/app.js') ] }, // 檔案路徑指向(可加快打包過程)。resolve: { alias: { 'react': pathToReact } }, // 生成檔案,是模組構建的終點,包括輸出檔案與輸出路徑。 output: { path: path.resolve(__dirname, 'build'), filename: '[name].js' }, // 這裡配置了處理各模組的 loader ,包括 css 預處理 loader ,es6 編譯 loader,圖片處理 loader。 module: { loaders: [ { test: /\.js$/, loader: 'babel', query: { presets: ['es2015', 'react'] } } ], noParse: [pathToReact] }, // webpack 各外掛物件,在 webpack 的事件流中執行對應的方法。 plugins: [ new webpack.HotModuleReplacementPlugin() ] };
除此之外再大致介紹下 webpack 的一些核心概念:
- loader:能轉換各類資源,並處理成對應模組的載入器。loader 間可以序列使用。
- chunk:code splitting 後的產物,也就是按需載入的分塊,裝載了不同的 module。
對於 module 和 chunk 的關係可以參照 webpack 官方的這張圖:
-
plugin:webpack 的外掛實體,這裡以 UglifyJsPlugin 為例。
function UglifyJsPlugin(options) { this.options = options; } module.exports = UglifyJsPlugin; UglifyJsPlugin.prototype.apply = function(compiler) { compiler.plugin("compilation", function(compilation) { compilation.plugin("build-module", function(module) { }); compilation.plugin("optimize-chunk-assets", function(chunks, callback) { // Uglify 邏輯 }); compilation.plugin("normal-module-loader", function(context) { }); }); };
在 webpack 中你經常可以看到 compilation.plugin(‘xxx’, callback) ,你可以把它當作是一個事件的繫結,這些事件在打包時由 webpack 來觸發。
3. 流程總覽
在具體流程學習前,可以先通過這幅 webpack 整體流程圖 瞭解一下大致流程(建議儲存下來檢視)。
0.3. shell 與 config 解析
每次在命令列輸入 webpack 後,作業系統都會去呼叫 ./node_modules/.bin/webpack
這個 shell 指令碼。這個指令碼會去呼叫 ./node_modules/webpack/bin/webpack.js
並追加輸入的引數,如 -p , -w 。(圖中 webpack.js 是 webpack 的啟動檔案,而 [email protected] 是字尾引數)
在 webpack.js 這個檔案中 webpack 通過 optimist 將使用者配置的 webpack.config.js 和 shell 指令碼傳過來的引數整合成 options 物件傳到了下一個流程的控制物件中。
1. optimist
和 commander 一樣,optimist 實現了 node 命令列的解析,其 API 呼叫非常方便。
var optimist = require("optimist"); optimist .boolean("json").alias("json", "j").describe("json") .boolean("colors").alias("colors", "c").describe("colors") .boolean("watch").alias("watch", "w").describe("watch") ...
獲取到字尾引數後,optimist 分析引數並以鍵值對的形式把引數物件儲存在 optimist.argv 中,來看看 argv 究竟有什麼?
// webpack --hot -w { hot: true, profile: false, watch: true, ... }
2. config 合併與外掛載入
在載入外掛之前,webpack 將 webpack.config.js 中的各個配置項拷貝到 options 物件中,並載入使用者配置在 webpack.config.js 的 plugins 。接著 optimist.argv 會被傳入到 ./node_modules/webpack/bin/convert-argv.js
中,通過判斷 argv 中引數的值決定是否去載入對應外掛。(至於 webpack 外掛執行機制,在之後的執行機制篇會提到)
ifBooleanArg("hot", function() { ensureArray(options, "plugins"); var HotModuleReplacementPlugin = require("../lib/HotModuleReplacementPlugin"); options.plugins.push(new HotModuleReplacementPlugin()); }); ... return options;
options
作為最後返回結果,包含了之後構建階段所需的重要資訊。
{ entry: {},//入口配置 output: {}, //輸出配置 plugins: [], //外掛集合(配置檔案 + shell指令) module: { loaders: [ [Object] ] }, //模組配置 context: //工程路徑 ... }
這和 webpack.config.js 的配置非常相似,只是多了一些經 shell 傳入的外掛物件。外掛物件一初始化完畢, options 也就傳入到了下個流程中。
var webpack = require("../lib/webpack.js"); var compiler = webpack(options);
0.4. 編譯與構建流程
在載入配置檔案和 shell 字尾引數申明的外掛,並傳入構建資訊 options 物件後,開始整個 webpack 打包最漫長的一步。而這個時候,真正的 webpack 物件才剛被初始化,具體的初始化邏輯在 lib/webpack.js
中,如下:
function webpack(options) { var compiler = new Compiler(); ...// 檢查options,若watch欄位為true,則開啟watch執行緒 return compiler; } ...
webpack 的實際入口是 Compiler 中的 run 方法,run 一旦執行後,就開始了編譯和構建流程 ,其中有幾個比較關鍵的 webpack 事件節點。
compile
開始編譯make
從入口點分析模組及其依賴的模組,建立這些模組物件build-module
構建模組after-compile
完成構建seal
封裝構建結果emit
把各個chunk輸出到結果檔案after-emit
完成輸出
1. 核心物件 Compilation
compiler.run 後首先會觸發 compile ,這一步會構建出 Compilation 物件:
這個物件有兩個作用,一是負責組織整個打包過程,包含了每個構建環節及輸出環節所對應的方法,可以從圖中看到比較關鍵的步驟,如 addEntry()
, _addModuleChain()
,buildModule()
, seal()
, createChunkAssets()
(在每一個節點都會觸發 webpack 事件去呼叫各外掛)。二是該物件內部存放著所有 module ,chunk,生成的 asset 以及用來生成最後打包檔案的 template 的資訊。
2. 編譯與構建主流程
在建立 module 之前,Compiler 會觸發 make,並呼叫 Compilation.addEntry
方法,通過 options 物件的 entry 欄位找到我們的入口js檔案。之後,在 addEntry 中呼叫私有方法 _addModuleChain
,這個方法主要做了兩件事情。一是根據模組的型別獲取對應的模組工廠並建立模組,二是構建模組。
而構建模組作為最耗時的一步,又可細化為三步:
-
呼叫各 loader 處理模組之間的依賴
webpack 提供的一個很大的便利就是能將所有資源都整合成模組,不僅僅是 js 檔案。所以需要一些 loader ,比如
url-loader
,jsx-loader
,css-loader
等等來讓我們可以直接在原始檔中引用各類資源。webpack 呼叫doBuild()
,對每一個 require() 用對應的 loader 進行加工,最後生成一個 js module。Compilation.prototype._addModuleChain = function process(context, dependency, onModule, callback) { var start = this.profile && +new Date(); ... // 根據模組的型別獲取對應的模組工廠並建立模組 var moduleFactory = this.dependencyFactories.get(dependency.constructor); ... moduleFactory.create(context, dependency, function(err, module) { var result = this.addModule(module); ... this.buildModule(module, function(err) { ... // 構建模組,新增依賴模組 }.bind(this)); }.bind(this)); };
-
呼叫 acorn 解析經 loader 處理後的原始檔生成抽象語法樹 AST
Parser.prototype.parse = function parse(source, initialState) { var ast; if (!ast) { // acorn以es6的語法進行解析 ast = acorn.parse(source, { ranges: true, locations: true, ecmaVersion: 6, sourceType: "module" }); } ... };
-
遍歷 AST,構建該模組所依賴的模組
對於當前模組,或許存在著多個依賴模組。當前模組會開闢一個依賴模組的陣列,在遍歷 AST 時,將 require() 中的模組通過
addDependency()
新增到陣列中。當前模組構建完成後,webpack 呼叫processModuleDependencies
開始遞迴處理依賴的 module,接著就會重複之前的構建步驟。Compilation.prototype.addModuleDependencies = function(module, dependencies, bail, cacheGroup, recursive, callback) { // 根據依賴陣列(dependencies)建立依賴模組物件 var factories = []; for (var i = 0; i < dependencies.length; i++) { var factory = _this.dependencyFactories.get(dependencies[i][0].constructor); factories[i] = [factory, dependencies[i]]; } ... // 與當前模組構建步驟相同 }
3. 構建細節
module 是 webpack 構建的核心實體,也是所有 module 的 父類,它有幾種不同子類:NormalModule
, MultiModule
, ContextModule
, DelegatedModule
等。但這些核心實體都是在構建中都會去呼叫對應方法,也就是 build()
。來看看其中具體做了什麼:
// 初始化module資訊,如context,id,chunks,dependencies等。 NormalModule.prototype.build = function build(options, compilation, resolver, fs, callback) { this.buildTimestamp = new Date().getTime(); // 構建計時 this.built = true; return this.doBuild(options, compilation, resolver, fs, function(err) { // 指定模組引用,不經acorn解析 if (options.module && options.module.noParse) { if (Array.isArray(options.module.noParse)) { if (options.module.noParse.some(function(regExp) { return typeof regExp === "string" ? this.request.indexOf(regExp) === 0 : regExp.test(this.request); }, this)) { return callback(); } } else if (typeof options.module.noParse === "string" ? this.request.indexOf(options.module.noParse) === 0 : options.module.noParse.test(this.request)) { return callback(); } } // 由acorn解析生成ast try { this.parser.parse(this._source.source(), { current: this, module: this, compilation: compilation, options: options }); } catch (e) { var source = this._source.source(); this._source = null; return callback(new ModuleParseError(this, source, e)); } return callback(); }.bind(this)); };
對於每一個 module ,它都會有這樣一個構建方法。當然,它還包括了從構建到輸出的一系列的有關 module 生命週期的函式,我們通過 module 父類類圖其子類類圖(這裡以 NormalModule 為例)來觀察其真實形態:
可以看到無論是構建流程,處理依賴流程,包括後面的封裝流程都是與 module 密切相關的。
0.5. 打包輸出
在所有模組及其依賴模組 build 完成後,webpack 會監聽 seal
事件呼叫各外掛對構建後的結果進行封裝,要逐次對每個 module 和 chunk 進行整理,生成編譯後的原始碼,合併,拆分,生成 hash 。 同時這是我們在開發時進行程式碼優化和功能新增的關鍵環節。
Compilation.prototype.seal = function seal(callback) { this.applyPlugins("seal"); // 觸發外掛的seal事件 this.preparedChunks.sort(function(a, b) { if (a.name < b.name) { return -1; } if (a.name > b.name) { return 1; } return 0; }); this.preparedChunks.forEach(function(preparedChunk) { var module = preparedChunk.module; var chunk = this.addChunk(preparedChunk.name, module); chunk.initial = chunk.entry = true; // 整理每個Module和chunk,每個chunk對應一個輸出檔案。 chunk.addModule(module); module.addChunk(chunk); }, this); this.applyPluginsAsync("optimize-tree", this.chunks, this.modules, function(err) { if (err) { return callback(err); } ... // 觸發外掛的事件 this.createChunkAssets(); // 生成最終assets ... // 觸發外掛的事件 }.bind(this)); };
1. 生成最終 assets
在封裝過程中,webpack 會呼叫 Compilation 中的 createChunkAssets
方法進行打包後代碼的生成。 createChunkAssets 流程如下:
-
不同的 Template
從上圖可以看出通過判斷是入口 js 還是需要非同步載入的 js 來選擇不同的模板物件進行封裝,入口 js 會採用 webpack 事件流的 render 事件來觸發
Template類
中的renderChunkModules()
(非同步載入的 js 會呼叫 chunkTemplate 中的 render 方法)。if(chunk.entry) { source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates); } else { source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates); }
在 webpack 中有四個 Template 的子類,分別是
MainTemplate.js
,ChunkTemplate.js
,ModuleTemplate.js
,HotUpdateChunkTemplate.js
,前兩者先前已大致有介紹,而 ModuleTemplate 是對所有模組進行一個程式碼生成,HotUpdateChunkTemplate 是對熱替換模組的一個處理。 -
模組封裝
模組在封裝的時候和它在構建時一樣,都是呼叫各模組類中的方法。封裝通過呼叫
module.source()
來進行各操作,比如說 require() 的替換。MainTemplate.prototype.requireFn = "__webpack_require__"; MainTemplate.prototype.render = function(hash, chunk, moduleTemplate, dependencyTemplates) { var buf = []; // 每一個module都有一個moduleId,在最後會替換。 buf.push("function " + this.requireFn + "(moduleId) {"); buf.push(this.indent(this.applyPluginsWaterfall("require", "", chunk, hash))); buf.push("}"); buf.push(""); ... // 其餘封裝操作 };
-
生成 assets
各模組進行 doBlock 後,把 module 的最終程式碼迴圈新增到 source 中。一個 source 對應著一個 asset 物件,該物件儲存了單個檔案的檔名( name )和最終程式碼( value )。
2. 輸出
最後一步,webpack 呼叫 Compiler 中的 emitAssets()
,按照 output 中的配置項將檔案輸出到了對應的 path 中,從而 webpack 整個打包過程結束。要注意的是,若想對結果進行處理,則需要在 emit
觸發後對自定義外掛進行擴充套件。
0.6. 總結
webpack 的整體流程主要還是依賴於 compilation
和 module
這兩個物件,但其思想遠不止這麼簡單。最開始也說過,webpack 本質是個外掛集合,並且由 tapable
控制各外掛在 webpack 事件流上執行,至於具體的思想和細節,將會在後一篇文章中提到。同時,在業務開發中,無論是為了提升構建效率,或是減小打包檔案大小,我們都可以通過編寫 webpack 外掛來進行流程上的控制,這個也會在之後提到。