认识
一、认识
Webpack
工作流如下所示:
-
初始化阶段:
-
初始化参数: 从配置文件、 配置对象、
Shell
参数中读取,与默认配置结合得出最终的参数 -
创建编译器对象: 用上一步得到的参数创建
Compiler
对象 -
初始化编译环境: 包括注入内置插件、注册各种模块工厂、初始化
RuleSet
集合、加载配置的插件等 -
开始编译: 执行
compiler
对象的run
方法,创建Compilation
对象 -
确定入口: 根据配置中的
entry
找出所有的入口文件,调用compilation.addEntry
将入口文件转换为dependence
对象。
-
-
构建阶段:
-
编译模块(
make
): 从entry
模块开始,通过IO
接口读取文件内容,之后调用LoaderRunner
并将文件内容以source
参数形式传递到Loader
数组,source
数据在Loader
数组内首先经过pitch
阶段读取资源文件内容再经过normal
阶段处理资源文件内容转换为标准Js
内容,调用JS
解析器acorn
将内容转换为AST
对象,遍历AST
, 找出import
或者require
等模块导入语句, 收集模块依赖数组dependencies
, 遍历dependencies
数组创建Dependency
对象, 并将Dependency
转换为Module
对象, 递归处理这些依赖模块,直到所有入口依赖的文件都经过了本步骤的处理。这个过程从entry
模块开始, 逐步递归找出所有依赖文件, 模块之间隐式形成了以entry
为起点, 以模块为节点, 以导入导出依赖为边的有向图关系Dependency Graph
。-
相对路径转为绝对路径: 首先需要将文件的相对引用路径转换为绝对路径, 这个过程可能涉及多次
IO
操作,执行效率取决于 文件层次深度 -
调用
loader-runner
遍历loader
完成内容转译: 找到具体文件后,需要读入文件内容并调用loader-runner
遍历Loader
数组完成内容转译,这个过程需要执行较密集的CPU
操作,执行效率取决于Loader
的数量与复杂度。另外, 遇到babel-loader
、eslint-loader
、ts-loader
等工具时可能需要重复生成AST
-
将模块编译为
AST
, 并遍历AST
找出模块的依赖资源: 需要将模块内容解析为AST
结构,并遍历AST
找出模块的依赖资源,这个过程同样需要较密集的CPU
操作,执行效率取决于 代码复杂度 -
递归处理依赖资源, 执行效率取决于模块数量
-
遍历
AST
时, 触发各种钩子, 比如遇到import
语句时, 触发exportImportSpecifier
钩子,HarmonyExportDependencyParserPlugin
监听该钩子,将依赖资源添加为Dependency
对象, 调用module
对象的addDependency
, 将Dependency
对象转换为Module
对象并添加到依赖数组中 -
AST
遍历完毕后,调用module.handleParseResult
处理模块依赖数组 -
对于
module
新增的依赖,调用handleModuleCreate
,控制流回到第一步; 所有依赖都解析完毕后,构建阶段结束 -
在构建阶段,为什么需要先将依赖文件构建为
Dependency
,之后再根据Dependency
创建文件对应的Module
对象?Dependency
对象到底有什么作用? 答: 将源文件转换为依赖Dependency
对象, 再基于这些依赖创建模块Module
对象, 可以解决代码模块化的构建的复杂性, 优化构建输出, 提供灵活的代码分割和加载策略。-
Dependency
:Dependency
对象是源文件之间联系的抽象表示。每一个Dependency
实例代表了一个文件对另一个文件的依赖,这种依赖不仅限于JavaScript
代码模块间的import/require
语句,也包括CSS
中的@import
,图片、字体文件的引用等。通过将依赖具体化为对象,构建系统能够更精细地控制和管理每个依赖项。例如,它可以分析依赖关系的类型(例如,是否是动态导入),依赖的加载优先级,以及是否需要将依赖项分割到不同的bundle
中。将依赖关系具象化为对象,使得构建工具可以在不直接操作源代码的情况下,对依赖关系进行解析、修改和优化。例如,通过修改Dependency
对象,构建工具可以实现代码拆分、懒加载等高级功能。 -
Module
:Module
对象代表了从一个入口依赖及其所有依赖文件构建出的最终代码块。每个Module
包含了处理后的代码、模块的依赖关系、以及模块的元数据等信息。在创建Module
对象的过程中,源代码会被加载、转换(例如,通过Babel进行语法转换)和优化(如压缩)。这一步是实现代码转换和优化策略的关键环节。基于模块和依赖关系的分析,构建工具可以决定如何分割代码以支持代码分割和动态加载,优化应用的加载时间和性能。
-
-
这个过程从
entry
模块开始, 逐步递归找出所有依赖文件, 模块之间隐式形成了以entry
为起点, 以模块为节点, 以导入导出依赖为边的有向图关系Dependency Graph
。Dependency Graph
涉及如下数据类型:-
ModuleGraph
: 记录Dependency Graph
信息的容器,记录构建过程中涉及到的所有module
、dependency
对象,以及这些对象互相之间的引用 -
ModuleGraphConnection
: 记录模块间引用关系的数据结构,内部通过originModule
属性记录引用关系中的父模块,通过module
属性记录子模块 -
ModuleGraphModule
:Module
对象在Dependency Graph
体系下的补充信息,包含模块对象的incomingConnections
—— 指向模块本身的ModuleGraphConnection
集合,即谁引用了模块自身;outgoingConnections
—— 该模块对外的依赖,即该模块引用了其他那些模块。 -
这些类型之间关系的基本逻辑是:
Compilation
类内部会维护一个全局唯一的ModuleGraph
实例对象, 每次解析出新模块后,将Module
、Dependency
,以及模块之间的关系 ——ModuleConnection
记录到compilation.moduleGraph
对象中。ModuleGraph
除了记录依赖关系外,还提供了许多工具方法,方便使用者迅速读取出module
或dependency
附加的信息。ModuleGraph
内部有两个关键属性: 通过_dependencyMap
属性记录Dependency
对象与ModuleGraphConnection
连接对象之间的映射关系,后续的处理中可以基于这层映射迅速找到Dependency
实例对应的引用与被引用者; 通过_moduleMap
属性记录Module
与ModuleGraphModule
之间的映射关系。
-
-
-
完成模块编译: 上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的模块依赖关系图。
-
-
生成阶段:
-
合并(
seal
): 遍历module
集合(模块依赖图),根据entry
配置及引入资源的方式,将module
分配到不同的Chunk
, 并将 Chunk 之间的父子依赖关系梳理成ChunkGraph
与若干ChunkGroup
对象。遍历ChunkGraph
,调用compilation.emitAsset
方法标记chunk
的输出规则,即转化为assets
集合。此时,Webpack
可以分析出需要输出哪些Chunk
, 每个Chunk
包含那些Module
,以及每个Module
经过Loader
翻译后的代码内容,Chunk
与Chunk
之间的父子依赖关系ChunkGroup
: 专门实现关系链管理,配合SplitChunksPlugin
能够更高效、智能地实现启发式分包。比如, 解决默认分包规则最大的问题是无法解决模块重复,如果多个Chunk
同时包含同一个Module
,那么这个Module
会被不受限制地重复打包进这些Chunk
。
-
优化(
optimization
): 对上述Chunk
施加一系列优化操作,包括:tree-shaking
、terser
、scope-hoisting
、压缩、Code Split
等-
压缩: 根据
optimization
配置执行一系列产物优化操作,特别是Terser
插件需要执行大量AST
相关的运算,执行效率取决于 产物代码量 -
Code Split
: 根据splitChunks
配置、entry
配置、动态模块引用语句等,确定模块与Chunk
的映射关系,其中splitChunks
相关的分包算法非常复杂,涉及大量CPU
计算
-
-
生成产物代码: 将所有
Module
内容一一转换为适当的产物代码形态,并以Chunk
为单位合并Module
产物代码,之后根据Module
中出现的特性依赖,补充相应运行时代码,最终构建出我们日常所见的Webpack Bundle
代码文件。-
什么是模块转译:
Webpack
的打包功能并不是将原始文件代码复制-粘贴到产物文件那么简单,为了确保代码能在不同环境 ——多种版本的浏览器、Node
、Electron
等正常运行,构建时需要对模块源码适当做一些转换操作。 -
单模块转译: 这一步主要用于计算模块实际输出代码,遍历
compilation.modules
数组,调用module
对象的codeGeneration
方法,执行模块转译计算 -
收集运行时依赖: 第一次循环遍历所有
module
, 收集所有module
的runtime
依赖; 第二次循环遍历所有chunk
,将chunk
下所有module
的runtime
统一收录到chunk
中; 第三次循环遍历所有runtime chunk
,收集其对应的子chunk
下所有runtime
依赖,之后遍历所有依赖并发布runtimeRequirementInTree
钩子,(主要是)RuntimePlugin
插件订阅该钩子并根据依赖类型创建对应的RuntimeModule
子类实例。 -
模块合并: 调用
compilation.createChunkAssets
方法,以Chunk
为单位,将相应的所有module
及runtimeModule
按规则塞进产物框架中,最终合并输出成完整的Bundle
文件
-
-
写入文件系统(
emitAssets
): 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
-
可以看出,Webpack
需要执行非常密集的 IO
与 CPU
操作,计算成本高,再加上 Webpack
以及大多数组件都使用 JavaScript
编写,无法充分利用多核 CPU
能力,所以在中大型项性能通常表现较差。
Webpack
过程中, 频繁的在解析 JS
为 AST
, 例如调用 Loader
链加载文件时,遇到 babel-loader
、eslint-loader
、ts-loader
等工具时可能需要重复生成 AST
; 分析模块依赖时则需要遍历 AST
,执行大量运算; Seal
阶段也同样存在大量 AST
遍历,以及代码转换、优化操作。
二、问题
2.1 Vite、EsBuild、Rollup、Webpack 各自优势、特点?
Vite
是一个前端开发与构建工具。采用双引擎架构, 开发阶段使用 Esbuild
+ no-bundle
服务,生产环境用 Rollup
编译构建。Vite
在开发阶段, Vite
项目的启动可以分为两步。第一步是依赖预构建,借助 Esbuild
超快的编译速度来做第三方库构建和 TS/JSX
语法编译, 第二步是 Dev Server
的启动, 基于浏览器原生 ESModule
的支持实现了 no-bundle
服务,实现开发阶段的 Dev Server
, 进行模块的按需加载, 可以直接在浏览器中运行源码,无需事先打包。每一个文件请求进来都会经历一系列的编译流程,然后 Vite
会将编译结果响应给浏览器。Vite
生产环境借助 Rollup
, 从 AST
解析的功能开始,完成代码的词法分析(tokenize
)和语义分析(parse
),实现模块依赖图和作用域链的搭建,并完成 Tree Shaking
、循环依赖检测及 Bundle
代码生成
EsBuild
是基于 Golang
开发的一款打包工具,相比传统的打包工具,主打性能优势,在构建速度上可以比传统工具快 10~100
倍, 可以将其他格式的模块转化为 EsModule
Rollup
是一款基于 ES Module
模块规范实现的 JavaScript
打包工具, 并且 Rollup
具有天然的 Tree Shaking
功能,可以分析出未使用到的模块并自动擦除。Rollup
可以直接处理 Es Modules
, 对于 CommonJs
需要通过插件来转换。
Webpack
: 同样作为前端开发与构建工具, 开发与生产环境打包逻辑相同, 从所有入口开始, 递归处理所有依赖, 会对所有模块进行打包操作。生成最终代码前, 根据模块中出现的特性依赖,补充相应运行时代码, 比如立即表达式 IIFE
、Webpack
runtime
运行时代码, 生成最终产物。Webpack
实现了一套自己的 CommonJS
规范, 在 Webpack
中, 每个模块都被包装在一个函数中, 这个函数接受一个对象, 这个对象有 exports
、require
、module
等属性, 通过这种方式实现了模块的隔离, 每个模块都有自己的作用域, 不会污染全局作用域。