Webpack5源码解读系列1 - 一文了解Webpack核心流程
什么是webpack?
Webpack
是一个现代 JavaScript 应用静态资源打包器。Webpack
打包应用时会从入口出发,根据模块依赖关系图谱图将所有模块连接起来并输出为静态资源文件。
Webpack
是JS时代的破局者。在Webpack
出现之前,前端社区陆陆续续出现多种模块化管理方案,如esm、cjs、amd、system,导致社区中存在各种模块管理方案依赖库,而不同模块管理方案不能够互相使用。Webpack
的出现打破了这个局面,Webpack
能够识别转译所有模块管理方案,从而屏蔽模块管理方案差距,做到应用层无感知。
Webpack在设计上大量使用了事件钩子解耦模块,其中编译器(Compiler)通过提供插件机制将Webpack核心能力“外包出去”,使得Webpack具有较高的能力可拓展性,Webpack在编译过程中通过抛出事件通知插件完成编译任务。
核心概念
Compiler & Compilation
一句话介绍Compiler
和Compilation
:Compiler
是Webpack
运行的引擎,应用运行时常驻,内部每次构建任务都会由一个Compilation
实例完成,下面统一将上Compiler
、Compilation
称为「编译器。
Compiler对于使用者来说相对会比较熟悉,使用Node脚本启动Webpack
时,可以先获取Compiler实例在根据场景启动编译器编译。
const webpack = require('webpack');
const config = require('./webpack.config');
// 1. 获取编译器实例
const compiler = webpack(config);
// 2. 执行编译操作
compiler.run((err, stats) => {
if (err) {
console.error(err);
}
// ...code
compiler.close((closeErr) => {
// ...code
});
});
// or 3. 开启watch
const watching = compiler.watch(
{
aggregateTimeout: 300,
poll: undefined,
},
(err, stats) => {
// Print watch/build result here...
console.log(stats);
}
);
Plugin
插件(即plug-in)指一种满足特定接口协议且能够扩充应用能力程序。对于插件而言没有主程序时无法正常运行,而主程序无需插件可正常运行,所以插件是一种可插拔、对主流程无影响的应用。
Webpack
通过提供基于Tapable的插件机制提高编译器的灵活性,插件内部可在Compiler
和Compilation
实例上注册钩子。构建任务进行过程中会不断抛出事件钩子,插件通过订阅这些钩子丰富Webpack
能力。
Loader
模块预处理器(即Loader
)为Webpack
提供了处理各种各样文件的能力。如果没有Loader
,那么Webpack
只能处理语法解析器支持的语法,而Loader
的存在能够让Webpack
支持JSX语法、css拓展语法、甚至是图片、文本等非代码文件。
Module
在模块化编程中,开发者会将功能拆分成不同模块,以此方便单元测试和代码解耦,所以模块化可以理解为代码单元。Webpack
将所有的文件都视为Module
,Module
是项目文件在内存中的映射。
Chunk
Chunk
是Module
代码的封装,在代码生成阶段会将模块代码塞入Chunk
中,并最终输出为产物文件。
Source
资源文件的抽象,具备各种字符串操作方法。内容可以是源代码,也可以是产物代码,在不同模块之间流通。
核心构建流程
配置处理
Webpack
内部高度插件化,通过插件将功能模块“外包”出去。Webpack
大多数配置项逻辑都会交由插件实现,具体体现为配置项在初始化时会转为一个个插件,如下面的配置:
module.exports = {
// 1. 使用EntryPlugin
entry: path.resolve(__dirname, 'src', 'index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
},
// 2. 使用ExternalsPlugin
externals: {
react: 'react'
},
resolve: {
// 3. 应用于一些插件的入参,如DllReferencePlugin的入参
extensions: [
'.ts',
'.tsx',
'.js',
'.json'
],
},
// 4. 根据配置项转为EvalDevToolModulePlugin或EvalSourceMapDevToolPlugin等插件
devtool: false,
}
配置项在初始化时处理为插件,插件通过监听Compiler
或 Compilation
钩子事件从而内部闭环完成逻辑,这样做能够较大程度降低Webpack
模块耦合度。这样做的好处在于新增或修改与主流程无关的能力时,无需修改核心代码,降低了维护成本。
构建模块依赖拓扑图
概念
项目文件在文件系统中,Webpack
不能够直接分析文件,而是需要将文件映射到内存中才能够分析模块依赖拓扑。文件在磁盘中以有向图的形式存在,并且大多数情况下是成环图(存在循环依赖),应用文件在内存中映射为有成环向图:
在介绍构建过程之前,需要引入两个概念:Dependency
和ModuleGraph
。
Dependency
Webpack
官网描述到一个模块依赖于另外一个模块称为Dependency
,在Webpack
内部,模块引用另外一个模块内容,会处理为Dependency
:
import "./index.less";
import { Home } from './App';
import Index from './Index';
上面模块引用表达式会解析为多个Dependency
,每个Dependency
都有具体模块导入信息:
事实上,除了模块导入语句会被处理为Dependency
,模块导出语句、其他Webpack
专属语法都会处理为Dependency
,后续代码生成时会根据这些Dependency
做代码转译。不过在模块拓扑图构建阶段我们暂时可以认为 Dependency
等同于模块引用。
-
ModuleGraph
-
ModuleGraph
是项目文件引用拓扑图在内存中的映射,内部由ModuleGraphModule
和ModuleGraphConnection
组成: ModuleGraphModule
:Graph的节点,是模块在ModuleGraph
的映射;ModuleGraphConnection
:Graph的边,是Dependency
在ModuleGraph
的映射。
-
构建过程
单个模块构建
开始进入构建时,编译器抛出事件,EntryPlugin
根据入口配置往Compilation
注册入口Dependency
,Compilation
以此Dependency
为起点进入构建阶段,每个模块构建都会经过四个阶段:
- 实例化模块
Dependency
是模块的前身,如果用网络请求来形容的话,那么Dependency
则是描述请求信息,其中Dependency
的request
字段相当于请求的URL,用于描述资源位置,request
可以按照Loader
规范添加参数,参数不同时编译器会视为不同资源,从而构建出不同实例。如下面两个例子:
// 不使用loader处理文件
import './index.css';
// 使用loader处理文件
import '!style-loader!css-loader?modules!./index.css';
由于使用Loader
会改变文件内容,所以使用不同Loader
会造成文件内容不一致,故编译器将上面两个请求链接视为不同的资源,所以会产生两个模块实例,注意,此时并不会读取模块内容。
- 添加模块
模块创建完毕之后,需要根据引用关系逐步构建ModuleGraph
,所以本阶段会模块存放到ModuleGraph
中并建立父子关系。
- 构建模块
当所有工作准备就绪时,进入到模块构建阶段,构建阶段需要读取文件内容,并对文件内容做处理。Webpack使用Source
对文件内容做一层抽象,Source
不仅仅用于文件内容读取,在代码生成时也会使用并最终将文件内容提交出去。
实例化模块过程中会经历模块预处理和代码解析,前者会将文件处理为符合JavaScript语法的代码,后者会使用JavaScript语法解析器解析代码并抛出事件,由各个语法插件根据解析事件创建Dependency
。
- 处理模块依赖
模块构建完成时,会根据模块导入语法产生Dependency
,此时需要处理Dependency
,做去重操作并进入下一轮模块构建。
整体构建流程
每个模块构建时都会经历四个步骤,每个步骤内部都含有异步操作,如果按照普通的DFS或者BFS算法去遍历,那么会因为异步等待时间过多而导致构建性能下降。所以编译器使用异步执行队列提高多个异步任务并发执行速率。
编译器一共提供了四个队列,这四个队列分别对应模块的四个构建过程,并且任务队列之间存在优先级别,优先级别从高到低分别是模块实例化队列、模块添加队列、模块构建队列、依赖处理队列
编译器使用四条异步队列的原因有两个:
- 提高性能:相比于只使用一条异步队列而言,四条队列根据构建阶段细分队列,从而提高模块构建速率;
- 多编译器协同:在未来可能会有多个编译器协同工作,通过拦截这些队列来完成作业编排。
当所有模块都构建完毕时,编译器会得到一份项目模块引用关系在内存中的映射,存放于ModuleGraph
:
分析产物组织形式
分包规则
Web应用代码体积会影响到应用的加载速度,大型项目中往往会使用到代码异步加载能力,如Vue或React的路由懒加载能力、或直接使用import()
语句异步加载模块,这些能力都是基于Webpack
的代码分割功能实现。代码分割功能本质上是项目产物代码拆分成多个文件(下面称为分包),并在运行时按需下载文件,从而提高应用加载速度。
Webpack
一共有三种文件拆分类型:
- 入口分包
Webpack
每个入口配置在编译结束时会产生单独文件,如下面配置后,代码生成时会产生entry-1.js
和entry-2.js
文件:
module.exports = {
entry: {
entry1: {
import: 'A.js',
},
entry2: {
import: 'B.js',
}
}
}
- 异步分包
在代码中使用require.ensure('xxx')
或者import('xxx')
模块都能够使用代码分割能力,代码分割能力底层都是将目标模块代码从本包中抽离出来,单独生成一个代码包,在运行时通过异步加载代码方式实现。
import './Home.js';
// 使用异步加载能力,会将 target.js模块从本模块所属chunk独立出去
import('./target.js');
- Runtime分包
默认地,Webpack
应用运行时代码都会放到应用入口Chunk
上,这是为了能够让所有代码都能够使用运行时能力。如果一个项目存在多个入口,那么可以选择将Runtime
相关能力独立出来,减少代码冗余,下面编译会产出runtime.js
文件存储Runtime
能力:
module.exports = {
entry: {
entry1: {
import: 'A.js',
runtime: 'runtime'
},
entry2: {
import: 'B.js',
runtime: 'runtime',
}
}
}
模块分包处理
模块分包
经过分包规则处理后编译器会为每个分包创建一个ChunkGroup
实例,ChunkGroup
可以理解为 Chunk
的集合并存在父子引用关系,用于描述 Chunk
引用关系,ChunkGroup
包含分包涉及到的所有模块,在代码生成阶段将模块分到Chunk
中用于文件生成。
如下面例子中使用异步导入语法将模块进行分包,使用ChunkGroup
分析引用关系,可以看到ChunkGroup-1
会加载ChunkGroup-2
,那么在打包产物文件会输出为两个文件Chunk-1
和Chunk-2
,且Chunk-1
内部会加载Chunk-2
文件:
上面模块引用关系比较简单,很容易进行分包,但实际项目模块模块引用往往很复杂,很容易出现同一个模块在不同分包内使用,如下面例子:
上面例子根据异步分包规则会产生三个ChunkGroup
,此时会发现出现了冗余模块,它们分别是B
和G
模块,冗余模块会导致两个问题:
- 打包产物体积增大,影响代码下载速率;
- 如果冗余模块中存在副作用,那么产生一些意料之外的bug;
所以在模块分包完毕之后,编译器需要通过分析ChunkGroup
引用关系去除冗余模块。
剔除冗余模块
经过模块分包之后ChunkGroup
以有向无环图(DAG)形式存在,比如:
上面ChunkGroup
组成的DAG代表着产物代码包的加载顺序,该DAG的两个拓扑排序代表着两种代码包加载的可能性,分别是:
1 -> 2 -> 4 -> 5
1 -> 3 -> 4 -> 5
拓扑排序代表着运行时代码某个场景下的加载顺序,拓扑排序中靠前代码包模块会比靠后代码包模块先加载,不过这并不意味着靠后ChunkGroup
模块能够直接使用靠前的ChunkGroup
模块,这里分为两种情况:
ChunkGroup
为DAG必经节点,那么可以直接使用,如ChunkGroup-2
能够使用ChunkGroup-1
的模块。
ChunkGroup
为可选节点,那么可访问模块为可选节点模块的交集,如ChunkGroup-4
可访问模块为ChunkGroup-2
和ChunkGroup-3
的模块交集以及ChunkGroup-1
模块。
我们以前面例子剔除冗余模块,可以得到以下结果:
在上面例子中需要特别注意:因为 ChunkGroup-2
和 ChunkGroup-3
之间无法确定加载顺序(它们不在同一个拓扑排序中),所以 G
模块是需要出现冗余以保证应用正常运行。
编译器基于ModuleGraph
和分包规则分析生成ChunkGroup
,之后经过剔除冗余模块后,建立了描述产物文件引用图ChunkGraph
,用于后续代码生成使用:
模块代码转译及输出文件封装
运行时能力
在讲解模块转译前,需要介绍Webpack
运行时(即Runtime
)能力,Runtime
可以理解为应用在浏览器运行的基石,如果将应用比喻成一座建筑,那么Runtime
则是这座建筑的地基。Runtime
不仅仅包括模块管理、代码异步加载等基础能力,还可以包括Webpack
一些特性能力,如HMR、Module Ferdaration以及未来可能新增的能力。下面是Webpack
编译产物,黄色区域为Runtime
的模块管理部分代码:
Webpack
将Runtime
按能力拆分处理为多个RuntimeModule
,RuntimeModule
可以理解为Webpack
官方依赖库,编译后会注入到产物中,提供应用运行基础能力。
模块代码转译
转译原理
模块代码转译目的是将特定语法转为符合Runtime
要求的语法,这是Webpack能够处理兼容所有模块语法的实现原理。模块代码转译分为两个阶段:搜集阶段和转译阶段
- 搜集阶段
搜集阶段发生在模块构建时,模块构建时使用JavaScript Parser解析代码文件并抛出遍历AST节点的事件,此时插件通过监听Parser事件并生成Dependency
实例记录语法以及代码位置。(这里Dependency
实际上是各个语法基于Dependency
实现类,这里为了方便统一叫做Dependency
)。
- 转译阶段
每个Dependency
都有对应的Template
工具类负责将Dependency
解释为特定代码。在代码转译阶段,会取出模块所有Dependency
,并由对应Template
负责转译Dependency
并输出Source
,由此完成模块转译。
这里的Dependency
、Template
、Plugin
三者配合起来能够转译任何模块语法,如HMR
能力中,使用HotModuleReplacePlugin
+ ModuleHotAcceptDependency
+ ModuleHotAcceptDependency.Template
处理热更新的特殊语法module.hot.accept
。
模块代码生成流程
应用中会经历两次模块代码生成任务,目的各有不同:
- 第一次模块代码生成
第一次代码生成目的是获取模块使用到的Runtime能力。编译器根据ChunkGraph
获取到项目所有文件模块(NormalModule
),此时根据Dependency.Template
进行模块代码转译,在转译过程中获取语法所需的Runtime
能力,将其注入到全局能力集合中,后续会根据该集合生成RuntimeModule
。
- 第二次模块代码生成
第二次代码会将NormalModule
和RuntimeModule
一起做代码生成,并最终交由Chunk进行封装。
下面是完整的模块代码生成流程:
Chunk文件封装
模块代码生成完毕后不能直接运行,而是需要将模块代码封装到Chunk
中并生成Chunk
代码内容,进而保证代码能够在浏览器中正常运行。Chunk
文件有多种类型,如js、css、json类型文件,这些文件的封装是由各个插件实现,如JavaScriptModulePlugin
、CssModulePlugin
、JSONModulePlugin
等插件提供各种语言文件生成逻辑,与模块代码生成相似,Chunk
代码生成结果也是Source
:
下面以JavaScript文件生成为例子讲解Chunk
代码生成流程。在分析产物组织形式阶段分析Chunk
包含文件以及Chunk
的引用关系,并输出ChunkGraph
,Chunk
代码生成在ChunkGraph
基础上做代码生成工作。首先Chunk
会分为两种类型:含Runtime
和不含Runtime
的Chunk
含有Runtime
的Chunk
:代码分为三部分:
Chunk
容器代码,创建作用域并提供存放模块代码Runtime
代码:根据模块代码时获取所有RuntimeModule
生成的代码- 模块代码:
NormalModule
生成的代码
-
不含
Runtime
的Chunk
:将本Chunk
包含所有NormalModule
代码生成存放于容器中即可
由于Runtime
能力全局只需要一份,且需要所有代码均能够访问到Runtime
能力,所以一般情况下都会将代码生成在入口Chunk
文件中,除非有特殊说明需要将Runtime
抽离出去。编译器通过ChunkGraph
为各个Chunk
生成代码之后,将产物以Source
形式提交出去。
ChunkGraph
经过代码生成后,会转为一个个描述Chunk
的Source
:
之后编译器将Source
逐个输出为文件,至此,Webpack
完成编译任务。
小结
本文浅介绍了Webpack
核心工作流程,Webpack
核心构建流程有三个阶段,分别是构建模块依赖拓扑图、分析产物组织形式、模块转译及模块封装。
- 构建模块依赖拓扑图
Webpack
无法直接在文件系统上分析文件拓扑,需要将应用文件从文件系统上映射到内存中,用于后续分析。应用文件从文件系统映射到磁盘过程中使用了Loader
机制将非JavaScript语法的文件转为JavaScript代码,这是Webpack
能够识别所有文件底层实现。
- 分析产物组织形式
构建完ModuleGraph
后,Webpack
需要确定产物文件组织形式。本阶段Webpack
根据分包规则将模块分为多个代码包并将它们的引用关系记录到ChunkGraph
中,用于后续代码生成。
3. 模块代码转译及输出文件封装
ChunkGraph
生成完毕后,已经确定了产物输出形式,接下来进行模块代码生成和输出文件封装:
- 模块代码生成:转译模块语法,如将ES6模块、CommonJS、AMD等语法转译为符合Webpack Runtime语法。
- 输出文件封装:根据
ChunkGraph
将模块装入Chunk
转载自:https://juejin.cn/post/7231804508081258555