#科學燃計劃#
隨著多終端裝置的迅速普及,Web前端開發的複雜性和應用場景日益擴大,Webpack在前端構建演變的工程化浪潮中擔當起了針對不同應用場景打包的大任。 如今,Webpack可謂是JavaScript社群最偉大的專案之一。
本文力爭從原始碼層面窺探Webpack的實現原理。文中出示了核心的程式碼塊並註釋了相應的path,如果你也想揭開Webpack神秘的面紗,那就開啟一份原始碼跟隨本文一起享受一次禿頭的快樂。
Webpack本質Webpack的本質是什麼呢?可能有的同學已經知道了,
Webpack本質上一種基於事件流的程式設計範例,其實就是一系列的外掛執行。
Webpack主要使用Compiler和Compilation兩個類來控制Webpack的整個生命週期。他們都繼承了Tapabel並且透過Tapabel來註冊了生命週期中的每一個流程需要觸發的事件。
TapabelTapabel是一個類似於 Node.js 的 EventEmitter 的庫,主要是控制鉤子函式的釋出與訂閱,是Webpack外掛系統的大管家。
Tapabel提供的鉤子及示例Tapable庫為外掛提供了很多 Hook以便掛載。
const { SyncHook, // 同步鉤子 SyncBailHook, // 同步熔斷鉤子 SyncWaterfallHook, // 同步流水鉤子 SyncLoopHook, // 同步迴圈鉤子 AsyncParalleHook, // 非同步併發鉤子 AsyncParallelBailHook, // 非同步併發熔斷鉤子 AsyncSeriesHook, // 非同步序列鉤子 AsyncSeriesBailHook, // 非同步序列熔斷鉤子 AsyncSeriesWaterfallHook // 非同步序列流水鉤子} = require("tapable");
Tabpack 提供了同步&非同步繫結鉤子的方法,方法如下所示:
AsyncSync繫結:tapAsync/tapPromise/tap繫結:tap執行:callAsync/promise執行:call
Tabpack簡單示例const demohook = new SyncHook(["arg1", "arg2", "arg3"]);// 繫結事件到webpack事件流demohook.tap("hook1",(arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) // 1 2 3// 執行繫結的事件demohook.call(1,2,3)
原始碼解讀初始化啟動之Webpack的入口檔案
追本溯源,第一步我們要找到Webpack的入口檔案。
當透過命令列啟動Webpack後,npm會讓命令列工具進入node_modules.bin 目錄。
然後查詢是否存在 webpack.sh 或者 webpack.cmd 檔案,如果存在,就執行它們,不存在就會丟擲錯誤。
實際的入口檔案是:node_modules/webpack/bin/webpack.js,讓我們來看一下里面的核心函式。
// node_modules/webpack/bin/webpack.js// 正常執行返回process.exitCode = 0; // 執行某個命令 const runCommand = (command, args) => {...}// 判斷某個包是否安裝const isInstalled = packageName => {...}// webpack可用的CLI:webpacl-cli和webpack-commandconst CLIs = {...}// 判斷是否兩個CLI是否安裝了const installedClis = CLIs.filter(cli=>cli.installed);// 根據安裝數量進行處理if (installedClis.length === 0) {...} else if (installedClis.length === 1) {...} else {...}
啟動後,Webpack最終會找到 webpack-cli /webpack-command的 npm 包,並且 執行 CLI。
webpack-cli搞清楚了Webpack啟動的入口檔案後,接下來讓我們把目光轉移到webpack-cli,看看它做了哪些事兒。
引入 yargs,對命令列進行定製分析命令列引數,對各個引數進行轉換,組成編譯配置項引用webpack,根據配置項進行編譯和構建webpack-cli 會處理不需要經過編譯的命令。
// node_modules/webpack-cli/bin/cli.jsconst {NON_COMPILATION_ARGS} = require("./utils/constants");const NON_COMPILATION_CMD = process.argv.find(arg => { if (arg === "serve") { global.process.argv = global.process.argv.filter(a => a !== "serve"); process.argv = global.process.argv; } return NON_COMPILATION_ARGS.find(a => a === arg);});if (NON_COMPILATION_CMD) { return require("./utils/prompt-command")(NON_COMPILATION_CMD,...process.argv);}
webpack-cli提供的不需要編譯的命令如下。
// node_modules/webpack-cli/bin/untils/constants.jsconst NON_COMPILATION_ARGS = [ "init", // 建立一份webpack配置檔案 "migrate", // 進行webpack版本遷移 "add", // 往webpack配置檔案中增加屬性 "remove", // 往webpack配置檔案中刪除屬性 "serve", // 執行webpack-serve "generate-loader", // 生成webpack loader程式碼 "generate-plugin", // 生成webpack plugin程式碼 "info" // 返回與本地環境相關的一些資訊];
webpack-cli 使用命令列工具包yargs。
// node_modules/webpack-cli/bin/config/config-yargs.jsconst { CONFIG_GROUP, BASIC_GROUP, MODULE_GROUP, OUTPUT_GROUP, ADVANCED_GROUP, RESOLVE_GROUP, OPTIMIZE_GROUP, DISPLAY_GROUP} = GROUPS;
webpack-cli對配置檔案和命令列引數進行轉換最終生成配置選項引數 options,最終會根據配置引數例項化webpack物件,然後執行構建流程。
除此之外,讓我們回到node_modules/webpack/lib/webpack.js裡來看一下Webpack還做了哪些準備工作。
// node_modules/webpack/lib/webpack.jsconst webpack = (options, callback) => { ... options = new WebpackOptionsDefaulter().process(options); compiler = new Compiler(options.context); new NodeEnvironmentPlugin().apply(compiler); ... compiler.options = new WebpackOptionsApply().process(options, compiler); ... webpack.WebpackOptionsDefaulter = WebpackOptionsDefaulter; webpack.WebpackOptionsApply = WebpackOptionsApply; ... webpack.NodeEnvironmentPlugin = NodeEnvironmentPlugin;}
WebpackOptionsDefaulter的功能是設定一些預設的Options(程式碼比較多不貼了,大家自行檢視node_modules/webpack/lib/WebpackOptionsDefaulter.js)。
// node_modules/webpack/lib/node/NodeEnvironmentPlugin.jsclass NodeEnvironmentPlugin { apply(compiler) { ... compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => { if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge(); }); }}
從上面的程式碼我們可以知道,NodeEnvironmentPlugin外掛監聽了beforeRun鉤子,它的作用是清除快取。
WebpackOptionsApplyWebpackOptionsApply會將所有的配置options引數轉換成webpack內部外掛。
使用預設外掛列表
output.library -> LibraryTemplatePluginexternals -> ExternalsPlugindevtool -> EvalDevtoolModulePlugin, SourceMapDevToolPluginAMDPlugin, CommonJsPluginRemoveEmptyChunksPlugin// node_modules/webpack/lib/WebpackOptionsApply.jsnew EntryOptionPlugin().apply(compiler);compiler.hooks.entryOption.call(options.context, options.entry);
實際上,外掛最後都會變成compiler物件上的例項。
EntryOptionPlugin接下來讓我們進入EntryOptionPlugin外掛,看看它做了哪些事兒。
// node_modules/webpack/lib/EntryOptionPlugin.jsmodule.exports = class EntryOptionPlugin { apply(compiler) { compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => { if (typeof entry === "string" || Array.isArray(entry)) { itemToPlugin(context, entry, "main").apply(compiler); } else if (typeof entry === "object") { for (const name of Object.keys(entry)) { itemToPlugin(context, entry[name], name).apply(compiler); } } else if (typeof entry === "function") { new DynamicEntryPlugin(context, entry).apply(compiler); } return true; }); }};
如果是陣列,則轉換成多個entry來處理,如果是物件則轉換成一個個entry來處理。
如上述程式碼所示。
compiler例項化是在node_modules/webpack/lib/webpack.js裡完成的。透過EntryOptionPlugin外掛進行引數校驗。透過WebpackOptionsDefaulter將傳入的引數和預設引數進行合併成為新的options,建立compiler,以及相關plugin,最後透過 WebpackOptionsApply將所有的配置options引數轉換成Webpack內部外掛。
不要急,還沒完事。
再次來到我們的node_modules/webpack/lib/webpack.js中
if (options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) { const watchOptions = Array.isArray(options) ? options.map(o => o.watchOptions || {}) : options.watchOptions || {}; return compiler.watch(watchOptions, callback);}compiler.run(callback);
例項compiler後會根據options的watch判斷是否啟動了watch,如果啟動watch了就呼叫compiler.watch來監控構建檔案,否則啟動compiler.run來構建檔案。
編譯構建compile首先會例項化NormalModuleFactory和ContextModuleFactory。然後進入到run方法。
// node_modules/webpack/lib/Compiler.jsrun(callback) { ... // beforeRun 如上文NodeEnvironmentPlugin外掛清除快取 this.hooks.beforeRun.callAsync(this, err => { if (err) return finalCallback(err); // 執行run Hook開始編譯 this.hooks.run.callAsync(this, err => { if (err) return finalCallback(err); this.readRecords(err => { if (err) return finalCallback(err); // 執行compile this.compile(onCompiled); }); }); });}
在執行this.hooks.compile之前會執行this.hooks.beforeCompile,來對編譯之前需要處理的外掛進行執行。緊接著this.hooks.compile執行後會例項化Compilation物件。
// node_modules/webpack/lib/compiler.jscompile(callback) { const params = this.newCompilationParams(); this.hooks.beforeCompile.callAsync(params, err => { if (err) return callback(err); // 進入compile階段 this.hooks.compile.call(params); const compilation = this.newCompilation(params); // 進入make階段 this.hooks.make.callAsync(compilation, err => { if (err) return callback(err); compilation.finish(err => { if (err) return callback(err); // 進入seal階段 compilation.seal(err => { if (err) return callback(err); this.hooks.afterCompile.callAsync(compilation, err => { if (err) return callback(err); return callback(null, compilation); }) }) }) }) })}
make一個新的Compilation建立完畢,將從Entry開始讀取檔案,根據檔案型別和配置的Loader對檔案進行編譯,編譯完成後再找出該檔案依賴的檔案,遞迴的編譯和解析。
我們來看一下make鉤子被監聽的地方。
如程式碼中註釋所示,addEntry是make構建階段真正開始的標誌。
// node_modules/webpack/lib/SingleEntryPlugin.jscompiler.hooks.make.tapAsync( "SingleEntryPlugin", (compilation, callback) => { const { entry, name, context } = this; cosnt dep = SingleEntryPlugin.createDependency(entry, name); // make構建階段開始標誌 compilation.addEntry(context, dep, name, callback); })
addEntry實際上呼叫了_addModuleChain方法,_addModuleChain方法將模組新增到依賴列表中去,同時進行模組構建。構建時會執行如下函式。
// node_modules/webpack/lib/Compilation.js// addEntry -> addModuleChain_addModuleChain(context, dependency, onModule, callback) {...this.buildModule(module, false, null, null, err => { ...})...}
如果模組構建完成,會觸發finishModules。
// node_modules/webpack/lib/Compilation.jsfinish(callback) { const modules = this.modules; this.hooks.finishModules.callAsync(modules, err => { if (err) return callback(err); for (let index = 0; index < modules.length; index++) { const module = modules[index]; this.reportDependencyErrorsAndWarnings(module, [module]); } callback(); })}
ModuleModule包括NormalModule(普通模組)、ContextModule(./src/a ./src/b)、ExternalModule(module.exports=jQuery)、DelegatedModule(manifest)以及MultiModule(entry:['a', 'b'])。
本文以NormalModule(普通模組)為例子,看一下構建(Compilation)的過程。
使用 loader-runner 執行 loadersLoader轉換完後,使用 acorn 解析生成AST使用 ParserPlugins 新增依賴loader-runner// node_modules/webpack/lib/NormalModule.jsconst { getContext, runLoaders } = require("loader-runner");doBuild(){ ... runLoaders( ... ) ...}...try { const result = this.parser.parse()}
doBuild會去載入資源,doBuild中會傳入資源路徑和外掛資源去呼叫loader-runner外掛的runLoaders方法去載入和執行loader。
acorn// node_modules/webpack/lib/Parser.jsconst acorn = require("acorn");
使用acorn解析轉換後的內容,輸出對應的抽象語法樹(AST)。
// node_modules/webpack/lib/Compilation.jsthis.hooks.buildModule.call(module);...if (error) { this.hooks.failedModule.call(module, error); return callback(error);}this.hooks.succeedModule.call(module);return callback();
成功就觸發succeedModule,失敗就觸發failedModule。
最終將上述階段生成的產物存放到Compilation.js的this.modules = [];上。
完成後就到了seal階段。
這裡補充介紹一下Chunk生成的演算法。
Chunk生成演算法1.webpack首先會將entry中對應的module都生成一個新的chunk。
2.遍歷module的依賴列表,將依賴的module也加入到chunk中。
3.如果一個依賴module是動態引入的模組,會根據這個module建立一個新的chunk,繼續遍歷依賴。
4.重複上面的過程,直至得到所有的chunk。
seal所有模組及其依賴的模組都透過Loader轉換完成,根據依賴關係開始生成Chunk。
seal階段也做了大量的的最佳化工作,進行了hash的建立以及對內容進行生成(createModuleAssets)。
// node_modules/webpack/lib/Compilation.jsthis.createHash();this.modifyHash();this.createModuleAssets();
// node_modules/webpack/lib/Compilation.jscreateModuleAssets(){ for (let i = 0; i < this.modules.length; i++) { const module = this.modules[i]; if (module.buildInfo.assets) { for (const assetName of Object.keys(module.buildInfo.assets)) { const fileName = this.getPath(assetName); this.assets[fileName] = module.buildInfo.assets[assetName]; this.hooks.moduleAsset.call(module, fileName); } } }}
seal階段經歷了很多的最佳化,比如tree shaking就是在這個階段執行。最終生成的程式碼會存放在Compilation的assets屬性上。
emit將輸出的內容輸出到磁碟,建立目錄生成檔案,檔案生成階段結束。
// node_modules/webpack/lib/compiler.jsthis.hooks.emit.callAsync(compilation, err => { if (err) return callback(err); outputPath = compilation.getPath(this.outputPath); this.outputFileSystem.mkdirp(outputPath, emitFiles);})
實現一個簡易的Webpack
為了能夠更深入的理解Webpack的整體流程,我們可以動手來實現一個簡易的Webpack。
總結Webpack在啟動階段對配置引數和命令列引數以及預設引數進行了合併,並進行了外掛的初始化工作。完成初始化的工作後呼叫Compiler的run開啟Webpack編譯構建過程,構建主要流程包括compile、make、build、seal、emit等階段。
當然,Webpack原始碼還包括很多具體的實現細節,透過一篇文章是總結不完的,大家感興趣的可以進一步學習。
參考深入淺出Webpack玩轉Webpackwebpack4原始碼分析