「搞點硬貨」從源碼窺探Webpack4.x原理

想象一下,爲了擁有美好的生活,你必須穿越一片危險的叢林。你能夠安全地留在原地,過着普通的生活;你也能夠冒險穿越叢林,過着絕妙的生活。你將如何對待這一選擇?

                                                                                           《原則》瑞·達利歐前端

觀感度:🌟🌟🌟🌟🌟node

口味:部隊火鍋webpack

烹飪時間:15mingit



隨着多終端設備的迅速普及,Web前端開發的複雜性和應用場景日益擴大,Webpack在前端構建演變的工程化浪潮中擔當起了針對不一樣應用場景打包的大任。 現在,Webpack可謂是JavaScript社區最偉大的項目之一。github

本文力爭從源碼層面窺探Webpack的實現原理。文中出示了核心的代碼塊並註釋了相應的path,若是你也想揭開Webpack神祕的面紗,那就打開一份源碼跟隨本文一塊兒享受一次禿頭的快樂。web

文章過長,記得點贊收藏~ 單押X2算法

Webpack本質

Webpack的本質是什麼呢?可能有的同窗已經知道了,npm

Webpack本質上一種基於事件流的編程範例,其實就是一系列的插件運行。編程

Webpack主要使用CompilerCompilation兩個類來控制Webpack的整個生命週期。他們都繼承了Tapabel而且經過Tapabel來註冊了生命週期中的每個流程須要觸發的事件。數組

Tapabel

Tapabel是一個相似於 Node.js 的 EventEmitter 的庫,主要是控制鉤子函數的發佈與訂閱,是Webpack插件系統的大管家。

Tapabel提供的鉤子及示例

Tapable庫爲插件提供了不少 Hook以便掛載。

const {
    SyncHook,                   // 同步鉤子
    SyncBailHook,               // 同步熔斷鉤子
    SyncWaterfallHook,          // 同步流水鉤子
    SyncLoopHook,               // 同步循環鉤子
    AsyncParalleHook,           // 異步併發鉤子
    AsyncParallelBailHook,      // 異步併發熔斷鉤子
    AsyncSeriesHook,            // 異步串行鉤子
    AsyncSeriesBailHook,        // 異步串行熔斷鉤子
    AsyncSeriesWaterfallHook     // 異步串行流水鉤子
} = require("tapable");
複製代碼

Tabpack 提供了同步&異步綁定鉤子的方法,方法以下所示:

Async Sync
綁定: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-command
const 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.js
const {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.js
const 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.js
const {
    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.js
const 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.js
class NodeEnvironmentPlugin {
  apply(compiler) {
      ...		
      compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
	  if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
      });
  }
}
複製代碼

從上面的代碼咱們能夠知道,NodeEnvironmentPlugin插件監聽了beforeRun鉤子,它的做用是清除緩存。

WebpackOptionsApply

WebpackOptionsApply會將全部的配置options參數轉換成webpack內部插件。

使用默認插件列表

  • output.library -> LibraryTemplatePlugin
  • externals -> ExternalsPlugin
  • devtool -> EvalDevtoolModulePlugin, SourceMapDevToolPlugin
  • AMDPlugin, CommonJsPlugin
  • RemoveEmptyChunksPlugin
// node_modules/webpack/lib/WebpackOptionsApply.js
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);
複製代碼

實際上,插件最後都會變成compiler對象上的實例。

EntryOptionPlugin

接下來讓咱們進入EntryOptionPlugin插件,看看它作了哪些事兒。

// node_modules/webpack/lib/EntryOptionPlugin.js
module.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

首先會實例化NormalModuleFactoryContextModuleFactory。而後進入到run方法。

// node_modules/webpack/lib/Compiler.js
run(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.js
compile(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.js
compiler.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.js
finish(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();
    })
}
複製代碼

Module

Module包括NormalModule(普通模塊)ContextModule(./src/a ./src/b)ExternalModule(module.exports=jQuery)DelegatedModule(manifest)以及MultiModule(entry:['a', 'b'])

本文以NormalModule(普通模塊)爲例子,看一下構建(Compilation)的過程。

  • 使用 loader-runner 運行 loaders
  • Loader轉換完後,使用 acorn 解析生成AST
  • 使用 ParserPlugins 添加依賴

loader-runner

// node_modules/webpack/lib/NormalModule.js
const { 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.js
const acorn = require("acorn");
複製代碼

使用acorn解析轉換後的內容,輸出對應的抽象語法樹(AST)。

// node_modules/webpack/lib/Compilation.js
this.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.js
this.createHash();
this.modifyHash();
this.createModuleAssets();
複製代碼
// node_modules/webpack/lib/Compilation.js
createModuleAssets(){
    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就是在這個階段執行。最終生成的代碼會存放在Compilationassets屬性上。

emit

將輸出的內容輸出到磁盤,建立目錄生成文件,文件生成階段結束。

// node_modules/webpack/lib/compiler.js
this.hooks.emit.callAsync(compilation, err => {
    if (err) return callback(err);
    outputPath = compilation.getPath(this.outputPath);
    this.outputFileSystem.mkdirp(outputPath, emitFiles);
})
複製代碼

實現一個簡易的Webpack

爲了可以更深刻的理解Webpack的總體流程,咱們能夠動手來實現一個簡易的Webpack。

簡易的Webpack源碼地址 倉庫地址,歡迎Star~

總結

Webpack在啓動階段對配置參數和命令行參數以及默認參數進行了合併,並進行了插件的初始化工做。完成初始化的工做後調用Compiler的run開啓Webpack編譯構建過程,構建主要流程包括compilemakebuildsealemit等階段。

固然,Webpack源碼還包括不少具體的實現細節,經過一篇文章是總結不完的,你們感興趣的能夠進一步學習。

參考

❤️愛心三連擊

1.看到這裏了就點個贊支持下吧,你的贊是我創做的動力。

2.關注公衆號前端食堂你的前端食堂,記得按時吃飯

3.後臺回覆福利,便可得到海量學習資料。

相關文章
相關標籤/搜索