跳到主要内容

认识

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

一、认识


Webpack 工作流如下所示:

  1. 初始化阶段:

    1. 初始化参数: 从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数

    2. 创建编译器对象: 用上一步得到的参数创建 Compiler 对象

    3. 初始化编译环境: 包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等

    4. 开始编译: 执行 compiler 对象的 run 方法,创建 Compilation 对象

    5. 确定入口: 根据配置中的 entry 找出所有的入口文件,调用 compilation.addEntry 将入口文件转换为 dependence 对象。

  2. 构建阶段:

    1. 编译模块(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

      1. 相对路径转为绝对路径: 首先需要将文件的相对引用路径转换为绝对路径, 这个过程可能涉及多次 IO 操作,执行效率取决于 文件层次深度

      2. 调用 loader-runner 遍历 loader 完成内容转译: 找到具体文件后,需要读入文件内容并调用 loader-runner 遍历 Loader 数组完成内容转译,这个过程需要执行较密集的 CPU 操作,执行效率取决于 Loader 的数量与复杂度。另外, 遇到 babel-loadereslint-loaderts-loader 等工具时可能需要重复生成 AST

      3. 将模块编译为 AST, 并遍历 AST 找出模块的依赖资源: 需要将模块内容解析为 AST 结构,并遍历 AST 找出模块的依赖资源,这个过程同样需要较密集的 CPU 操作,执行效率取决于 代码复杂度

      4. 递归处理依赖资源, 执行效率取决于模块数量

      5. 遍历 AST 时, 触发各种钩子, 比如遇到 import 语句时, 触发 exportImportSpecifier 钩子, HarmonyExportDependencyParserPlugin 监听该钩子,将依赖资源添加为 Dependency 对象, 调用 module 对象的 addDependency, 将 Dependency 对象转换为 Module 对象并添加到依赖数组中

      6. AST 遍历完毕后,调用 module.handleParseResult 处理模块依赖数组

      7. 对于 module 新增的依赖,调用 handleModuleCreate,控制流回到第一步; 所有依赖都解析完毕后,构建阶段结束

      8. 构建阶段,为什么需要先将依赖文件构建为 Dependency,之后再根据 Dependency 创建文件对应的 Module 对象?Dependency 对象到底有什么作用? 答: 将源文件转换为依赖 Dependency 对象, 再基于这些依赖创建模块 Module 对象, 可以解决代码模块化的构建的复杂性, 优化构建输出, 提供灵活的代码分割和加载策略。

        • Dependency: Dependency对象是源文件之间联系的抽象表示。每一个Dependency实例代表了一个文件对另一个文件的依赖,这种依赖不仅限于JavaScript代码模块间的import/require语句,也包括CSS中的@import,图片、字体文件的引用等。通过将依赖具体化为对象,构建系统能够更精细地控制和管理每个依赖项。例如,它可以分析依赖关系的类型(例如,是否是动态导入),依赖的加载优先级,以及是否需要将依赖项分割到不同的bundle中。将依赖关系具象化为对象,使得构建工具可以在不直接操作源代码的情况下,对依赖关系进行解析、修改和优化。例如,通过修改Dependency对象,构建工具可以实现代码拆分、懒加载等高级功能。

        • Module: Module对象代表了从一个入口依赖及其所有依赖文件构建出的最终代码块。每个Module包含了处理后的代码、模块的依赖关系、以及模块的元数据等信息。在创建Module对象的过程中,源代码会被加载、转换(例如,通过Babel进行语法转换)和优化(如压缩)。这一步是实现代码转换和优化策略的关键环节。基于模块和依赖关系的分析,构建工具可以决定如何分割代码以支持代码分割和动态加载,优化应用的加载时间和性能。

      9. 这个过程从 entry 模块开始, 逐步递归找出所有依赖文件, 模块之间隐式形成了以 entry 为起点, 以模块为节点, 以导入导出依赖为边的有向图关系 Dependency GraphDependency Graph 涉及如下数据类型:

        • ModuleGraph: 记录 Dependency Graph 信息的容器,记录构建过程中涉及到的所有 moduledependency 对象,以及这些对象互相之间的引用

        • ModuleGraphConnection: 记录模块间引用关系的数据结构,内部通过 originModule 属性记录引用关系中的父模块,通过 module 属性记录子模块

        • ModuleGraphModule: Module 对象在 Dependency Graph 体系下的补充信息,包含模块对象的 incomingConnections —— 指向模块本身的 ModuleGraphConnection 集合,即谁引用了模块自身;outgoingConnections —— 该模块对外的依赖,即该模块引用了其他那些模块。

        • 这些类型之间关系的基本逻辑是: Compilation 类内部会维护一个全局唯一的 ModuleGraph 实例对象, 每次解析出新模块后,将 ModuleDependency,以及模块之间的关系 —— ModuleConnection 记录到 compilation.moduleGraph 对象中。ModuleGraph 除了记录依赖关系外,还提供了许多工具方法,方便使用者迅速读取出 moduledependency 附加的信息。ModuleGraph 内部有两个关键属性: 通过 _dependencyMap 属性记录 Dependency 对象与 ModuleGraphConnection 连接对象之间的映射关系,后续的处理中可以基于这层映射迅速找到 Dependency 实例对应的引用与被引用者; 通过 _moduleMap 属性记录 ModuleModuleGraphModule 之间的映射关系。

    2. 完成模块编译: 上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的模块依赖关系图

  3. 生成阶段:

    1. 合并(seal): 遍历 module 集合(模块依赖图),根据 entry 配置及引入资源的方式,将 module 分配到不同的 Chunk, 并将 Chunk 之间的父子依赖关系梳理成 ChunkGraph 与若干 ChunkGroup 对象。遍历 ChunkGraph,调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合。此时, Webpack 可以分析出需要输出哪些 Chunk, 每个 Chunk 包含那些 Module,以及每个 Module 经过 Loader 翻译后的代码内容, ChunkChunk 之间的父子依赖关系

      • ChunkGroup: 专门实现关系链管理,配合 SplitChunksPlugin 能够更高效、智能地实现启发式分包。比如, 解决默认分包规则最大的问题是无法解决模块重复,如果多个 Chunk 同时包含同一个 Module,那么这个 Module 会被不受限制地重复打包进这些 Chunk
    2. 优化(optimization): 对上述 Chunk 施加一系列优化操作,包括:tree-shakingterserscope-hoisting压缩Code Split

      1. 压缩: 根据 optimization 配置执行一系列产物优化操作,特别是 Terser 插件需要执行大量 AST 相关的运算,执行效率取决于 产物代码量

      2. Code Split: 根据 splitChunks 配置、entry 配置、动态模块引用语句等,确定模块与 Chunk 的映射关系,其中 splitChunks 相关的分包算法非常复杂,涉及大量 CPU 计算

    3. 生成产物代码: 将所有 Module 内容一一转换为适当的产物代码形态,并以 Chunk 为单位合并 Module 产物代码,之后根据 Module 中出现的特性依赖,补充相应运行时代码,最终构建出我们日常所见的 Webpack Bundle 代码文件。

      1. 什么是模块转译: Webpack 的打包功能并不是将原始文件代码复制-粘贴到产物文件那么简单,为了确保代码能在不同环境 ——多种版本的浏览器、NodeElectron 等正常运行,构建时需要对模块源码适当做一些转换操作。

      2. 单模块转译: 这一步主要用于计算模块实际输出代码,遍历 compilation.modules 数组,调用 module 对象的 codeGeneration 方法,执行模块转译计算

      3. 收集运行时依赖: 第一次循环遍历所有 module, 收集所有 moduleruntime 依赖; 第二次循环遍历所有 chunk,将 chunk 下所有 moduleruntime 统一收录到 chunk 中; 第三次循环遍历所有 runtime chunk,收集其对应的子 chunk 下所有 runtime 依赖,之后遍历所有依赖并发布 runtimeRequirementInTree 钩子,(主要是) RuntimePlugin 插件订阅该钩子并根据依赖类型创建对应的 RuntimeModule 子类实例。

      4. 模块合并: 调用 compilation.createChunkAssets 方法,以 Chunk 为单位,将相应的所有 moduleruntimeModule 按规则塞进产物框架中,最终合并输出成完整的 Bundle 文件

    4. 写入文件系统(emitAssets): 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

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

Webpack 过程中, 频繁的在解析 JSAST, 例如调用 Loader 链加载文件时,遇到 babel-loadereslint-loaderts-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: 同样作为前端开发与构建工具, 开发与生产环境打包逻辑相同, 从所有入口开始, 递归处理所有依赖, 会对所有模块进行打包操作。生成最终代码前, 根据模块中出现的特性依赖,补充相应运行时代码, 比如立即表达式 IIFEWebpack runtime 运行时代码, 生成最终产物。Webpack 实现了一套自己的 CommonJS 规范, 在 Webpack 中, 每个模块都被包装在一个函数中, 这个函数接受一个对象, 这个对象有 exportsrequiremodule 等属性, 通过这种方式实现了模块的隔离, 每个模块都有自己的作用域, 不会污染全局作用域。

2.2 Vite、EsBuild、Rollup、Webpack 各自的 bundle.js 特点?

参考资料


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

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