CommanderXL/Biu-blog

Webpack childCompiler子编译

CommanderXL opened this issue · 5 comments

childCompiler 子编译

webpack 子编译可以理解成创建了一个新的构建流程。webpack 内部的 compilation 的实例上提供了创建子编译流程的 API:createChildCompiler。

class Compilation {
  ...
  /**
	 * This function allows you to run another instance of webpack inside of webpack however as
	 * a child with different settings and configurations (if desired) applied. It copies all hooks, plugins
	 * from parent (or top level compiler) and creates a child Compilation
	 *
	 * @param {string} name name of the child compiler
	 * @param {TODO} outputOptions // Need to convert config schema to types for this
	 * @param {Plugin[]} plugins webpack plugins that will be applied
	 * @returns {Compiler} creates a child Compiler instance
	 */
  createChildCompiler(name, outputOptions, plugins) {
    const idx = this.childrenCounters[name] || 0;
    this.childrenCounters[name] = idx + 1;
    return this.compiler.createChildCompiler(
      this, // 传入 compilation 对象
      name,
      idx,
      outputOptions,
      plugins
    );
  }
  ...
}

那么这个子编译流程到底和父编译流程有哪些差异呢?

class Compiler {
  ...
  createChildCompiler(
		compilation,
		compilerName,
		compilerIndex,
		outputOptions,
		plugins
	) {
		const childCompiler = new Compiler(this.context); // 创建新的 compiler 对象,和父 compiler 拥有相同的 context 上下文路径
		if (Array.isArray(plugins)) { // 如果在子编译的过程中需要相关插件的处理,那么就在创建子编译的阶段传入这些插件,需要注意的是在这个阶段执行这些插件的话,下面的有关 childCompiler 一些配置信息是拿不到的,因此可以先创建 childCompiler,然后由自己去手动的 apply 插件
			for (const plugin of plugins) {
				plugin.apply(childCompiler);
			}
		}
		for (const name in this.hooks) {
			if (
				![
					"make",
					"compile",
					"emit",
					"afterEmit",
					"invalid",
					"done",
					"thisCompilation"
				].includes(name) 
			) {
				if (childCompiler.hooks[name]) { // 子编译不会继承上面列出来的编译流程当中的钩子
					childCompiler.hooks[name].taps = this.hooks[name].taps.slice();
				}
			}
		}
    // 接下来就是设置子编译 compiler 实例上的相关的属性或者方法
		childCompiler.name = compilerName;
		childCompiler.outputPath = this.outputPath;
		childCompiler.inputFileSystem = this.inputFileSystem;
		childCompiler.outputFileSystem = null;
		childCompiler.resolverFactory = this.resolverFactory;
		childCompiler.fileTimestamps = this.fileTimestamps;
		childCompiler.contextTimestamps = this.contextTimestamps;

		const relativeCompilerName = makePathsRelative(this.context, compilerName);
		if (!this.records[relativeCompilerName]) {
			this.records[relativeCompilerName] = [];
		}
		if (this.records[relativeCompilerName][compilerIndex]) {
			childCompiler.records = this.records[relativeCompilerName][compilerIndex];
		} else {
			this.records[relativeCompilerName].push((childCompiler.records = {}));
		}

		childCompiler.options = Object.create(this.options); // options 配置继承于父编译 compiler 实例
		childCompiler.options.output = Object.create(childCompiler.options.output);
		for (const name in outputOptions) {
			childCompiler.options.output[name] = outputOptions[name];
		}
		childCompiler.parentCompilation = compilation; // 建立父子编译之间的关系

    // 触发 childCompiler hooks
		compilation.hooks.childCompiler.call(
			childCompiler,
			compilerName,
			compilerIndex
		);

		return childCompiler;
	}
  ...
}

通过代码我们发现在创建子编译 compiler 的过程中是过滤掉了make/compiler/emit/afterEmit等 hooks 的触发函数的,即子编译流程相对于父编译流程来说的话不具备完整的构建流程。例如在父编译的流程开始阶段会触发 hooks.make 钩子,这样完成入口文件的添加及开始相关的编译流程,而子编译要想完成编译文件的工作的话就需要你手动的在创建子编译的时候添加入口插件(例如 SingleEntryPlugin)。父编译阶段使用 compiler 实例上的 run 方法开始进行,而子编译阶段有一个独立的 runAsChild 方法用以开始编译,其中在 runAsChild 方法的 callback 中可以看到子编译阶段是没有单独的 emitAssets 的阶段的。在子编译阶段如果需要输出文件的话,是需要挂载到父编译的 compilation.assets 上的:

class Compiler {
	...
	runAsChild() {
		this.compile((err, compilation) => {
			...
			this.parentCompilation.children.push(compilation)
			for (const name of Object.keys(compilation.assets)) { // 将子编译需要输出的 chunk 文件挂载到父编译上,进而完成相关的 chunk 的输出工作
				this.parentCompilation.assets[name] = compilation.assets[name];
			}
			...
		})
	}
	...
}

那么 childCompiler 子编译具体有哪些使用场景呢?在 webpack 官方的抽离 css chunk 的插件当中mini-css-extract-plugin就是使用到了 childCompiler 子编译去完成 css 的抽离工作,它主要体现了这个插件内部会提供了一个单独的 pitch loader,使用这个 pitch loader 进行样式模块(例如css/stylus/scss/less)的流程处理的拦截工作,在拦截的过程当中为每个样式模块都创建新的 childCompiler,这个 childCompiler 主要完成的工作就是专门针对这个样式模块进行编译相关的工作。可以想象的到就是每一个样式模块完成编译的工作后,都会生成一个 css chunk file。当然我们最终希望的是这些 css chunk file 最终能合并到一个 css chunk 文件当中,最后项目上线后,只需要加载少量的 css 文件。因此在 mini-css-extract-plugin 插件内部,每个样式模块通过子编译的流程后,是直接删除掉了 compilation.chunks 当中包含的所有的 file,即这些 css 模块最终不会被挂载到父编译的 assets 上,这样也不会为每个样式模块输出一个 css chunk file。这个插件等每个样式模块的子编译流程结束后,都会新建一个 css module,这个 css module 依赖类型为插件内部自己定义的,并且会作为当前正在编译的 module 依赖而被添加到当前模块当中。接下来,在父编译的 createChunkAssets 流程当中,分别触发 maniTemplate.hooks.renderManifest 和 chunkTemplate.hooks.renderManifest 的钩子的时候,会分别将 chunk 当中所包含的 css module 过滤出来,得到 css module 的集合,这样最终在输出文件的时候就会输出 css chunk 文件,这些 css chunk 文件当中就是分别包含了 css module 的集合而输出的。

PS:不过在你写插件或者 loader 的过程中,需要注意的一个地方就是一些 hooks,例如 thisCompilation 是不会被 childCompiler 继承的,因此如果有些插件注册的相关的 hooks 正好是这个,那么在你创建了 childCompiler 需要手动的调用这些插件的 apply 方法并传入 childCompiler,这样这些插件才能在 childCompiler 当中工作起来。这里也可以很明显的感受到在 compiler.js 当中触发 hooks.thisCompilation 和 hooks.compilation 2个钩子的区别。hooks.compilation 会被 childCompiler 继承,在 childCompiler 编译流程当中还会触发对应的钩子函数,而 hooks.thisCompilation 上绑定的钩子函数只适用于当前的 compiler 编译流程,如果是需要在其他的编译流程(childCompiler)当中使用的话,那么就需要手动的添加这些钩子。

主 compiler 在创建子编译的过程当中 compilation.createChildCompiler 会将主 compiler 上已经注册好的 hooks 一并在 childCompiler 上注册好。例如在主 compiler 上注册了 hooks.finishMake 的回调,那么在 childCompiler 编译流程当中,会触发这个 hooks.finishMake 所注册好的回调。

一个是钩子数量上有差异,另外就是 childCompiler 会复用主 compiler 上注册好的对应的 hooks。

想问下这个地方,mini-css-extract-plugin 为啥不直接再起一个 compiler,而用 childCompiler?因为只有部分勾子,运行速度理论上快?

@BUPTlhuanyu 我是这样理解的:其实这里 childCompiler 就是一个新起的 compiler,只不过 childCompiler 和所在的原有的 compiler 之间是有一定联系的,也就是在 childCompiler 初始化阶段所 继承/复用 的相关配置流程信息。childCompiler 算是对于一个新的 compiler 的一个封装,去解决在构建过程中可能需要进行的其他的编译相关的操作。

另外就是在 webpack5 (5.32.0+) 里面提供了 importModule 的方法去简化了构建过程中要进行其他编译流程操作

感谢回复,其实是这样最近遇到一个 + child process + 异步 编译 less 的问题,我有一些 loader:

extract-xxx
thread-loader
loaderA
loaderB
loaderC

前两个都只有 pitch,第一个用到了 childCompiler,第二个用到了 child Process 开启多进程打包,这些loader之间需要传递数据。现在在处理 less 的时候遇到字体url的时候,走到自定义的处理字体的loader,这个loader其实回往 compilation 上塞数据,这个 compilation 是子还是父呢?从断点来看是子。

虽然速度优化了50%,现在还需要确定下面的一些疑点:

  1. 感觉上这里多进程貌似没有理论依据,但是确实速度优化了
  2. webpack 的异步的详细机制
  3. childCompiler 的实现机制

老哥能否指点一二?或者有啥比较好的涉及上面三个问题的源码分析的文章?webpack 源码太多😄,有点难读,想白嫖,哈哈。

@BUPTlhuanyu

  1. thread-loader 具体实现没有太了解过。不过在我们平时的使用场景当中(跨平台打包编译构建)也会手动开启多进程来进行并行编译提高速度,这个并行处理的收益也是显而易见的。
  2. webpack 异步的话基本都是 node.js callback 的形式去组织代码的,所以堆栈非常深,看代码的时候经常需要翻看代码当中出现的 callback 到底是外部传入还是说内部定义的。这个应该在你调试代码的时候有体会到,就是调试的时候在不同函数、文件之间乱跳。
  3. childCompiler 这块的话其实核心就是 new Compiler 创建了一个新的 Compiler 实例,这个 compiler 实例的话很多属性都是继承来自主 compiler,有些 hooks、属性等等和主的 compiler 有些差异。但是整个编译构建流程是和主的保持一致的,所以如果你要利用 childCompiler 去做一些工作的话,大致需要了解初始化的一些操作即可。在具体的使用场景当中的话,基本是遇到那种需要构建一个新的 js module 然后走编译流程获取你想要的结果。例如 vue-loader 还有这个 issue 里面提到的关于 mini-css-extract-plugin 都是这样: 一个自定义的匹配规则 -> loader pitch -> 返回一个期望的 js module -> 利用 childCompiler 新的编译构建。