认识
一、认识
Webpack
构建工作流如下所示:
一、初始化阶段: 初始化阶段进行读取、合并用户和默认配置, 并进行预处理。然后实例化 Compiler
对象,并注册构建生命周期的钩子。再加载内置插件、注册 Loader
、模块工厂、文件系统抽象和 Resolver
,构建 RuleSet
集合。再加载并注册用户插件,为后续各阶段提供扩展能力。然后解析 entry
配置,构建初始的依赖关系,为后续模块编译奠定基础。
-
读取与解析配置,
Webpack
会从多种渠道读取配置参数,包括配置文件(如webpack.config.js
)、命令行(Shell
)参数以及内置的默认配置。读取到的用户配置会与默认配置进行合并,形成最终的构建参数。这一步不仅确定了入口、输出、Loader
、插件等基本信息,还会设置性能、缓存、调试(如devtool
)等高级选项。 -
应用
WebpackOptionsApply
, 在配置合并之后,Webpack
会通过类似WebpackOptionsApply
的内部机制,将默认的内置插件(例如DefinePlugin``、HotModuleReplacementPlugin
等)自动注入到插件链中。这一阶段还会对配置进行预处理和验证,确保所有参数格式正确,并对某些特殊配置(比如externals
、resolve
、module.rules
)进行相应转换。 -
创建
Compiler
对象, 利用最终的配置参数,Webpack
实例化一个Compiler
对象。Compiler
代表了整个构建过程的上下文,是构建流程的总调度者。在Compiler
对象创建过程中,会注册大量生命周期钩子(hooks
),这些钩子为插件提供了介入构建过程的能力,如beforeRun
、run
、watchRun
等。 -
初始化编译环境,
Compiler
在初始化阶段会加载Webpack
自带的内置插件,这些插件为后续的模块解析、代码分割、优化等步骤提供基础能力。内部会构建各种模块工厂(ModuleFactory
),用于后续对不同类型模块(JavaScript
、JSON
、WASM
、Asset
等)的处理。同时,Loader
的注册和解析器(Resolver
)的初始化也在此阶段完成,确保在构建阶段能够正确解析并转换资源。根据配置中的module.rules
,Webpack
会构造出RuleSet
集合,用以在模块加载时确定使用哪些Loader
及其执行顺序(包括pitch
阶段与normal
阶段)。Webpack
初始化阶段还会确定文件系统抽象(OutputFileSystem
),在开发环境下可能使用内存文件系统以支持热更新,而在生产构建中则使用Node
的fs
模块操作磁盘。 -
加载并注册用户插件, 在内置插件注入之后,
Webpack
会遍历用户配置中声明的插件列表,逐一加载并注册到Compiler
上。每个插件都会订阅特定的生命周期钩子,进而在后续各阶段对构建过程进行干预和增强。插件初始化阶段可能会执行一些预备工作,如设置环境变量、收集依赖信息、调整Loader
配置等。 -
确定入口, 在所有初始化工作完成后,
Compiler
会根据最终配置中的entry
配置,解析出所有入口文件。这一步通常会涉及对相对路径转绝对路径的处理,依赖于内部的Resolver
模块(例如enhanced-resolve
)。Webpack
通过调用compilation.addEntry
(或类似 API),将入口文件封装成初始的Dependency
对象,为后续构建Dependency Graph
做准备。
二、构建阶段: 构建阶段的核心任务是将入口模块开始递归地将所有源文件转换成标准化的 Module
模块对象,并通过 Loader
、AST
分析、依赖收集等步骤构建出完整的依赖图(Dependency Graph
依赖图)。
-
模块的路径解析与文件读取, 每个模块最初以相对路径的形式存在。
Webpack
内部使用类似enhanced-resolve
的机制将相对路径转换为绝对路径,这个过程可能涉及多次IO
操作,且深层次的文件目录会影响解析效率。解析出绝对路径后,Webpack
通过底层IO
接口读取文件内容,这一步为后续Loader
转译提供了原始数据。在路径解析阶段,Webpack
利用Resolver
模块(如enhanced-resolve
)确保每个模块的引用路径能正确解析到目标文件。这个过程同样会涉及插件(如alias
、extensions
配置)的介入。 -
Loader
处理,Webpack
使用loader-runner
来依次调用配置好的Loader
。Loader
的处理分为两个阶段: 1.Pitch
阶段, 从左到右依次执行所有Loader
的pitch
方法, 每个Loader
的pitch
可以选择提前返回结果, 若返回结果则会中断后续的pitch
调用, 否则继续到下一个Loader
, 如果所有Loader
的pitch
均未返回数据, 则会读取原始文件内容, 进入normal
阶段; 2.Normal
阶段,Loader
的normal
方法按照从右向左的顺序执行(即倒序执行), 在该阶段, 每个Loader
对上一步传递的内容进行转换,例如对ES6
语法进行Babel
转译、对TypeScript
代码进行编译等。此过程可能非常消耗CPU
,尤其是当Loader
数量多、转换逻辑复杂时(如使用babel-loader
、eslint-loader
、ts-loader
等时,有时会多次生成AST
进行校验与转换)。Webpack 5
内置了持久化缓存机制,可以将Loader
处理、AST
解析等中间结果缓存起来,加快后续增量构建的速度。 -
AST
解析与依赖收集,Loader
处理后得到标准的JavaScript
内容,将传递给内置的JS
解析器(如acorn
)生成对应的 抽象语法树(AST
),解析器遍历AST
,寻找所有与模块加载相关的语句(如import
、require
、动态import()
等)。 每个匹配的语句都将被转换为一个依赖描述,存放在模块的dependencies
数组中。此阶段也会触发诸如exportImportSpecifier
等钩子,供相应的插件(如HarmonyExportDependencyParserPlugin
)捕捉并进一步加工依赖信息。Webpack 5
内置了持久化缓存机制,可以将Loader
处理、AST
解析等中间结果缓存起来,加快后续增量构建的速度。 -
构建
Dependency
与Module
对象, 1.Dependency
对象, 每个依赖项会首先构建为一个Dependency
对象,表示模块之间的引用关系。这种抽象化不仅适用于JavaScript
模块,也适用于CSS
的@import
、图片、字体等其他资源引用。将依赖具象化后,构建系统能够针对依赖关系进行更精细的控制与优化,比如动态导入、代码拆分等; 2.Module
对象, 接下来,根据每个Dependency
对象创建对应的Module
对象,Module
表示Loader
处理、AST
转换、优化后得到的代码单元, 每个Module
包含最终转译后的代码、模块元数据(如标识符、缓存信息)、以及该模块与其他模块之间的依赖关系; 3. 递归构建, 对于每个新产生的依赖,Webpack
会递归执行上述流程:从路径解析、文件读取、Loader
处理到AST
解析、依赖收集,直至所有从入口模块能触达的文件都被处理完毕。 -
构建依赖图(
Dependency Graph
), 从入口模块出发,所有处理完毕的Module
与对应的Dependency
被整合到一个全局的ModuleGraph
中。ModuleGraph
记录了所有模块(Module
)、依赖(Dependency
)以及它们之间的引用关系,形成了一个有向图结构,即Dependency Graph
。其中,ModuleGraphConnection
, 用于记录模块间的连接关系,保存父模块(originModule
)与子模块(module
)的关联;ModuleGraphModule
, 为每个Module
提供附加信息, 如incomingConnections
(谁引用了该模块)和outgoingConnections
(该模块引用了哪些其他模块)。内部还通过_dependencyMap
和_moduleMap
建立起Dependency
对象与对应ModuleGraphConnection
、Module
对象与ModuleGraphModule
的映射,便于快速查找和后续优化。 -
完成模块编译, 在经过上面的递归处理后,所有入口及其依赖的模块均已被
Loader
转译、AST
分析并构建成Module
对象。此时,整个构建阶段的输出包括: 1. 每个模块的转译结果, 经过Loader
与Babel
等工具处理后的标准化代码; 2. 完整的模块依赖关系图, 所有Module
与Dependency
对象构成的有向图,为后续的代码分割、优化和打包生成提供基础数据。
三、生成阶段:
-
合并(
seal
)阶段, 将Module
按照入口和依赖关系分配到各个Chunk
。构建ChunkGraph
与ChunkGroup
,明确模块与Chunk
的映射和Chunk
间的父子依赖关系。遍历ChunkGraph
后,Webpack
调用compilation.emitAsset
方法,将每个Chunk
的输出规则转化为Asset
集合。此时系统已经确定了哪些Chunk
将被输出,每个Chunk
内包含哪些Module
(及经过Loader
转译后的代码)。 -
优化(
optimization
)阶段, 在Chunk
合并后,Webpack
会对生成的Chunk
以及其中的模块进行一系列优化操作,主要包括:-
代码压缩(
Compression
): 根据配置中指定的压缩方案(如Terser
插件),对产物代码进行压缩。这通常涉及大量AST
运算,执行效率取决于代码体积。 -
Scope Hoisting
(作用域提升): 通过ModuleConcatenationPlugin
(或内置的优化逻辑),将多个模块合并到同一个闭包中,减少模块包装函数的调用,降低运行时开销并减小打包体积。 -
Tree-Shaking
, 针对ES Module
静态分析,剔除未被引用的导出,从而有效减小最终bundle
的大小。 -
Side Effects
分析, 当package.json
中设置了sideEffects: false
(或指定了具体的副作用配置)时,Webpack
能够更精准地移除那些虽有导入但不会产生副作用的代码。 -
代码分割与缓存组(
CacheGroups
), 依据SplitChunks
配置,Webpack
将共享模块和特定路径的模块按规则分离到独立的Chunk
中,以便于更细粒度的缓存控制和并行加载。
-
-
生成产物代码阶段, 这一阶段的目标是将经过优化的模块转译结果合并为最终的 Bundle 代码文件,过程包括多个关键步骤:
-
模块转译与
Code Generation
,Webpack
并不是简单地将原始代码 复制-粘贴 到Bundle
中,而是对每个Module
进行转译,以确保兼容性(例如转换ES6+
语法、处理TypeScript
、移除开发时调试信息等)。遍历compilation.modules
数组,对每个Module
调用其codeGeneration
方法,生成最终的代码字符串和相关映射信息(例如SourceMap
)。 -
运行时依赖收集, 为保证模块在运行时能够正确加载,
Webpack
需要将各个模块之间的运行时依赖收集整理: 第一遍遍历, 收集每个Module
内部的runtime
依赖信息; 第二遍遍历, 将同一Chunk
下所有Module
的runtime
依赖统一汇总到该Chunk
内; 第三遍遍历, 对于生成的runtime Chunk
,再次收集其下所有子Chunk
的runtime
依赖,并通过runtimeRequirementInTree
钩子触发,供RuntimePlugin
插件生成相应的RuntimeModule
子类实例。这些RuntimeModule
最终负责实现模块加载、缓存管理和动态加载等功能。 -
模块与运行时代码合并, 调用
compilation.createChunkAssets
方法,以Chunk
为单位将所有Module
的转译代码和生成的RuntimeModule
合并为最终的产物框架。这一步保证了各Chunk
内部的代码按照正确的顺序组织,确保在浏览器或其他运行环境中能够顺利执行。
-
-
写入文件系统(
emitAssets
)阶段, 根据Webpack
的输出配置(如output.path
、output.filename
等),系统确定最终Bundle
的输出路径和文件名。Webpack
通过内置的OutputFileSystem
(通常基于Node.js
的fs
模块,但在开发模式下可能使用内存文件系统)将最终生成的Asset
写入到目标文件系统。在写入过程中,所有经过优化与合并的Chunk
、Runtime
代码以及SourceMap
(如有配置)都会被写入磁盘,形成最终可供部署和运行的Bundle
文件。
可以看出,Webpack
需要执行非常密集的 IO
与 CPU
操作,计算成本高,再加上 Webpack
以及大多数组件都使用 JavaScript
编写,无法充分利用多核 CPU
能力,所以在中大型项性能通常表现较差。
Webpack
过程中, 频繁的在解析 JS
为 AST
, 例如调用 Loader
链加载文件时,遇到 babel-loader
、eslint-loader
、ts-loader
等工具时可能需要重复生成 AST
; 分析模块依赖时则需要遍历 AST
,执行大量运算; Seal
阶段也同样存在大量 AST
遍历,以及代码转换、优化操作。
二、问题
2.1 Webpack 构建工作流
一、初始化阶段: 1. 读取配置与合并参数, Webpack
会从配置文件、命令行等多种渠道获取配置,然后与默认配置合并,形成最终构建参数; 2. 实例化 Compiler
与注册钩子, 基于配置创建 Compiler
对象, 注册生命周期钩子,这为后续插件介入提供机会; 3. 加载内置与用户插件, 注册 Loader
、Resolver
, 自动注入内置插件(如 DefinePlugin
、HMR
插件等),同时注册用户插件、Loader
以及文件解析器,这一步构建了整个编译环境。4. 解析入口, 通过 Resolver
解析配置中的 entry
, 确定项目的入口文件,进而为依赖图构建做准备。
二、构建阶段: 1. 模块解析与文件读取, Webpack
从入口开始递归遍历,利用 enhanced-resolve
将相对路径转换为绝对路径,然后读取文件内容; 2. Loader
处理, 通过 loader-runner
依次执行 Loader
。这里分为 pitch
阶段(顺序执行)和 normal
阶段(倒序执行),实现对源文件的转译(例如 Babel
、TypeScript
编译等); 3. AST
分析与依赖收集, 对 Loader
处理后的代码进行 AST
解析,提取出 import
、require
、动态 import
等依赖信息,将其转化为 Dependency
对象; 4. 构建依赖图, 所有经过 Loader
解析后生成的 Module
会被整合到一个依赖图中,明确模块之间的引用关系,形成完整的 ModuleGraph
。
三、生成阶段: 1. Chunk
划分与优化, Webpack
根据依赖图将模块划分到各个 Chunk
中,同时执行代码压缩、Tree-Shaking
、Scope Hoisting
等优化操作; 2. 代码生成与合并, 各 Chunk
内的 Module
会通过 Code Generation
生成最终代码,并结合运行时模块(Runtime
)构成完整的 Bundle
; 3. 输出到文件系统, 根据 output
配置,将最终生成的 Bundle
文件写入磁盘或内存文件系统。
2.2 Webpack 做代码混淆是怎么做的?
在生产环境下,Webpack
默认使用 TerserPlugin
来对代码进行压缩。Terser
通过删除空白、注释以及无用代码,并对变量名、函数名进行 mangle
(重命名) 处理,从而达到一定程度的代码混淆效果。这种方式主要侧重于减少文件体积和提高加载效率,同时也使代码阅读难度增加,但严格来说它并非专门为混淆而设计,而是作为压缩优化手段。
2.3 Webpack 压缩代码时遵循的原则?
Webpack
在压缩代码时, 主要遵循以下几个原则,以在减少文件体积的同时确保代码的功能和执行行为不受影响:
-
语义不变性, 压缩过程必须保证压缩前后代码的功能和逻辑一致。所有优化(如删除无用代码、内联常量、变量重命名)都必须确保不改变代码执行的结果。
-
删除死代码与
Tree Shaking
,Webpack
会利用静态分析和模块间的依赖关系,剔除未被引用或永远不会执行的代码,减少冗余部分。这也是Tree Shaking
的基本理念。 -
变量与函数名混淆(
Mangle
), 在压缩过程中,通过缩短变量和函数名(mangle
)来减小代码体积,但同时要确保不会重命名那些有特殊含义或外部依赖的标识符(例如全局变量或第三方库暴露的接口)。 -
保留必要的注释, 虽然压缩时会删除大部分注释,但出于版权声明或其他法律要求,可能会配置保留部分注释,确保法律信息和关键信息不被剔除。
-
压缩安全性与可配置性, 压缩插件(如
TerserPlugin
)提供了大量配置选项,允许开发者根据项目需求控制压缩程度(例如是否启用某些高级优化、是否对特定代码区域进行保护等),以便在优化体积的同时避免引入潜在风险。