Skip to content

⚠️ Important Notice

This post was last updated on: which was . Please pay attention to its timelines.

背景

虽然现在构建工具日新月异,Vite、Rspack、Turbopack...,但还是有大量的旧项目使用了Wepback,并且面试大概率还是会问Wepback相关的东西,所以这里还是总结一些Webpack相关知识点吧。

Webpack流程概述

流程图

该图来自范文杰老师的系列文章。具体哪一篇不记得了,这里随便贴了个链接。

总体介绍

首先要知道的是Webpack核心功能:可以将各种资源包括图片/css/js等打包成标准的浏览器可以识别的文件。

从功能上可以将Webpack的工作分为三个阶段:

  1. 初始化阶段
    • 初始化参数:会从配置文件、 配置对象、Shell参数中读取参数,然后和默认参数合并得到最终的参数配置
    • 初始化编译环境:用上一步的参数创建Compiler对象,然后注入内置插件、注册各种module工厂函数、初始化RullSet集合、加载配置的插件Plugins等。
    • 确定入口:编译环境确定好之后,会执行Compiler.run方法,进入编译阶段。然后从配置参数的entry中找到入口文件,调用compilition.addEntry将入口文件转为dependence对象。
  2. 构建阶段
    • 编译模块(make):根据上面的dependence对象创建module对象,然后调用loader将模块转译成标准的JS内容,然后调用JS解释器将内容转为AST对象,进一步分析出其依赖的模块,然后递归直到所有的依赖都被处理。

    NOTE

    Webpack5之前只能识别符合JavaScript规范的文本,Webpack5之后添加了其他parser

    • 完成模块编译:所有的依赖都处理完成之后,就得到了每个模块转译之后的内容以及依赖关系图
  3. 生成阶段
    • 输出(seal):根据入口和模块之间的依赖关系,就可以组装成一个个包含多个模块moduleChunk,然后再将Chunk转换成单独文件加入到输出列表。这里是最后修改输入文件的时机。
    • 写入文件系统(emitAsstes):输出内容确定之后,就可以根据配置的输出参数output配置,将文件内容写入文件系统。

NOTE

单次构建是从上而下顺序执行的,如果开启了watch,构建完成后不会退出Webpack进程,而是监听文件内容,如果文件变化,重新回到构建阶段重新构建

每个阶段的侧重点都不同,所做的事情也不同,初始化的目的是根据用户配置创建好构建环境构建阶段主要是解析文件以及依赖的关系;最后的生成阶段重点在按照规则组装、包装模块输出可直接运行的产物包

对涉及到的对象做一下介绍:

  • Compiler:编译容器,包含一些编译环境,起到一个管理编译的作用。该对象一直存在,直到退出。
  • Compilation:单次编译过程的管理器。如watch = true时,运行过程只有一个Compiler对象,但是每次重新编译,都会创建一个新的Compilation对象。
  • Dependence:依赖对象,记录着模块间的依赖关系。比如a文件中引入了b文件,在单次编译过程中,b文件会先暂时以dependence对象的形式存在。
  • ModuleWebpack内所有资源都以Module对象的形式存在,所有关于资源的操作都是以其为基本单位的。
  • Chunk:编译完成准备输出时,会将Module按照依赖关系组装成Chunk

详细介绍

初始化阶段Init

首先是配置参数的合并:从配置文件、Shell中拿到用户指定的配置参数,之后会校验配置,之后和默认配置合并,这就得到了所有的配置参数。

然后是编译环境的初始化:在全部的配置参数完毕之后,会调用new Compiler创建Compiler对象。然后遍历用户指定的插件,执行插件的apply方法;之后加载内置插件。

NOTE

内置插件是根据配置内容动态注入的,比如:

  • EntryOptionPlugin插件用来处理entry配置

  • 根据devtool的值选择插件来处理sourceMapEvalSourceMapDevToolPlugin/SourceMapDevToolPlugin/EvalDevToolModuelPlugin

  • 注入RuntimePlugin,根据代码内容动态注入Webpack runtime代码

经过上述处理,就创建出了compiler实例,并且编译环境也初始化完成了,之后就会调用compiler.compile函数开始构建。

compiler.compile函数中,会先创建当前的compilation对象,然后经过一系列函数链路之后,执行compilation.addEntry函数,将入口文件转为dependence对象,最后会执行handleModuleCreate开始构建内容

NOTE

每次重新构建都会执行一次compile函数,所以每次构建都是新的compilation对象

compiler.compile函数中并没有什么实质的功能,但是却搭建起了后续构建的流程。

js
compile(callback) {
	const params = this.newCompilationParams();
	// 执行beforeCompile compile两个hook
	this.hooks.beforeCompile.callAsync(params, err => {
		this.hooks.compile.call(params)
		// 创建compilation对象,每次重新构建都会执行,所以compilation会被重复创建
		const compilation = this.newCompilation(params);
		// 执行make hook 初始化完成 进入构建阶段
		this.hooks.make.callAsync(compilation, err => {	
			// 执行finishMake hook 构建结束
			this.hooks.finishMake.callAsync(compilation, err => {
				process.nextTick(() => {
					compilation.finish(err => {
						// 执行seal hook 进入生成阶段
						compilation.seal(err => {
							// 执行afterCompile hook
							this.hooks.afterCompile.callAsync(compilation, err => {
								return callback(null, compilation);
							});
						});
					});
				});
			});
		});
	});
}

可以看到,通过触发各种重要的hook,完成了后续流程:

  • beforeComile(Asynchook)
  • compile(Synchook)
  • make(Asynchook):初始化完成,进入构建阶段
  • finishMake(Asynchook)
  • seal(Synchook):构建完成,进入生成阶段
  • afterCompile(Asynchook)

构建阶段Make

该阶段的目的就是从入口文件开始,通过递归对所有涉及到的资源进行解析处理。

compile函数中,通过执行make这个hook进入的构建阶段,EntryPlugin会监听该hook从而开始构建工作的

js
// EntryPlugin.js 主要逻辑
class EntryPlugin {
  apply(compiler) {
    const { entry, options, context } = this;
		// 将入口文件加入dependency 依赖对象 中
		const dep = EntryPlugin.createDependency(entry, options);

		// 监听make hook,调用addEntry方法
		compiler.hooks.make.tapAsync(
			"EntryPlugin", 
			(compilation, callback) => {
				compilation.addEntry(context, dep, options, err => {
					callback(err);
				});
		});
  }
}

所以构建阶段是通过addEntry触发的,大致过程如下:

  1. 找到入口文件,从入口文件开始,handleModuleCreate根据文件类型,调用对应的工厂函数创建出module子类
  2. 然后调用loader-runner中的runLoaders将不同类型的module转译成js格式
  3. 接着调用acorn js解释器通过词法分析、语法分析、语义分析转成AST
  4. 之后遍历AST,分析其对应的依赖(import),调用module.addDependency将依赖对象放到当前module的依赖列表中。在遍历AST时,会触发各种hook:
    • 遇到import语句时,触发exportImportSpecifierhook
    • 还会触发HarmonyExportDependencyParserPlugin,将依赖的资源添加为Dependency对象
  5. AST遍历之后,调用module.handleParseResult处理模块依赖,即将AST再转回代码。
  6. 对于新增的依赖,调用handleModuleCreate,回到第一步,开始递归构建子依赖
  7. 所有的依赖都构建完成之后,就可以得到依赖关系图

在这个过程中,要先通过loader将各种资源转成js,然后通过acorn来解析js转成AST,然后分析AST,进行递归构建。构建之后,所有的依赖都转为了Module,并且能获得Module之间的dependence依赖关系

生成阶段Seal

该阶段主要目的就是将Module按照一定规则组装成Chunk,并且将Chunk转成适合在目标环境运行的产物形态。

compile函数中,通过执行sealhook进入的该阶段。

构建完成之后,已经有了处理后的文件(Module)以及依赖之间的关系,然后就会执行compilation.seal生成最终的资源

该阶段主要的工作,就是将文件从Module按规则组装成Chunk,默认规则就是:

  1. entry以及依赖组合成一个Chunk
  2. 动态引入的组合成一个Chunk

但是默认的规则有些简单,通常是需要添加额外配置的,比如一些公共库、第三方库等是需要单独成Chunk的。

和plugin相关的钩子有:

  • seal进入生成阶段触发
  • optimizeChunks处理chunks触发,即上述的额外配置,可以通过该hook添加一些chunk的组合规则。

生成chunk之后,遍历chunks转为assets,最后将assets写入文件系统。

入口的entry和Chunk是一一对应的,Chunk和最终的产物也是一一对应的。

小结

整个流程资源形态转化是这样的:

  • 编译阶段compilation.make:将入口文件加入dependency依赖列表,然后根据文件类型调用工厂函数创建module对象,然后调用loader对内容进行处理,直到最后所有依赖都转成module
  • 生成阶段compilation.seal:该阶段按照一些规则(默认 + 自定义)将module组合成Chunk,然后将Chunk转成assets资源集合的形式
  • 输入资源阶段compilation.emitAssets:该阶段将assets资源转为文件

到这里,大致过程就描述的差不多了。不过在每个阶段都会触发一些hook钩子,用来触发Plugins,这也是Webpack的插件架构。

Plugin插件简介

Webpack的插件机制是基于tapable的,所以可以先看一下tapable描述

简单描述插件机制就是webpack的构建过程中会触发一些hook的回调,并且提供了一些上下文信息,所以插件可以通过定义这些hook的回调来做一些操作,进而达到影响构建的目的。

一个简单的插件如下:

js
class SimplePlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap('SimplePlugin', (compilation) => {
      compilation.hooks.optimizeChunkAssets.tapAsync('SimplePlugin', () => {
        // 触发的回调
      })
    })
  }
}

上一次更新: