webpack原理学习笔记

274 阅读6分钟

一、前言

webpack 是一个常用的打包工具,它的流行得益于模块化和单页应用的流行。平时开发过程中,更多在意的是它的使用。从加载配置文件、到构建、打包、输出文件的过程,也是一件超级有趣的事情。

在了解它的构建过程之前,可以先稍微了解一些东西:

  • Loader:模块转换器,用于把模块原内容按照需求转换成新内容。
  • Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情。
  • Compiler:Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。
  • Compilation:当 Webpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。

二、流程概括

  1. 初始化:解析命令行参数, 初始化配置参数(配置文件参数与默认配置参数合并)、创建 compiler 实例、遍历 plugins 数组(如果数组某一项类型不是 function 就调用它的 apply 方法)、根据配置参数添加插件之类。执行 compiler 实例的 run 方法进入编译阶段。
  2. 编译阶段:从构建的入口文件开始,解析得到 loader 路径和入口路径(同时将loader解析成了固定格式,因为配置的时候支持多种格式配置)。调用 doBuild,创建 normalModule (normalModule 是一个要构建的模块实例,它记录了入口文件是什么、要使用的 loader 有哪些等等) ,之后开始build模块。build 的时候首先是执行 runloaders,它会递归的加载loader,然后使用 loader 对文件处理,得到最后的输出结果。
  3. 寻找依赖:使用 acorn 解析上面得到的输出结果,寻找依赖关系。之后重复 2-3 来处理所依赖的文件。
  4. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表。
  5. 写入内容:根据配置确定输出的路径和文件名,创建目录、创建文件、接着把文件内容写入。

三、流程图

四、流程分析

1. 配置文件

const path = require('path')
const MyPlugin = require('../my-webpack-plugin')// 引用的 plugin

module.exports = {
  mode: 'development', //打包模式
  entry: './src/index.js', //从哪个文件开始打包
  module: {
    rules: [{
      test: /\.js$/,
      loader: path.resolve(__dirname, '..', './my-loader.js'),// 引用的 loader
    }]
  },
  plugins: [
    new MyPlugin()
  ],
  output: {
    filename: '[hash].js', //最终文件命名
    path: path.resolve(__dirname,'..', 'dist') //输出文件夹的路径
  }
}
// src/index.js 入口文件
import b from './index1.js'
const a = 1;
console.log(a + b);

// src/index1.js 依赖文件
const b = 2
export default b;
// 自定义 loader
module.exports = function loader(source) {
    return source.replace('const','var');
};
// 自定义插件
module.exports = class MyPlugin {
  apply(compiler) {
    // 设置回调来访问 compilation 对象:
    compiler.hooks.compilation.tap('myPlugin', compilation => {
      console.log('Hello compiler!')
      // 现在,设置回调来访问 compilation 中的任务点:
      compilation.hooks.optimize.tap('myPlugin', () => {
        console.log('Hello compilation!')
      })
    })
  }
}

2. 命令行执行

我们通常通过在 package.json 文件配置 scripts 来启动构建。webpack 作为可执行命令,如果是局部安装的话,它的位置在 node_modules/.bin/webpack,可以通过执行它来进入调试。

	...
	"scripts": {
    "build": "webpack --config ./build/webpack.config.js",
    "build:debug": "node --inspect-brk node_modules/.bin/webpack --config ./build/webpack.config.js"
  },
  ...

3. 解析参数

3.1从 Shell 语句中解析

首先先了解一下 process.argv,这个属性返回一个数组,其中包含当启动 Node.js 进程时传入的命令行参数。 第一个元素是 process.execPath。 其余元素将是任何其他命令行参数。

所以当我们执行 npm run build:debug的时候,process.argv数组是长这样,包含了 shell 语句中的参数。

4. 初始化

4.1 这里的例子,在解析完命令行参数之后,去加载了配置文件 webpack.config.js,得到配置参数。 node模块加载机制

4.2 与默认配置合并

就算没有写配置,webpack 也是有默认配置的。它有一个合并用户配置与默认配置的过程。

	process(options) {// 传入的 options 是用户的配置
		options = Object.assign({}, options);
		for (let name in this.defaults) {
			switch (this.config[name]) {
				case undefined:
					if (getProperty(options, name) === undefined) {
						setProperty(options, name, this.defaults[name]);
					}
					break;
				case "call":
					setProperty(
						options,
						name,
						this.defaults[name].call(this, getProperty(options, name), options)
					);
					break;
				case "make":
					if (getProperty(options, name) === undefined) {
						setProperty(options, name, this.defaults[name].call(this, options));
					}
					break;
				case "append": {
					let oldValue = getProperty(options, name);
					if (!Array.isArray(oldValue)) {
						oldValue = [];
					}
					oldValue.push(...this.defaults[name]);
					setProperty(options, name, oldValue);
					break;
				}
				default:
					throw new Error(
						"OptionsDefaulter cannot process " + this.config[name]
					);
			}
		}
		return options;// 最后得到合并的配置
	}

4.3 初始化 compiler

它继承于 Tapable,包含了完整的 webpack 配置,拥有很多的钩子,可以在感兴趣的钩子上添加回调,会在 webpack 构建流程中被触发,同时它也负责启动编译。

class Compiler extends Tapable {
	constructor(context) {
		super();
    this.hooks = {
			...
		}
		...	
  }
	...
  run(callback) {// 编译的开始
    ...
  }
}

4.4 遍历 plugins 数组

遍历 plugins 的过程,就是判断每一项是否类型是 function,不是的话就调用插件实例上的 apply 方法,在 apply 方法里,就可以在 compiler 暴露出的钩子上添加回调。 回调被调用的时机点,遍布 webpack 构建的生命周期。跟 vue 和 react 生命周期中在某个时机点,调用生命周期钩子函数的意思差不多。

...
if (options.plugins && Array.isArray(options.plugins)) {
			for (const plugin of options.plugins) {
				if (typeof plugin === "function") {
					plugin.call(compiler, compiler);
				} else {
					plugin.apply(compiler);
				}
			}
		}
...

4.5 在进入编译阶段之前,这里还需根据参数做一些事情。

比如 mode 是 development 或者 production,会通过 DefinePlugin 插件在 process.env.NODE_ENV 上设置值。optimization.minimizer 可以添加插件来覆盖默认的。根据 options.externals 是否加载 ExternalsPlugin 插件等等。

5 编译阶段

compiler.run 方法调用之后进入编译阶段。

5.1 实例化 compilation ,它也是继承于 Tapable

compilation 实例上会用于保存了compiler 的引用、模块资源、以及编译生成资源等等

class Compilation extends Tapable {
	constructor(compiler) {
		super();
		this.hooks = {
			...
		}
	}
	...
}

我们的自定义 plugin 之前订阅了 compilation 创建事件,所以在 compilation 实例被创建的时候被调用。调用的同时又订阅了优化开始的事件。

 compiler.hooks.compilation.tap('myPlugin', compilation => {
      console.log('Hello compiler!')
      // 现在,设置回调来访问 compilation 中的任务点:
      compilation.hooks.optimize.tap('myPlugin', () => {
        console.log('Hello compilation!')
      })
 })

5.2 从入口文件开始

(compilation, callback) => {
				const { entry, name, context } = this;

				const dep = SingleEntryPlugin.createDependency(entry, name);
				compilation.addEntry(context, dep, name, callback);
}

在真正的使用 loader 对文件内容处理之前,会先解析入口文件的绝对路径和 loader 的绝对路径,并且将 loader 解析成固定格式。

这里的固定格式的意思就是无论你使用哪种姿势配置 loader,如这样

  module: {
    rules: [
      {
        test: /\.js$/,
        loader: path.resolve(__dirname, "..", "./my-loader.js")+'?op=xxx',
      }
    ]
  },

或这样

  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: path.resolve(__dirname, "..", "./my-loader.js"),
          options: {
            op:'xxx'
          }
        }
      }
    ]
  },

最后整理出来的 loaders 数组格式,差不多都长图片中的这样。转成统一格式,方面后续处理。

5.3 创建 normalModule 实例

normalModule 是一个要构建的模块实例,它记录了入口文件是什么、要使用的 loader 有哪些等等。后面的 chunk 生成文件模块也要使用到它上面记录的信息。它也会被保存在 compilation 实例中。

					let createdModule = this.hooks.createModule.call(result);
					if (!createdModule) {
						if (!result.request) {
							return callback(new Error("Empty dependency (no request)"));
						}

						createdModule = new NormalModule(result);
					}

					createdModule = this.hooks.module.call(createdModule, result);

					return callback(null, createdModule);

之后便开始了真正的模块 build,也就是在 build 之前,会调用 compilation 的 buildModule 钩子事件。这个钩子能做的事情很有趣,可以把 module(也就是 normalModule 实例)的文件路径指向一个空文件(也就是之后构建的时候都是对这个空文件做处理),来实现插件可插拔的配置。

this.hooks.buildModule.call(module);

5.4 加载 loader,从 loaders 数组第一位开始加载

function iteratePitchingLoaders(options, loaderContext, callback) {
  // loaderContext.loaders 是 loaders 数组
  // loaderContext.loaderIndex 是被加载的 loader 的下标
  
	if(loaderContext.loaderIndex >= loaderContext.loaders.length)
		return processResource(options, loaderContext, callback);// 这里是加载全部 loader 之后,读入口模块文件,开始用 loader 处理文件内容

	var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];

	if(currentLoaderObject.pitchExecuted) {// 判断 loader 是否加载过
		loaderContext.loaderIndex++;// 下标加1,
		return iteratePitchingLoaders(options, loaderContext, callback);// 调用自身
	}

	loadLoader(currentLoaderObject, function(err) {// 通过 require 加载 loader
		...
    
		currentLoaderObject.pitchExecuted = true;// 当前 loader 被加载了
		if(!fn) return iteratePitchingLoaders(options, loaderContext, callback);// 调用自身

		...
	});
}

看一下 processResource 方法

function processResource(options, loaderContext, callback) {
	// 设置 loader 的下标为最后一个 loader
	loaderContext.loaderIndex = loaderContext.loaders.length - 1;

	var resourcePath = loaderContext.resourcePath; // 构建文件的路径
	if(resourcePath) {
		loaderContext.addDependency(resourcePath);
		options.readResource(resourcePath, function(err, buffer) {// 根据构建文件路径,读文件
			if(err) return callback(err);
			options.resourceBuffer = buffer;
			iterateNormalLoaders(options, loaderContext, [buffer], callback); // 看下面
		});
	} else {
		iterateNormalLoaders(options, loaderContext, [null], callback);
	}
}

然后进入 loader 对模块文件处理的步骤

5.5 使用 loader 对入口模块文件进行处理,从 loaders 最后一位开始对模块处理

上面设置 loader 的下标,和这边的 loaderContext.loaderIndex--, 让 loader 的处理顺序从右往左。

function iterateNormalLoaders(options, loaderContext, args, callback) {
	if(loaderContext.loaderIndex < 0)
		return callback(null, args);// 当 loader 对模块内容处理完之后调用

	var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];// 获得当前的 loader

	if(currentLoaderObject.normalExecuted) {// loader 是否被执行过
		loaderContext.loaderIndex--;// 修改 loaders 索引
		return iterateNormalLoaders(options, loaderContext, args, callback);// 调用自身
	}

	var fn = currentLoaderObject.normal;
	currentLoaderObject.normalExecuted = true;// 标识 loader 是否被执行过
	if(!fn) {
		return iterateNormalLoaders(options, loaderContext, args, callback);// 调用自身
	}

	convertArgs(args, currentLoaderObject.raw);

	runSyncOrAsync(fn, loaderContext, args, function(err) {// 使用 loader 对模块内容进行处理
		if(err) return callback(err);

		var args = Array.prototype.slice.call(arguments, 1);
		iterateNormalLoaders(options, loaderContext, args, callback);// 调用自身
	});
}

就在这个处理的过程中,就调用到了我们的自定义 loader,这里只是简单的把文件内容的 const 替换为了 var

module.exports = function loader(source) {
    return source.replace('const','var');
};

5.6 寻找依赖

当 loader 对模块处理完之后,剩下要做的事情就是找出模块所依赖的文件。通过 acorm 来解析模板文件。

	parse(source, initialState) {
		let ast;
		let comments;
		if (typeof source === "object" && source !== null) {
			
      ...
      
		} else {
			comments = [];
			ast = Parser.parse(source, {// 解析得到 ast
				sourceType: this.sourceType,
				onComment: comments
			});
		}

		...
	
		if (this.hooks.program.call(ast, comments) === undefined) {
      ...
			this.prewalkStatements(ast.body);
      ...
		}
		...
	}

5.7 找到依赖之后,处理方式跟入口文件处理一样。

6. 输出资源

6.1 创建 hash 的过程

有需要再看吧。

	createHash() {
		const outputOptions = this.outputOptions;
		const hashFunction = outputOptions.hashFunction;
		const hashDigest = outputOptions.hashDigest;
		const hashDigestLength = outputOptions.hashDigestLength;
		const hash = createHash(hashFunction);
		if (outputOptions.hashSalt) {
			hash.update(outputOptions.hashSalt);
		}
		this.mainTemplate.updateHash(hash);
		this.chunkTemplate.updateHash(hash);
		for (const key of Object.keys(this.moduleTemplates).sort()) {
			this.moduleTemplates[key].updateHash(hash);
		}
		for (const child of this.children) {
			hash.update(child.hash);
		}
		for (const warning of this.warnings) {
			hash.update(`${warning.message}`);
		}
		for (const error of this.errors) {
			hash.update(`${error.message}`);
		}
		const modules = this.modules;
		for (let i = 0; i < modules.length; i++) {
			const module = modules[i];
			const moduleHash = createHash(hashFunction);
			module.updateHash(moduleHash);
			module.hash = /** @type {string} */ (moduleHash.digest(hashDigest));
			module.renderedHash = module.hash.substr(0, hashDigestLength);
		}
		const chunks = this.chunks.slice();
		chunks.sort((a, b) => {
			const aEntry = a.hasRuntime();
			const bEntry = b.hasRuntime();
			if (aEntry && !bEntry) return 1;
			if (!aEntry && bEntry) return -1;
			return byId(a, b);
		});
		for (let i = 0; i < chunks.length; i++) {
			const chunk = chunks[i];
			const chunkHash = createHash(hashFunction);
			try {
				if (outputOptions.hashSalt) {
					chunkHash.update(outputOptions.hashSalt);
				}
				chunk.updateHash(chunkHash);
				const template = chunk.hasRuntime()
					? this.mainTemplate
					: this.chunkTemplate;
				template.updateHashForChunk(
					chunkHash,
					chunk,
					this.moduleTemplates.javascript,
					this.dependencyTemplates
				);
				this.hooks.chunkHash.call(chunk, chunkHash);
				chunk.hash = /** @type {string} */ (chunkHash.digest(hashDigest));
				hash.update(chunk.hash);
				chunk.renderedHash = chunk.hash.substr(0, hashDigestLength);
				this.hooks.contentHash.call(chunk);
			} catch (err) {
				this.errors.push(new ChunkRenderError(chunk, "", err));
			}
		}
		this.fullHash = /** @type {string} */ (hash.digest(hashDigest));
		this.hash = this.fullHash.substr(0, hashDigestLength);
	}

6.2 输出

输出是把每个包含多个模块的 Chunk 转换成一个单独的文件加入到输出列表。这里的 Chunk 包含了两个模块,分别是 index.jsindex1.jsnormalModule

	createChunkAssets() {
		
    ...
    
		for (let i = 0; i < this.chunks.length; i++) {
			
      ...
      
			try {
				
          ...
					
					
						source = fileManifest.render();// 合并模块生成一份资源
				
						...
            
					}
					this.emitAsset(file, source, assetInfo);// 把输出资源存起来了,其实是存在 compilation 上
				
        	...
		}
	}

写到这里不得不提一嘴,我们在配置文件中,经常使用中括号包裹一些变量,比如 [hash] 之类的,也是在这里替换的。就是拿到各种生成后的变量,通过replace 大法替换。

const replacePathVariables = (path, data, assetInfo) => {
	
  ...

	...

	return (
		path
			.replace(// 我们这边只使用到 [hash] 所以替换成 hash 的值
				REGEXP_HASH,
				withHashLength(getReplacer(data.hash), data.hashWithLength, assetInfo)
			).replace(xxx)
    ....
	);
};

7. 写入文件

7.1 创建输出目录

输出路径创建dist 目录

	this.hooks.emit.callAsync(compilation, err => {
			if (err) return callback(err);
			outputPath = compilation.getPath(this.outputPath);
			this.outputFileSystem.mkdirp(outputPath, emitFiles);
	});

7.2 输出文件

		const emitFiles = err => {
			if (err) return callback(err);

			asyncLib.forEachLimit(// 这个方法限制了每次异步操作时的允许的并发执行的任务数量。
				compilation.getAssets(),// 这个就是刚才被存在 compilation 上的输出资源
				15,
				({ name: file, source }, callback) => {
					let targetFile = file;
		
          ...

					const writeOut = err => {
						if (err) return callback(err);
						const targetPath = this.outputFileSystem.join(// 得到输出文件的目录
							outputPath,
							targetFile
						);
						
            ...
            
						} else {
							
              ...
              
							let content = source.source(); // 得到文件内容

							if (!Buffer.isBuffer(content)) {
								content = Buffer.from(content, "utf8");// 创建一个新的 Buffer 包含 content,并制定字符串的字符编码
							}

							...
              
							this.outputFileSystem.writeFile(targetPath, content, err => {// 写文件进去
								if (err) return callback(err);
								this.hooks.assetEmitted.callAsync(file, content, callback);
							});
						}
					};

					...
          
						writeOut();

          ...
				},
				
          ...
        
		};

五、使用的依赖

1.neo-async

1.1 parallel 方法

parallel函数是并行执行多个函数,每个函数都是立即执行,不需要等待其它函数先执行。 传给最终callback的数组中的数据按照 tasks 中声明的顺序,而不是执行完成的顺序。

var order = [];
var tasks = [
 function(done) {
   setTimeout(function() {
     order.push(1);
     done(null, 1);
   }, 10);
 },
 function(done) {
   setTimeout(function() {
     order.push(2);
     done(null, 2);
   }, 30);
 },
 function(done) {
   setTimeout(function() {
     order.push(3);
     done(null, 3);
   }, 40);
 },
 function(done) {
   setTimeout(function() {
     order.push(4);
     done(null, 4);
   }, 20);
 }
];
async.parallel(tasks, function(err, res) {
  console.log(res); // [1, 2, 3, 4];
  console.log(order); // [1, 4, 2, 3]
});

1.2 map方法

并且调用,有返回结果。

// array
var order = [];
var array = [1, 3, 2];
var iterator = function(num, done) {
  setTimeout(function() {
    order.push(num);
    done(null, num);
  }, num * 10);
};
async.map(array, iterator, function(err, res) {
  console.log(res); // [1, 3, 2];
  console.log(order); // [1, 2, 3]
});

1.3 each方法

并行的应用迭代器去遍历 array,iterator将调用数组列表,回调函数在它结束时进行调用。

// array
var order = [];
var array = [1, 3, 2];
var iterator = function(num, done) {
  setTimeout(function() {
    order.push(num);
    done();
  }, num * 10);
};
async.each(array, iterator, function(err, res) {
  console.log(res); // undefined
  console.log(order); // [1, 2, 3]
});

1.4 eachLimit 方法

类似于each,但它限制了每次异步操作时的允许的并发执行的任务数量。

// array with index
var order = [];
var array = [1, 5, 3, 4, 2];
var iterator = function(num, index, done) {
  setTimeout(function() {
    order.push([num, index]);
    done();
  }, num * 10);
};
async.eachLimit(array, 2, iterator, function(err, res) {
  console.log(res); // undefined
  console.log(order); // [[1, 0], [3, 2], [5, 1], [2, 4], [4, 3]]
});

2.enhanced-resolve

提供了异步的 resolve 方法,获取模块的绝对地址,顺便判断一下模块是否存在。

const fs = require("fs");
const { CachedInputFileSystem, ResolverFactory } = require("enhanced-resolve");

// create a resolver
const myResolver = ResolverFactory.createResolver({
	// Typical usage will consume the `fs` + `CachedInputFileSystem`, which wraps Node.js `fs` to add caching.
	fileSystem: new CachedInputFileSystem(fs, 4000),
	extensions: [".js", ".json"]
	/* any other resolver options here. Options/defaults can be seen below */
});

// resolve a file with the new resolver
const context = {};
const resolveContext = {};
const lookupStartPath = "/Users/webpack/some/root/dir";
const request = "./path/to-look-up.js";
myResolver.resolve({}, lookupStartPath, request, resolveContext, (
	err /*Error*/,
	filepath /*string*/
) => {
	// Do something with the path
});

3.crypto

这边用于生成 hash 值。

crypto.createHash(algorithm)
// 创建并返回一个hash对象,它是一个指定算法的加密hash,用于生成hash摘要。

hash.update(data)
// 更新hash的内容为指定的data。当使用流数据时可能会多次调用该方法。

hash.digest(encoding='binary')
// 计算所有传入数据的hash摘要。参数encoding(编码方式)可以为'hex', 'binary' 或者'base64'。

4.Tappable

webpack本质上是一种事件流的机制,它的工作流程就是将各个插件串联起来,而实现这一切的核心就是TapableTapable暴露出挂载plugin的方法,使我们能将plugin控制在webapack事件流上运行, CompilerCompilation等都是继承于Tabable类。也就是说在整个构建过程,插件可以做很多很多的事情都是因为它会在构建的某个时机点广播出事件通知。

参考资料

segmentfault.com/a/119000001…