webpack的工作流程(附带部分源码分析)

发布时间 2023-05-28 23:40:40作者: CD、小月

@

webpack的工作流程

说明工作流程之前,先抛出两个结论:

webpack 的核心功能,是抽离成很多个内部插件来实现的。
webpack插件通过监听对象对应的钩子而实现特定功能。

按照核心流程分为三个阶段:

  1. webpack的准备阶段
  2. modules和chunks的生成阶段
  3. 文件生成阶段

P.S. 以下的分析都基于 webpack 4.42.1

核心流程图如下:

说明:
图中每一列顶部名称表示该列中任务点所属的对象
图中每一行表示一个阶段
图中每个节点表示任务点名称
图中每个节点括号表示任务点的参数,参数带有callback是异步任务点
图中的箭头表示任务点的执行顺序
图中虚线表示存在循环流程

在这里插入图片描述

webpack的准备阶段

这个阶段的主要工作,是创建 CompilerCompilation 实例。

Compilation:这个对象是后续构建流程中最核心最重要的对象,它包含了一次构建过程中所有的数据。也就是说一次构建过程对应一个Compilation 实例。

本阶段流程和钩子

Compiler初始化,加载内部插件,该阶段在Compiler*对象*

本阶段流程和主要钩子 run -> compile->make

run:
任务点 run 只有在 webpack 以正常模式运行的情况下会触发,如果我们以监听(watch)的模式运行 webpack,那么任务点 run 是不会触发的,但是会触发任务点 watch-run。

// ../node_modules/webpack/lib/webpack.js
const webpack = (options, callback) => {
	...
	let compiler;
	if (callback) {
		...
		// compiler run
		compiler.run(callback);
	}
	return compiler;
};

compile
complie执行时,会生成createNormalModuleFactorycreateContextModuleFactory,并传入compilation,后会面modules和chunks的生成阶段做准备
(工厂对象顾名思义就是用来创建实例的,它们后续用来创建 NormalModule 以及 ContextModule 实例)。

// ../node_modules/webpack/lib/Compiler.js
run(callback) {
 		... 
        this.hooks.beforeRun.callAsync(this, err => {
            if (err) return finalCallback(err);
            this.hooks.run.callAsync(this, err => {
                if (err) return finalCallback(err);
                this.readRecords(err => {
                    if (err) return finalCallback(err);
                    // compiler compile
                    this.compile(onCompiled);
                });
            });
        });
    }

make
make钩子触发,相应插件运行,开始进入modules和chunks的生成阶段

// ../node_modules/webpack/lib/Compiler.js
	newCompilationParams() {
		const params = {
			normalModuleFactory: this.createNormalModuleFactory(),
			contextModuleFactory: this.createContextModuleFactory(),
			compilationDependencies: new Set()
		};
		return params;
	}
	compile(callback) {
		// createNormalModuleFactory和createContextModuleFactory 对象
		const params = this.newCompilationParams();
		this.hooks.beforeCompile.callAsync(params, err => {
			if (err) return callback(err);
			this.hooks.compile.call(params);
			// 生成compilation 
			// 传入createNormalModuleFactory和createContextModuleFactory 对象
			const compilation = this.newCompilation(params);
			// make钩子触发 向其他插件传入compilation对象
			// 开始进入modules和chunks的生成阶段
			this.hooks.make.callAsync(compilation, err => {
				if (err) return callback(err);
				// compilation对象 finishModules钩子触发 所有modules解析构建完成
				compilation.finish(err => {
					if (err) return callback(err);
					// compilation对象 seal钩子触发 
					compilation.seal(err => {
						if (err) return callback(err);
						// compiler对象 afterCompile钩子触发
						this.hooks.afterCompile.callAsync(compilation, err => {
							if (err) return callback(err);
							return callback(null, compilation);
						});
					});
				});
			});
		});
	}

modules和chunks的生成阶段

这个阶段的主要内容,是先解析项目依赖的所有 modules,再根据 modules 生成 chunks。
module 解析,包含了三个主要步骤:创建实例、loaders应用以及依赖收集。
chunks 生成, 主要步骤是找到 chunk 所需要包含的 modules。

module 解析

当上一个阶段make钩子被触发,此时内部入口插件(SingleEntryPlugin, MultiEntryPlugin, DynamicEntryPlugin)监听器会开始执行。监听器都会调用 Compilation 实例的 addEntry 方法,该方法将会触发第一批 module 的解析,这些 module 就是 entry 中配置的入口模块。

内部入口插件包括:SingleEntryPlugin, MultiEntryPlugin, DynamicEntryPlugin
根据不同entry配置调用不同入口插件

内部入口插件注册大概流程:

// ../node_modules/webpack/lib/webpack.js
// 通过WebpackOptionsApply 注册内部插件
compiler.options = new WebpackOptionsApply().process(options, compiler);
// ../node_modules/webpack/lib/WebpackOptionsApply .js
class WebpackOptionsApply extends OptionsApply {
    ...
    process(options, compiler) {
    	// 注册entryOption钩子监听器
        new EntryOptionPlugin().apply(compiler);
        // entryOption钩子
        compiler.hooks.entryOption.call(options.context, options.entry);
    }
}
// ../node_modules/webpack/lib/EntryOptionPlugin .js
const itemToPlugin = (context, item, name) => {
	if (Array.isArray(item)) {
		return new MultiEntryPlugin(context, item, name);
	}
	return new SingleEntryPlugin(context, item, name);
};
module.exports = class EntryOptionPlugin {
	apply(compiler) {
		// 监听entryOption钩子
		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;
		});
	}
};

不管哪个插件,内部都会监听 Compiler 实例对象的 make 钩子; 然后compilation.addEntry运行
以 SingleEntryPlugin 为例:


class SingleEntryPlugin {
	...
	apply(compiler) {
		...
		// 监听make钩子函数 compilation.addEntry运行
		compiler.hooks.make.tapAsync(
			"SingleEntryPlugin",
			(compilation, callback) => {
				const { entry, name, context } = this;
				const dep = SingleEntryPlugin.createDependency(entry, name);
				compilation.addEntry(context, dep, name, callback);
			}
		);
	}

解析流程

流程图:
在这里插入图片描述

1. 创建 NormalModule 实例
这里需要用到上一个阶段讲到的 NormalModuleFactory 实例, NormalModuleFactory 的 create 方法是创建 NormalModule 实例的入口,内部的主要过程是解析 module 需要用到的一些属性,比如需要用到的 loaders, 资源路径 resource 等等,最终将解析完毕的参数传给 NormalModule 构建函数直接实例化。

这里在解析参数的过程中,有两个比较实用的任务点 before-resolveafter-resolve,分别对应了解析参数前和解析参数后的时间点。举个例子,在任务点 before-resolve 可以做到忽略某个 module 的解析,webpack 内部插件 IgnorePlugin 就是这么做的:

// https://github.com/webpack/webpack/blob/master/lib/IgnorePlugin.js
class IgnorePlugin {
   checkIgnore(result, callback) {
      // check if result is ignored
      if(this.checkResult(result)) {
          return callback(); // callback第二个参数为 undefined 时会终止module解析
      }
      return callback(null, result);
  }
  apply(compiler) {
      compiler.plugin("normal-module-factory", (nmf) => {
         nmf.plugin("before-resolve", this.checkIgnore);
       });
       compiler.plugin("context-module-factory", (cmf) => {
           cmf.plugin("before-resolve", this.checkIgnore);
       });
   }
}

2. NormalModule实例调用 build()继续进行模块的构建
我们熟悉的 loaders 将会在这里开始应用,NormalModule 实例中的 loaders 属性已经记录了该模块需要应用的 loaders。应用 loaders 的过程相对简单,直接调用loader-runner 这个模块即可

webpack 中要求 NormalModule 最终都是 js 模块,所以 loader 的作用之一是将不同的资源文件转化成 js 模块。比如 html-loader 是将 html 转化成一个 js 模块。在应用完 loaders 之后,NormalModule 实例的源码必然就是 js 代码,这对下一个步骤很重要

3. 需要得到这个 module 所依赖的其他模块,所以就有一个依赖收集的过程。

webpack 的依赖收集过程是将 js 源码传给 js parser(webpack 使用的 parseracorn
parserjs 源码解析后得到对应的AST(抽象语法树, Abstract Syntax Tree)。然后 webpack 会遍历 AST,按照一定规则触发任务点。
有了AST对应的任务点,依赖收集就相对简单了,比如遇到任务点 call require,说明在代码中是有调用了require函数,那么就应该给 module 添加新的依赖。webpack 关于这部分的处理是比较复杂的,因为 webpack 要兼容多种不同的依赖方式,比如 AMD 规范、CommonJS规范,然后还要区分动态引用的情况,比如使用了 require.ensure, require.context。但这些细节对于我们讨论构建流程并不是必须的,因为不展开细节讨论。

parser 解析完成之后,module 的解析过程就完成了。每个 module 解析完成之后,都会触发 Compilation 实例对象的任务点 succeed-module,我们可以在这个任务点获取到刚解析完的 module 对象。正如前面所说,module 接下来还要继续递归解析它的依赖模块,最终我们会得到项目所依赖的所有 modules。此时任务点 make 结束。进入下个任务点 seal**

module 解析完成之后的操作,它会递归调用它所依赖的 modules 进行解析,所以当解析停止时,我们就能够得到项目中所有依赖的 modules,它们将存储在 Compilation 实例的 modules 属性中,并触发任务点 finish-modules

chunks 生成

Compialtion 实例的 seal 方法会被调用并马上触发任务点 seal。在这个任务点,我们可以拿到所有解析完成的 module,有了所有的 modules 之后,webpack 会开始生成 chunkswebpack 中的 chunk 概念,要不就是配置在 entry 中的模块,要不就是动态引入(比如 require.ensure)的模块。这些 chunk 对象是 webpack 生成最终文件的一个重要依据。

每个 chunk 的生成就是找到需要包含的 modules。这里大致描述一下 chunk 的生成算法:

  1. webpack 先将 entry 中对应的 module 都生成一个新的 chunk
  2. 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中
  3. 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖
  4. 重复上面的过程,直至得到所有的 chunks

在所有 chunks 生成之后,webpack 会对 chunks 和 modules 进行一些优化相关的操作,比如分配id、排序等,并且触发一系列相关的任务点,这些任务点一般是 webpack.optimize 属性下的插件会使用到,比如 CommonsChunkPlugin 会使用到任务点 optimize-chunks,但这里我们不深入讨论。

至此,modules 和 chunks 的生成阶段结束。接下来是文件生成阶段。

文件生成阶段

这个阶段的主要内容,是根据 chunks 生成最终文件。主要有三个步骤:

  1. 模板 hash 更新
  2. 模板渲染 chunk
  3. 生成文件

Compilation 在实例化的时候,就会同时实例化三个对象:MainTemplate, ChunkTemplateModuleTemplate。这三个对象是用来渲染 chunk 对象,得到最终代码的模板。第一个对应了在 entry 配置的入口 chunk 的渲染模板,第二个是动态引入的非入口 chunk 的渲染模板,最后是 chunk 中的 module 的渲染模板

模板 hash 更新

在开始渲染之前,Compilation 实例会调用 createHash 方法来生成这次构建的 hash。在 webpack
的配置中,我们可以在 output.filename 中配置 [hash] 占位符,最终就会替换成这个 hash。同样,createHash 也会为每一个 chunk 也创建一个 hash,对应 output.filename 的 [chunkhash] 占位符。

每个 hash 的影响因素比较多,首先三个模板对象会调用 updateHash 方法来更新 hash,在内部还会触发任务点 hash,传递 hash 到其他插件。 chunkhash 也是类似的原理。

模板渲染 chunk

当 hash 都创建完成之后,下一步就会遍历 compilation.chunks 来渲染每一个 chunk。如果一个 chunk 是入口 chunk,那么就会调用 MainTemplate 实例的 render 方法,否则调用 ChunkTemplate 的 render 方法

这里注意到 ModuleTemplate 实例会被传递下去,在实际渲染时将会用 ModuleTemplate 来渲染每一个
module,其实更多是往 module 前后添加一些"包装"代码,因为 module 的源码实际上是已经渲染完毕的,前面的loader已经将源码转为JS源码

不同模版的说明:

// 入口 chunk
/******/ (function(modules) { // webpackBootstrap
/******/     // install a JSONP callback for chunk loading
/******/     var parentJsonpFunction = window["webpackJsonp"];
/******/     window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
/******/         // add "moreModules" to the modules object,
/******/         // then flag all "chunkIds" as loaded and fire callback
/******/         var moduleId, chunkId, i = 0, resolves = [], result;
/******/         for(;i < chunkIds.length; i++) {
/******/             chunkId = chunkIds[i];
/******/             if(installedChunks[chunkId]) {
/******/                 resolves.push(installedChunks[chunkId][0]);
/******/             }
/******/             installedChunks[chunkId] = 0;
/******/         }
/******/         for(moduleId in moreModules) {
/******/             if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/                 modules[moduleId] = moreModules[moduleId];
/******/             }
/******/         }
/******/         if(parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);
/******/         while(resolves.length) {
/******/             resolves.shift()();
/******/         }
/******/         
/******/     };
/******/     // 其他代码..
/******/ })(/* modules代码 */);

// 动态引入的 chunk
webpackJsonp([0],[
    /* modules代码.. */
]);

createChunkAssets 流程如下:
在这里插入图片描述

当每个 chunk 的源码生成之后,就会添加在 Compilation 实例的 assets 属性中。
assets 对象的 key 是最终要生成的文件名称,因此这里要用到前面创建的 hash。调用 Compilation 实例内部的 getPath 方法会根据配置中的 output.filename 来生成文件名称。
assets 对象的 value 是一个对象,对象需要包含两个方法,source 和 size分别返回文件内容和文件大小。
当所有的 chunk 都渲染完成之后,assets 就是最终更要生成的文件列表。此时 Compilation 实例还会触发几个任务点,例如 addtional-chunk-assets,addintial-assets等,在这些任务点可以修改 assets 属性来改变最终要生成的文件。

完成上面的操作之后,Compilation 实例的 seal 方法结束,进入到 Compiler 实例的 emitAssets 方法。Compilation 实例的所有工作到此也全部结束,意味着一次构建过程已经结束,接下来只有文件生成的步骤。

生成文件

在 Compiler 实例开始生成文件前,最后一个修改最终文件生成的任务点 emit 会被触发:

// 监听 emit 任务点,修改最终文件的最后机会
compiler.plugin("emit", (compilation, callback) => {
    let data = "abcd"
       compilation.assets["newFile.js"] = {
               source() {
           return data
       }
       size() {
           return data.length
       }
   }
})

当任务点 emit 被触发之后,接下来 webpack 会直接遍历 compilation.assets生成所有文件,然后触发任务点 done,结束构建流程。

总结

经过对核心流程梳理,发现webpack核心流程还是相当繁琐,且参与对象较多,任务点也多;为了便于记忆,个人总结了一个简易版的

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
  3. 确定入口:根据配置中的 entry 找出所有的入口文件;
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  5. 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统