跳到主要内容

认识

2024年03月21日
柏拉文
越努力,越幸运

一、认识


Webpack 构建工作流如下所示:

一、初始化阶段: 初始化阶段进行读取、合并用户和默认配置, 并进行预处理。然后实例化 Compiler 对象,并注册构建生命周期的钩子。再加载内置插件、注册 Loader、模块工厂、文件系统抽象和 Resolver,构建 RuleSet 集合。再加载并注册用户插件,为后续各阶段提供扩展能力。然后解析 entry 配置,构建初始的依赖关系,为后续模块编译奠定基础。

  1. 读取与解析配置, Webpack 会从多种渠道读取配置参数,包括配置文件(如 webpack.config.js)、命令行(Shell)参数以及内置的默认配置。读取到的用户配置会与默认配置进行合并,形成最终的构建参数。这一步不仅确定了入口、输出、Loader、插件等基本信息,还会设置性能、缓存、调试(如 devtool)等高级选项。

  2. 应用 WebpackOptionsApply, 在配置合并之后,Webpack 会通过类似 WebpackOptionsApply 的内部机制,将默认的内置插件(例如 DefinePlugin``、HotModuleReplacementPlugin 等)自动注入到插件链中。这一阶段还会对配置进行预处理和验证,确保所有参数格式正确,并对某些特殊配置(比如 externalsresolvemodule.rules)进行相应转换。

  3. 创建 Compiler 对象, 利用最终的配置参数,Webpack 实例化一个 Compiler 对象。Compiler 代表了整个构建过程的上下文,是构建流程的总调度者。在 Compiler 对象创建过程中,会注册大量生命周期钩子(hooks),这些钩子为插件提供了介入构建过程的能力,如 beforeRunrunwatchRun 等。

  4. 初始化编译环境, Compiler 在初始化阶段会加载 Webpack 自带的内置插件,这些插件为后续的模块解析、代码分割、优化等步骤提供基础能力。内部会构建各种模块工厂(ModuleFactory),用于后续对不同类型模块(JavaScriptJSONWASMAsset 等)的处理。同时,Loader 的注册和解析器(Resolver)的初始化也在此阶段完成,确保在构建阶段能够正确解析并转换资源。根据配置中的 module.rulesWebpack 会构造出 RuleSet 集合,用以在模块加载时确定使用哪些 Loader 及其执行顺序(包括 pitch 阶段与 normal 阶段)。Webpack 初始化阶段还会确定文件系统抽象(OutputFileSystem),在开发环境下可能使用内存文件系统以支持热更新,而在生产构建中则使用 Nodefs 模块操作磁盘。

  5. 加载并注册用户插件, 在内置插件注入之后,Webpack 会遍历用户配置中声明的插件列表,逐一加载并注册到 Compiler 上。每个插件都会订阅特定的生命周期钩子,进而在后续各阶段对构建过程进行干预和增强。插件初始化阶段可能会执行一些预备工作,如设置环境变量、收集依赖信息、调整 Loader 配置等。

  6. 确定入口, 在所有初始化工作完成后,Compiler 会根据最终配置中的 entry 配置,解析出所有入口文件。这一步通常会涉及对相对路径转绝对路径的处理,依赖于内部的 Resolver 模块(例如 enhanced-resolve)。Webpack 通过调用 compilation.addEntry(或类似 API),将入口文件封装成初始的 Dependency 对象,为后续构建 Dependency Graph 做准备。

二、构建阶段: 构建阶段的核心任务是将入口模块开始递归地将所有源文件转换成标准化的 Module 模块对象,并通过 LoaderAST 分析、依赖收集等步骤构建出完整的依赖图(Dependency Graph 依赖图)。

  1. 模块的路径解析与文件读取, 每个模块最初以相对路径的形式存在。Webpack 内部使用类似 enhanced-resolve 的机制将相对路径转换为绝对路径,这个过程可能涉及多次 IO 操作,且深层次的文件目录会影响解析效率。解析出绝对路径后,Webpack 通过底层 IO 接口读取文件内容,这一步为后续 Loader 转译提供了原始数据。在路径解析阶段,Webpack 利用 Resolver 模块(如 enhanced-resolve)确保每个模块的引用路径能正确解析到目标文件。这个过程同样会涉及插件(如 aliasextensions 配置)的介入。

  2. Loader 处理, Webpack 使用 loader-runner 来依次调用配置好的 LoaderLoader 的处理分为两个阶段: 1. Pitch 阶段, 从左到右依次执行所有 Loaderpitch 方法, 每个 Loaderpitch 可以选择提前返回结果, 若返回结果则会中断后续的 pitch 调用, 否则继续到下一个 Loader, 如果所有 Loaderpitch 均未返回数据, 则会读取原始文件内容, 进入 normal 阶段; 2. Normal 阶段, Loadernormal 方法按照从右向左的顺序执行(即倒序执行), 在该阶段, 每个 Loader 对上一步传递的内容进行转换,例如对 ES6 语法进行 Babel 转译、对 TypeScript 代码进行编译等。此过程可能非常消耗 CPU,尤其是当 Loader 数量多、转换逻辑复杂时(如使用 babel-loadereslint-loaderts-loader 等时,有时会多次生成 AST 进行校验与转换)。Webpack 5 内置了持久化缓存机制,可以将 Loader 处理、AST 解析等中间结果缓存起来,加快后续增量构建的速度。

  3. AST 解析与依赖收集, Loader 处理后得到标准的 JavaScript 内容,将传递给内置的 JS 解析器(如 acorn)生成对应的 抽象语法树(AST,解析器遍历 AST,寻找所有与模块加载相关的语句(如 importrequire、动态 import() 等)。 每个匹配的语句都将被转换为一个依赖描述,存放在模块的dependencies 数组中。此阶段也会触发诸如 exportImportSpecifier 等钩子,供相应的插件(如 HarmonyExportDependencyParserPlugin)捕捉并进一步加工依赖信息。Webpack 5 内置了持久化缓存机制,可以将 Loader 处理、AST 解析等中间结果缓存起来,加快后续增量构建的速度。

  4. 构建 DependencyModule 对象, 1. Dependency 对象, 每个依赖项会首先构建为一个 Dependency 对象,表示模块之间的引用关系。这种抽象化不仅适用于 JavaScript 模块,也适用于 CSS@import、图片、字体等其他资源引用。将依赖具象化后,构建系统能够针对依赖关系进行更精细的控制与优化,比如动态导入、代码拆分等; 2. Module 对象, 接下来,根据每个 Dependency 对象创建对应的 Module 对象,Module 表示 Loader 处理、AST 转换、优化后得到的代码单元, 每个 Module 包含最终转译后的代码、模块元数据(如标识符、缓存信息)、以及该模块与其他模块之间的依赖关系; 3. 递归构建, 对于每个新产生的依赖,Webpack 会递归执行上述流程:从路径解析、文件读取、Loader 处理到 AST 解析、依赖收集,直至所有从入口模块能触达的文件都被处理完毕。

  5. 构建依赖图(Dependency Graph, 从入口模块出发,所有处理完毕的 Module 与对应的 Dependency 被整合到一个全局的 ModuleGraph 中。ModuleGraph 记录了所有模块(Module)、依赖(Dependency)以及它们之间的引用关系,形成了一个有向图结构,即 Dependency Graph。其中, ModuleGraphConnection, 用于记录模块间的连接关系,保存父模块(originModule)与子模块(module)的关联; ModuleGraphModule, 为每个 Module 提供附加信息, 如 incomingConnections(谁引用了该模块)和 outgoingConnections(该模块引用了哪些其他模块)。内部还通过 _dependencyMap_moduleMap 建立起 Dependency 对象与对应 ModuleGraphConnectionModule 对象与 ModuleGraphModule 的映射,便于快速查找和后续优化。

  6. 完成模块编译, 在经过上面的递归处理后,所有入口及其依赖的模块均已被 Loader 转译、AST 分析并构建成 Module 对象。此时,整个构建阶段的输出包括: 1. 每个模块的转译结果, 经过 LoaderBabel 等工具处理后的标准化代码; 2. 完整的模块依赖关系图, 所有 ModuleDependency 对象构成的有向图,为后续的代码分割、优化和打包生成提供基础数据。

三、生成阶段:

  1. 合并(seal)阶段, 将 Module 按照入口和依赖关系分配到各个 Chunk。构建 ChunkGraphChunkGroup,明确模块与 Chunk 的映射和 Chunk 间的父子依赖关系。遍历 ChunkGraph 后,Webpack 调用 compilation.emitAsset 方法,将每个 Chunk 的输出规则转化为 Asset 集合。此时系统已经确定了哪些 Chunk 将被输出,每个 Chunk 内包含哪些 Module(及经过 Loader 转译后的代码)。

  2. 优化(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 中,以便于更细粒度的缓存控制和并行加载。

  3. 生成产物代码阶段, 这一阶段的目标是将经过优化的模块转译结果合并为最终的 Bundle 代码文件,过程包括多个关键步骤:

    1. 模块转译与 Code Generation, Webpack 并不是简单地将原始代码 复制-粘贴Bundle 中,而是对每个 Module 进行转译,以确保兼容性(例如转换 ES6+ 语法、处理 TypeScript、移除开发时调试信息等)。遍历 compilation.modules 数组,对每个 Module 调用其 codeGeneration 方法,生成最终的代码字符串和相关映射信息(例如 SourceMap)。

    2. 运行时依赖收集, 为保证模块在运行时能够正确加载,Webpack 需要将各个模块之间的运行时依赖收集整理: 第一遍遍历, 收集每个 Module 内部的 runtime 依赖信息; 第二遍遍历, 将同一 Chunk 下所有 Moduleruntime 依赖统一汇总到该 Chunk 内; 第三遍遍历, 对于生成的 runtime Chunk,再次收集其下所有子 Chunkruntime 依赖,并通过 runtimeRequirementInTree 钩子触发,供 RuntimePlugin 插件生成相应的 RuntimeModule 子类实例。这些 RuntimeModule 最终负责实现模块加载、缓存管理和动态加载等功能。

    3. 模块与运行时代码合并, 调用 compilation.createChunkAssets 方法,以 Chunk 为单位将所有 Module 的转译代码和生成的 RuntimeModule 合并为最终的产物框架。这一步保证了各 Chunk 内部的代码按照正确的顺序组织,确保在浏览器或其他运行环境中能够顺利执行。

  4. 写入文件系统(emitAssets)阶段, 根据 Webpack 的输出配置(如 output.pathoutput.filename 等),系统确定最终 Bundle 的输出路径和文件名。Webpack 通过内置的 OutputFileSystem(通常基于 Node.jsfs 模块,但在开发模式下可能使用内存文件系统)将最终生成的 Asset 写入到目标文件系统。在写入过程中,所有经过优化与合并的 ChunkRuntime 代码以及 SourceMap(如有配置)都会被写入磁盘,形成最终可供部署和运行的 Bundle 文件。

可以看出,Webpack 需要执行非常密集的 IOCPU 操作,计算成本高,再加上 Webpack 以及大多数组件都使用 JavaScript 编写,无法充分利用多核 CPU 能力,所以在中大型项性能通常表现较差。

Webpack 过程中, 频繁的在解析 JSAST, 例如调用 Loader 链加载文件时,遇到 babel-loadereslint-loaderts-loader 等工具时可能需要重复生成 AST; 分析模块依赖时则需要遍历 AST,执行大量运算; Seal 阶段也同样存在大量 AST 遍历,以及代码转换、优化操作。

二、问题


2.1 Webpack 构建工作流

一、初始化阶段: 1. 读取配置与合并参数, Webpack 会从配置文件、命令行等多种渠道获取配置,然后与默认配置合并,形成最终构建参数; 2. 实例化 Compiler 与注册钩子, 基于配置创建 Compiler 对象, 注册生命周期钩子,这为后续插件介入提供机会; 3. 加载内置与用户插件, 注册 LoaderResolver, 自动注入内置插件(如 DefinePluginHMR 插件等),同时注册用户插件、Loader 以及文件解析器,这一步构建了整个编译环境。4. 解析入口, 通过 Resolver 解析配置中的 entry, 确定项目的入口文件,进而为依赖图构建做准备。

二、构建阶段: 1. 模块解析与文件读取, Webpack 从入口开始递归遍历,利用 enhanced-resolve 将相对路径转换为绝对路径,然后读取文件内容; 2. Loader 处理, 通过 loader-runner 依次执行 Loader。这里分为 pitch 阶段(顺序执行)和 normal 阶段(倒序执行),实现对源文件的转译(例如 BabelTypeScript 编译等); 3. AST 分析与依赖收集, 对 Loader 处理后的代码进行 AST 解析,提取出 importrequire、动态 import 等依赖信息,将其转化为 Dependency 对象; 4. 构建依赖图, 所有经过 Loader 解析后生成的 Module 会被整合到一个依赖图中,明确模块之间的引用关系,形成完整的 ModuleGraph

三、生成阶段: 1. Chunk 划分与优化, Webpack 根据依赖图将模块划分到各个 Chunk 中,同时执行代码压缩、Tree-ShakingScope Hoisting 等优化操作; 2. 代码生成与合并, 各 Chunk 内的 Module 会通过 Code Generation 生成最终代码,并结合运行时模块(Runtime)构成完整的 Bundle; 3. 输出到文件系统, 根据 output 配置,将最终生成的 Bundle 文件写入磁盘或内存文件系统。

2.2 Webpack 做代码混淆是怎么做的?

在生产环境下,Webpack 默认使用 TerserPlugin 来对代码进行压缩。Terser 通过删除空白、注释以及无用代码,并对变量名、函数名进行 mangle(重命名) 处理,从而达到一定程度的代码混淆效果。这种方式主要侧重于减少文件体积和提高加载效率,同时也使代码阅读难度增加,但严格来说它并非专门为混淆而设计,而是作为压缩优化手段。

2.3 Webpack 压缩代码时遵循的原则?

Webpack 在压缩代码时, 主要遵循以下几个原则,以在减少文件体积的同时确保代码的功能和执行行为不受影响:

  1. 语义不变性, 压缩过程必须保证压缩前后代码的功能和逻辑一致。所有优化(如删除无用代码、内联常量、变量重命名)都必须确保不改变代码执行的结果。

  2. 删除死代码与 Tree Shaking, Webpack 会利用静态分析和模块间的依赖关系,剔除未被引用或永远不会执行的代码,减少冗余部分。这也是 Tree Shaking 的基本理念。

  3. 变量与函数名混淆(Mangle, 在压缩过程中,通过缩短变量和函数名(mangle)来减小代码体积,但同时要确保不会重命名那些有特殊含义或外部依赖的标识符(例如全局变量或第三方库暴露的接口)。

  4. 保留必要的注释, 虽然压缩时会删除大部分注释,但出于版权声明或其他法律要求,可能会配置保留部分注释,确保法律信息和关键信息不被剔除。

  5. 压缩安全性与可配置性, 压缩插件(如 TerserPlugin)提供了大量配置选项,允许开发者根据项目需求控制压缩程度(例如是否启用某些高级优化、是否对特定代码区域进行保护等),以便在优化体积的同时避免引入潜在风险。

参考资料


Webpack5核心打包原理全流程解析

「Webpack5源码」make阶段(流程图)分析