webpack5源码之旅 - 先导

2023-02-09
webpack

Before

上文webpack5源码之旅 - webpack-cli讲到通过webpack-cli或者其它任何方式处理完参数后, 都会执行webpack(config)。
接下来我们就进入webpack一探究竟。

require('webpack')

webpack的入口文件是lib/index.js(package.json中配置的main值),也就是require('webpack')时执行的文件。

lib/index.js
Copy
const mergeExports = (obj, exports) => {
	const descriptors = Object.getOwnPropertyDescriptors(exports);
	for (const name of Object.keys(descriptors)) {
		const descriptor = descriptors[name];
		if (descriptor.get) {
			const fn = descriptor.get;
			Object.defineProperty(obj, name, {
				configurable: false,
				enumerable: true,
				get: memoize(fn)
			});
		} else if (typeof descriptor.value === "object") {
			Object.defineProperty(obj, name, {
				configurable: false,
				enumerable: true,
				writable: false,
				value: mergeExports({}, descriptor.value)
			});
		} else {
			throw new Error(
				"Exposed values must be either a getter or an nested object"
			);
		}
	}
	return /** @type {A & B} */ (Object.freeze(obj));
};

const fn = lazyFunction(() => require("./webpack"));
module.exports = mergeExports(fn, {...})
全屏

执行mergeExports方法,传入fn和一个对象exports。返回fn和exports对象merge后的对象(属性只读,不能删,不可修改)。(getOwnPropertyDescriptors,返回属性的描述对象。)
所以module.exports出去的其实就是fn,只不过增加了一堆属性。
再看fn,它执行了lazyFunction:

Copy
const memoize = fn => {
  let cache = false;
  /** @type {T} */
  let result = undefined;
  return () => {
    if (cache) {
      return result;
    } else {
      result = fn();
      cache = true;
      // Allow to clean up memory for fn
      // and all dependent resources
      fn = undefined;
      return result;
    }
  };
};
const lazyFunction = factory => {
	const fac = memoize(factory);
	const f = /** @type {any} */ (
		(...args) => {
			return fac()(...args);
		}
	);
	return /** @type {T} */ (f);
};
全屏

lazyFunction把你传入的方法用memoize包了一下,返回一个新的方法。
而memoize做的事情就是把你传入的方法(() => require("./webpack"))执行一次,然后把结果缓存起来,下次再调用的时候直接返回缓存的结果。
上面的mergeExports方法,处理get的时候也用了memoize包一下,所以webpack中的所有方法都会在第一次调用后被缓存起来。
接着进入到lib/webpack.js文件。

webpack(config)

lib/webpack.js
Copy
const webpack = /** @type {WebpackFunctionSingle & WebpackFunctionMulti} */ (
	(options, callback) => {...}
);

module.exports = webpack;
全屏

这个文件导出一个函数webpack,这个函数接收options和callback作为参数,到这里就真正进入到打包环节。
我们通常将整个编译过程分为三个阶段:

  • 初始化阶段
  • 构建阶段
  • 生成阶段

本文先介绍一下整个阶段会常遇到的一些模块、概念以及用到的独立的库,留一个印象,便于后续的阅读。

Compiler

编译管理器,传入options生成Compiler对象,这个对象是唯一的,会一直存活至全流程结束。
它控制着主流程,具体的事情会交给具体的模块处理。

Copy
const compiler = new Compiler(options.context, options); // 创建Compiler对象
全屏

Compilation

单次编辑过程的管理器,在构建阶段处理模块,保存构建信息。 每次文件变更触发重新编译时,都会创建一个新的 compilation 对象。

Copy
const params = {
  normalModuleFactory: this.createNormalModuleFactory(),
  contextModuleFactory: this.createContextModuleFactory()
}; // params为创建Compilation对象时需要的参数
const compilation = this.newCompilation(params); // 创建Compilation对象
全屏

Entry

入口,编译的起点,通常情况下webpack会从从入口开始寻找到所有的依赖,打成一个chunk。

lib/EntryPlugin.js
Copy
...
const { entry, options, context } = this;
const dep = EntryPlugin.createDependency(entry, options); // 创建Dependency对象

compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
  compilation.addEntry(context, dep, options, err => {
    callback(err);
  });
}); // 注册make钩子,执行compilation.addEntry方法
...

static createDependency(entry, options) {
  const dep = new EntryDependency(entry); // 入口文件会被实例化为EntryDependency
  // TODO webpack 6 remove string option
  dep.loc = { name: typeof options === "object" ? options.name : options };
  return dep;
}
全屏

Dependency

依赖对象,webpack 基于该类型记录模块间依赖关系。 上面Entry的代码中可以看到createDependency里,实例化了EntryDependency, 这个类继承自Dependency,还有很多很多不同种类的**Dependency,都继承自Dependency。

Module

在webpack的世界中,万事万物都是模块,js、css、图片或者视频,都会变成**Module。
普通的模块会生成NormalModule,创建Compilation对象的时候传入的params中有一个normalModuleFactory, 这个factory就是创建NormalModule的地方。
NormalModule中会调用对应的parse方法将源码解析为AST(抽象语法树),从而解析出所有依赖。
构建模块时,还会生成moduleGraph来记录模块之间引用信息。

Chunk

输出时,webpack会将之前生成的所有module,按照一定的规则打成一个或者多个chunk。webpack内置的chunk规则有:

  • 从entry开始,找到所有依赖的module,打包成一个chunk
  • 使用动态引入语句引入的模块,打包成一个chunk

Loader

webpack原生只支持js的处理,像是css、图片等就都需要通过loader转为js,才能被webpack处理。
所以通常我们会在配置文件中配置各种rules,这些rules会在实例化normalModuleFactory时进行处理。

Copy
module.exports = {
  ...,
  module: {
      rules: [
          {test: /\.ts$/, loader: 'ts-loader'},
      ]
  }
}
全屏

Plugin

相信用过webpack的小伙伴都不对Plugin感到陌生,通常都会写几个Plugin到配置文件中。 其实webpack的各种能力基本都是通过Plugin实现的,在初始化Compiler后,会挂载你配置的所有plugin, 然后在中WebpackOptionsApply,根据配置再去挂载各种内置Plugin(即使没有配置文件,它也会默认给你配置一些功能)。

lib/webpack.js
Copy
const createCompiler = rawOptions => {
	const options = getNormalizedWebpackOptions(rawOptions);
	applyWebpackOptionsBaseDefaults(options);
	const compiler = new Compiler(options.context, options);
  ...
	if (Array.isArray(options.plugins)) {
		for (const plugin of options.plugins) {
			if (typeof plugin === "function") {
				plugin.call(compiler, compiler);
			} else {
				plugin.apply(compiler);
			}
		}
	} // 挂载配置的plugin
	new WebpackOptionsApply().process(options, compiler); // 根据配置挂载内置plugin
	compiler.hooks.initialize.call();
	return compiler;
};
全屏

使用Plugin实现各种功能的好处就是这些Plugin都是与webpack解耦的,有很强的可扩展性。

lib/EntryOptionPlugin.js
Copy
class EntryOptionPlugin {
  /**
   * @param {Compiler} compiler the compiler instance one is tapping into
   * @returns {void}
   */
  apply(compiler) {
    compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
      EntryOptionPlugin.applyEntryOption(compiler, context, entry);
      return true;
    });
  }
  ...
}
全屏

每个Plugin都会有一个apply方法,它接收compiler作为参数,然后往上挂载钩子(hooks),然后就可以在特定的节点介入编译过程。
这个过程是基于Tapable实现的,下面也会简单介绍Tapable。

Parser

上面Module部分提到了Parser,webpack中最常用到的Parser就是JavascriptParser,它使用acorn将js解析为AST, 然后就可以分析中其中的所有依赖,还可以解析注释等等。

Tapable

这是webpack中最重要的一个库,基本上每个功能模块都可以看到它,Plugin的实现就是依赖于Tapable。它是一种发布订阅模式。

lib/COmpiler.js
Copy
entryOption: new SyncBailHook(["context", "entry"]) // 定义钩子
全屏

实例化一个钩子,并定义回调函数的行参。

lib/EntryOptionsPlugin.js
Copy
apply(compiler) {
		compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
			EntryOptionPlugin.applyEntryOption(compiler, context, entry);
			return true;
		});
	}
全屏

使用tap方法注册事件,并传入回调函数。

lib/WebpackOptionsApply.js
Copy
compiler.hooks.entryOption.call(options.context, options.entry);
全屏

使用call方法触发事件,这时候就会执行注册时传入的回调函数,并传入参数。

理解Tapable是读webpack源码的基础,参考的部分有相关文章,可以进行阅读学习,这边总结一下Tapable的几种钩子以及它们对应的注册和调用方法。

hook描述注册方法调用方法
SyncHook同步tapcall
SyncBailHook同步保险tapcall
SyncWaterfallHook同步瀑布tapcall
SyncLoopHook同步循环tapcall
AsyncParallelHook异步并行tap、tapAsync、tapPromisecall、callAsync、promise
AsyncParallelBailHook异步并行保险tap、tapAsync、tapPromisecall、callAsync、promise
AsyncSeriesHook异步串行tap、tapAsync、tapPromisecall、callAsync、promise
AsyncSeriesBailHook异步串行保险tap、tapAsync、tapPromisecall、callAsync、promise
AsyncSeriesWaterfallHook异步串行瀑布tap、tapAsync、tapPromisecall、callAsync、promise

Summary

介绍了webpack的入口文件,以及后续编译过程会遇到的一些概念,下一节我们就从初始化开始了解webpack到底是如何工作的。

Reference

1. webpack5 源码详解 - 先导
2. [万字总结] 一文吃透 Webpack 核心原理
3. 【中级/高级前端】为什么我建议你一定要读一读 Tapable 源码?