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的准备阶段

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

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执行时,会生成createNormalModuleFactory和createContextModuleFactory,并传入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-resolve 和 after-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 使用的 parser 是 acorn)
parser 将 js 源码解析后得到对应的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 会开始生成 chunks。webpack 中的 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, ChunkTemplate,ModuleTemplate。这三个对象是用来渲染 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. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统