侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

简易的webpack5配置方法(三):流程、依赖和分包逻辑以及sourcemap、HMR

lumozx
2022-08-17 / 0 评论 / 0 点赞 / 64 阅读 / 117922 字

在之前的文章,我们从webapck的应用层来了解是怎么进行工作的,而这篇文章,我们将从更加深入的底层,来学习webapck ,我们会了解到的编译流程,以及的依赖管理方式,同时再次从底层出发,来回他的分包规则和可能存在的问题,到最后,我们如同Vite一样,了解webpack的热更新。

构建流程

我们必须承认,webapck是相当复杂的,但也不能忘记一点:它是一个模块打包工具,也就是说,最核心的能力就是模块打包能力。

而这个流程基本可以看做以下逻辑。

  1. 流程开始

  2. 合并配置项中的参数

  3. 创建Compiler对象,Compiler对象是一个全局单例,负责把控整个构建流程、文件的监听、启动的编译。如果是热更新或者重新构建,那么Compiler会生成一个新的compilation对象,来负责此次构建过程,而compilation包含当前构建所需要的所有信息

  4. 创建compilation对象

  5. 从入口文件进行解析(entry)。

  6. 根据依赖类型,使用不同的ModuleFactry工厂类。

  7. 创建Module对象

  8. Module构建

  9. 根据Module解析loaders,然后使用loader转译

  10. 模块内容转为AST,找出依赖,如果存在依赖,那么就遍历依赖,跳转到第6步。

  11. Module Graph构建完毕

  12. 调用compilation.seal进行模块封装

  13. 再次遍历entry,创建Chunk对象

  14. 构建ChunkGraph,然后进行优化

  15. 生成运行时代码

  16. 创建Assets

  17. 如果配置允许,那么把产物写入磁盘

  18. 如果存在热更新,跳转到第6步。

如果忽略webpack的分支逻辑以及各种边界情况,那么它最核心的构建流程就如上文一样,当然我们可以把他们简化成为一个流程图,来更加直观了解到它的流程。

  1. 初始化配置,就在1 - 5步骤,在这一阶段,webpack会整理配置,创建 CompilerCompilation 等基础对象,并初始化插件及若干内置工厂、工具类,并最终根据 entry 配置,找到所有入口模块。

  2. 构建Module,对应6 - 10步骤,在这一阶段,webpack会使用loader将模块转译为JS,然后转译为AST,从AST中遍历依赖,之后递归依赖,直至构建完整的模块依赖图。

  3. 生成Chunk,对应第11 - 16步骤,根据entry,将模块进行组装,成为一个个Chunk,并将Chunk代码封窗为Asset,最后根据配置,是否写入磁盘。

  4. watch模式下,不会退出webpack进程,而是持续监听文件变动,然后从第6步重新构建。

初始化配置

初始化配置阶段,主要完成三个功能:整理、校验配置,运行插件,调用compiler.compile方法构建操作。

在启动的时候,

  1. 会将命令行的参数与配置文件中的参数进行合并

  2. 然后校验参数是否是合法的

  3. 最后在内部整理出最终的配置。

之后,创建Compiler对象

  1. 遍历配置中的插件,执行插件的apply方法

  2. 然后根据配置内容,动态注入对应插件,比如根据entry的值,注入DynamicEntryPluginEntryPlugin 插件,根据devtool的值注入Sourcemap插件,注入RuntimePlugin插件,最后调用compiler.compile

  3. 调用compiler.compile,虽然它的代码没有产生实质的业务逻辑,但它创建了之后所有流程的框架

  4. 调用 newCompilation 方法创建 compilation 对象

  5. 触发 make 钩子,紧接着 EntryPlugin 在这个钩子中调用 compilation 对象的 addEntry 方法创建入口模块,也就是启动了构建流程,到目前逻辑,初始化配置的流程已经结束了。

  6. make 执行完毕后,触发 finishMake 钩子

  7. 执行 compilation.seal 函数,进入生成阶段,开始封装 Chunk,生成产物

  8. seal 函数结束后,触发 afterCompile 钩子,开始执行收尾逻辑

可以说,compiler.compile就是webpack的核心流程架构。

构建Module

构建Module阶段是从entry开始递归解析模块内容、找出依赖,然后构建出整个module的依赖关系图,整个阶段的开始就是上文我们说的触发compilationaddEntry方法。

那么触发了addEntry之后,就会进行一下逻辑

  1. 调用handleModuleCreation,根据文件类型构建module子类,一般都是NormalModule

  2. 调用loader-runner转译module内容,也就是转译为js,这个时候执行了loader。

  3. 调用 acronjs转译为AST

  4. 遍历AST,如果是import,就触发exportImportSpecifier 钩子,然后在HarmonyExportDependencyParserPlugin监听处,将依赖资源设置为Dependency 对象,然后调用module对象的addDependency,将Dependency 对象转化为Module对象并添加到依赖数组。

  5. AST遍历结束,使用module对象的handleParseResult处理依赖数组

  6. 新增的依赖,使用handleModuleCreate,回到第一步,也就是递归。

  7. 所有依赖解析完毕,构建阶段就结束

从流程上我们不难看出,一个模块经历了从源码到js(即使是css,被loader处理后也会被解释为js,再到ast,然后转译为dependences对象,最后转化为module对象的历程。

而其中的重点就是递归。这里需要解释一下,dependences对象转化为module对象,并不会删除dependences对象,他们的依赖关系依然会保留,而是在compilation对象中增加对应的module对象。

生成Chunk

目前我们在内存中已经存储了目标项目的依赖图,那么接下来就讲这些依赖,也就是module对象拆分编排到Chunk对象中,打包成合适目标环境的产物。

在初始化配置中,我们可以了解到当前阶段,是在make阶段执行完毕,执行 compilation.seal 函数开始的。

  1. 创建本次构建的ChunkGraph对象

  2. 遍历入口集合,在这个时候,会调用addChunk方法为每个入口创建Chunk对象,然后以这个入口为起点,遍历该入口对应的dependences集合,关联到该Chunk。

  3. 此时我们得到了粗糙的Chunk,因此需要调用 buildChunkGraph 方法将这些 Chunk 处理成 Graph 结构,方便后续处理。

  4. 之后触发optimizeModules/optimizeChunks 等钩子,由插件(比如 SplitChunksPlugin)进一步修剪、优化 Chunk 结构。

  5. 如果所有Optimize钩子调用结束,就调用compilation.codeGeneration 方法生成 优化后的Chunk 代码,在这里我们需要注意,codeGeneration会遍历每一个ChunkModule对象,都会调用_codeGenerationModule,而_codeGenerationModule会继续往下调用module.codeGeneration生成单个Module代码,这里还要提醒下,虽然我们一直在说Module对象,但Module对象之间,也因为模块的不同,内部相同函数也有不同的实现,也对应不同的产物结果,也就是说module.codeGeneration之间也可能是不同的。

  6. 所有Module都执行完codeGeneration,我们就得到了打包后的模块代码,也就是模块代码的产物形态,紧接着就会调用createChunkAssets,生成最终chunk信息,注意,这里是信息,而非操作系统的文件。

  7. 对于这些信息,webpack会调用compilation.emitAssets进行提交,不过这里记录的依然是文件信息,也就是Assets

  8. 然后触发callback回调,控制流回到了compiler函数。

  9. 最后,调用compiler对象的emitAssets,输出文件。

总结一下,就是entrychunk一一对应的,chunk与输出的最终资源一一对应的,而其中的分包逻辑,比如SplitChunksPlugin插件,就是在第4步优化了chunk结构。

在整个流程中,会不断调用compilation.emitAssets提交文件信息,直到结束后,调用compiler.emitAssets函数,在函数内部,调用了compiler.outputFileSystem.writeFile 方法将 assets 集合写入文件系统,Webpack 完成从源码到资产文件的转换,构建工作至此结束。

流程小结

我们将执行步骤拆开来,从模块的角度,来看看它面临着什么变化

  1. 入口文件会以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录了 entry 的类型、路径等信息

  2. 根据依赖类型,调用不同的工厂函数创建 module 对象,之后读入module对应的文件内容,使用loader进行内容转化,之后读取到新的依赖,会重复这个过程,直到所有依赖都变成module对象

  3. 遍历module集合,根据入口配置以及引入资源的方式,将这些module分配不同的chunk

  4. chunk形成ChunkGraph结构

  5. 再遍历ChunkGraph调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合

  6. assets 写入文件系统

这里面使用了webpack的基础对象,包括

  • entry:入口文件

  • Compiler:编译管理器,Webpack 启动后会创建 compiler 对象,该对象一直存活直到构建结束进程退出,也就是说这个是一个单例对象

  • Compilation:单次构建过程的管理器,运行过程中只有一个compiler,但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象

  • Dependence:依赖对象,记录模块间依赖关系

  • ModuleWebpack 内部所有资源都会以 Module 对象形式存在,所有关于资源的操作、转译、合并都是以 Module 为单位进行的

  • Chunk:编译完成准备输出时,将 Module 按特定的规则组织成一个一个的 Chunk

依赖

如果仔细看构建流程,会产生些许疑问,在上文我们提到过所有资源都是以 Module 为单位进行的,但在 Module 之前,我们将资源转义为Dependency 对象,再转化为 Module 对象,为什么不直接使用Module呢?

那是因为虽然webapck的操作单元是Module,但相关的功能,比如树摇、组装Chunk、代码分割,就是高度依赖Dependency Graph

Dependency Graph

在webpack 5 之前,依赖关系在不同对象的属性中,并没有单独维护,比如:

  • 通过 module.dependencies 数组记录模块依赖对象

  • 通过 dependency.module 记录依赖对应的模块对象引用

  • 通过 dependency.module 记录依赖对应的模块对象引用

这样会导致一些列问题,比如关系灰色难懂,模块搜索和构建逻辑被耦合在一个Class中,造成Module的职责变重的同时复杂度变高,同时同一个Module对象无法在多个依赖中共享。

在weboack5中,重构了Dependency Graph,并使用Map、Set进行管理。

因此,产生了以下 逻辑:

  • ModuleGraph: 用来记录Dependency Graph的容器,同时还会记载构建过程中所有的moduledependency,这些对象之间是相互引用的

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

  • ModuleGraphModule:补充信息,包括incomingConnections——指向模块本身的ModuleGraphConnection集合,也就是谁引用了模块自身,outgoingConnections ——模块对外的的依赖,也就是引用了其他哪些模块。

乍一看,这些逻辑似乎不太好懂,因为不清楚他们存在怎样的互动逻辑,那么接下来就来介绍一下

  • 首先,Compilation会再内部维护一个全局唯一单例的ModuleGraph

  • 每次解析出新的模块,那么就将模块之间的关系,也就是ModuleGraphConnection,记录到ModuleGraph之中

  • ModuleGraph是一个对象,因此还提供许多工具方法,它有两个关键的属性:

  • 通过 _dependencyMap 属性记录 Dependency 对象与 ModuleGraphConnection 连接对象之间的映射关系,后续的处理中可以基于这层映射迅速找到 Dependency 实例对应的引用与被引用者,也就是依赖关系的上下文。

  • 通过 _moduleMap 属性记录 ModuleModuleGraphModule 之间的映射关系,就是上文说的补充信息。

那么什么时候进行依赖收集呢?

  • 在模块转义为AST的时候,webpack会解析出应用内部依赖关系,这个时候会通过module.addDependency创建Dependency 对象并记录到ModuleGraph

  • 调用handleModuleCreation的时候,当模块解析完毕,如果发现新依赖,就会回到构建Module第一步调用此方法,此方法会创建依赖的Dependency 对象,之后就会再次进行依赖收集,调用moduleGraph.setResolvedModule 方法将父子引用信息记录到 moduleGraph 对象上,而记录的属性就是_dependencyMapModuleGraphModule

那么这个有什么用呢?

它的主要作用在于信息索引和辅助构建ChunkGraph。由于记录了依赖关系图,因此可以看做是最底层的地图数据,我们可以通过提供的工具函数,来实现查询对应的Module实例、实例的依赖项、在何处被引用等等操作。

而构建ChunkGraph的时候,或根据entry生成对应chunk,然后会根据这个关系图,找出入口模块对应的所有 Module 对象,并将依赖关系转化为 ChunkGraph 对象。

ChunkGraph

我们在这里提到了ChunkGraph,如果要了解什么是ChunkGraph就需要我们先来梳理一下,ChunkGraph是怎么来的。

webpack的构建流程基本化为三个阶段:初始化、构建Module、生成Chunk,在构建Module阶段,webpack分析了模块间的依赖关系,建立了Dependency Graph,也就是ModuleGraph(这个是前者的容器),在生成Chunk阶段,又根据依赖图,将模块分开封装若干Chunk对象中,并且将Chunk只见的父子依赖关系梳理成ChunkGraph 与若干 ChunkGroup 对象。

我们在前面了解了构建Module阶段ModuleGraph所发挥的作用,那么在构建Chunk阶段,是怎么使用ModuleGraph转化为 ChunkGraph 对象的呢?

  1. 同前面多次提到过的一样,在构建Chunk阶段,会遍历entry配置,为每个入口创建一个空的ChunkEntryPoint对象EntryPoint是特殊的ChunkGroup,因为存在entry可以在对象中使用数组的情况——即多个入口合并到一处,因此在webpack中,每一个entry对应一个Chunk,但并非一定会生成对应数量的Assets,并设置好基本的ChunkGraph结构关系。

  2. 如果entry中设置了runtime,那么还会创建对应运行时代码的Chunk,分配个这个ChunkGroup

  3. 调用buildChunkGraph ,在内部调用了visitModules函数,遍历ModuleGraph,此时会将Module按照依赖关系分配给不同的Chunk,如果遇到异步模块,那么就为此异步模块创建新的ChunkGroupChunk对象

  4. buildChunkGraph中调用connectChunkGroups方法,建立ChunkGroup 之间、Chunk 之间的依赖关系,生成完整的 ChunkGraph 对象

  5. buildChunkGraph中调用cleanupUnconnectedGroups,清除无效的ChunkGroup

最终行程的关系图见下图。

经过上面的步骤,ModuleGraph中的模块,会按照本身的性质,分配到entryasyncruntime三个chunk中,并将chunk只见的依赖关系,储存在到ChunkGraphChunkGroup中,然后,在后续的流程中,会在这些对象的基础上修改分包策略,实现优化。

在这里,我们提到了三个概念,chunkchunkGroupchunkGraph,我们来了解一下他们的概念

  • chunk,构建Module阶段,从module读取内容,记录模块间依赖,chunk就是根据这些依赖,把多个module合并起来,形成一个中间产物。

  • chunkGroup,会包含一个或者多个chunkchunkGroupchunkGroup只见也会形成依赖关系。

  • chunkGraph,将上面chunk之间的关系、chunkGroup之间的关系,存储到compilation.chunkGraph之中,就是chunkGraph

分包

好的,我们又回来讲分包了,这次我们已经有了依赖图的知识,从更底层而非应用层,来看待分包的逻辑。

默认分包逻辑

我们之前讲过,module会被组织成三种不同类型的chunkentryasyncruntime,这个是默认的分包逻辑。

那么他们是怎样进行划分的呢?

Entry

在上文中,我们了解到,webpack会对每一个entry创建chunk对象,初始化结束后,webpack会根据模块依赖关系,将entry下的,所有可触达的module塞入chunk,就是visitModules所做的事情,这样,入口文件以同步方式引用了这些module,然后webpack会为入口文件创建ChunkEntryPoint,最终就形成了一个粗糙的打包产物。

Async

我们在上文的entry中,使用了这样的描述:入口文件以同步方式引用了这些module,那么异步模块呢?webpack会单独将每一个异步导入语句,都处理成一个单独的chunk,并且子模块也将加入到这个chunk之中,这个chunk就是async chunk

这里需要注意的是,如果异步模块的子模块是同步引入的,那么也会被加入到async chunk之中,这样在chunk层面,就形成了单项的依赖关系,从而避免循环引用,然后webpack会把这个依赖关系保存在ChunkGroup._parentsChunkGroup._children 属性中。

Runtime

webpack5中,还支持将runtime单独抽取为chunk,这样可以避免随着特性增加,entry chunk较多的情况下,重复打包几乎一样的代码。webpack5提供了entry.runtime属性,来声明如何打包运行时代码。

调用compilation.seal的时候,webpack会为entry创建EntryPoint,之后会判断entry的配置是否存在runtime配置,有则创建runtime值的chunk

由于runtime的值是可以相同的——同时这也是优化的手段,因此webpack会将运行时代码写入到同一runtime chunk中,从而达到优化的目的。

问题

这个默认分包逻辑很容易看出问题:那就是无法避免模块的重复打包。

如果多个entry引用了同一个模块,那么webpack不会优化这个行为,而是按照默认逻辑,将这个模块同时打入另两个chunk,最终在不同chunk之中行程相同的代码。因此这也是叫他粗糙的chunk的原因。

为了解决这个问题,webpack3引入了CommonChunkPlugin,试图将公共依赖提取成单独chunk,但CommonChunkPlugin本质是基于chunk间的依赖来实现的,很难推断出第三个包是作为父chunk还是子chunk,因此在逻辑中,强制设定为父chunk,反而在某些情况造成了性能影响。

webpack4中,专门引入了ChunkGroup来管理多个chunk的依赖,配合SplitChunksPlugin从而实现启发式分包。

编译

我们重温了一下分包逻辑,但依然有一些地方需要深入探讨一下,尽管我们在强调,根据ModuleGraph生成了ChunkGraph,然后又生成了产物代码,这个过程中,如果使用了新特性,会补充对应的运行时代码,最后行程最终产物。

那么模块是如何进行转译的呢?以及合包、分包这些逻辑是怎样与ModuleGraphChunkGraph互动的呢?

首先我们来思考一下,webpack是可以将目标模块代码直接拿来用的呢?

答案是否定的,因为webpack需要保证打包出来的文件在不同环境运行,而为了适应不同环境,需要对模块的代码进行一些转换。

举个例子,我们在入口函数引用了一个模块,经过打包后的产物是这样的:

整个产物可以分为三部分:

  • 顶部子模块对应的转译代码

  • 中间webpack的运行时代码

  • 底部的入口文件转译的立即执行函数的代码

显而易见,虽然跟源码语义相同,这些代码是经过webpack转译的。

与源码相比,

  • 入口模块被包装进立即执行函数中

  • 使用了__webpack_require__.r来适配ESM规范

  • import被转译为__webpack_require__

  • 入口模块打印子模块的变量被_bar__WEBPACK_IMPORTED_MODULE_0__["default"]代替

那么整个流程是怎么样的呢?

我们回忆一下,在生成chunk的时候compilation.seal 函数内会调用 buildChunkGraph 生成 Chunk 依赖关系图,然后webpack会得到生成chunk所需要的信息:生成哪些chunk、需要的module和对应的内容(loader翻译后的内容)、以及chunk只见的依赖关系。那么接下来就是Optimize钩子的调用,最后是compilation.codeGeneration 方法。

compilation.codeGeneration就是负责生成代码的方法,这个方法有三个步骤

  1. 单模块转译:如同前文所说,这个方法会调用每个module.codeGeneration,而codeGeneration会调用JavascriptGeneratorgenerate 方法,然后遍历module对象的dependenciespresentationalDependencies 数组,然后执行每个数组项 dependeny 对象对应的 template.apply 方法。但会产生副作用,首先会修改soucre数据,然后将结果记录到initFragments,然后将运行时依赖记录到runtimeRequirements

  2. 收集运行依赖:在上一个步骤中,我们记录了运行时依赖,因此,在这一步需要将这些依赖收集起来,通过compilation.processRuntimeRequirementsruntimeRequirements转换成RuntimeModule 对象,并挂载到 ChunkGroup 中。

  3. 模块合并:此时我们有了运行时依赖,也有了模块转译后的代码,那么接下里就是以chunk为单位,将这俩按照规则塞到里面,并最后输出产物文件。

总结一下,整个流程是先遍历所有模块的依赖对象,收集编译结果和运行时依赖,然后将内容合并为打包产物。

单模块转译

既然大体流程搞清楚了,那我们深入了解步骤的详细情况,单模块转译是调用每个模块的codeGeneration开始,之后,会调用JavascriptGenerator.generate,遍历模块的dependencies 数组,依次调用依赖对象对应的 Template 子类 apply 方法更新模块内容。

我们来看一块JavascriptGenerator对应的源码

从源码中,可以看到,generate没有操作源码,而是提供了一个执行框架,转译是在Template.apply中进行的,而Template的工作除了转译,还有操作initFragments,也就是在模块源码之外补充代码片段,以及将运行时依赖记录到runtimeRequirements,也就是收集依赖步骤的依赖来源。

然后代码的翻译结果,也就是新代码,会在所有Dependency执行完毕后,跟着initFragments一起传入InitFragment.addToSource

addToSource的逻辑分为三个部分

  1. 遍历InitFragment,按顺序合并运行时代码的开头

  2. 合并模块翻译后的

  3. 遍历InitFragment,按顺序合并运行时代码的结尾

所以模块合并本质就是InitFragment数组层层包裹翻译后的源代码,这串逻辑主要是Template.apply启动并维护的。

收集运行时依赖

其实在构建Module阶段,webpack就在持续性收集运行时依赖。

在之前我们提到过,webpack会将代码转成AST来解析模块依赖,其实还在这个时候,收集了运行时的依赖。

而核心逻辑compilation.processRuntimeRequirements函数,包含着三次循环

  • 第一步遍历所有module,收集所有module的运行时依赖,我们在上文提到,webpack会在Template.apply进行源码翻译,然后收集依赖,将运行时依赖记录到runtimeRequirements,此时会将runtimeRequirements挂载到chunkGraph

  • 第二次循环遍历所有chunk,将chunk下所有的module运行时统一收录到chunk中,到这一步骤,会从module的运行时依赖整合到chunk层面

  • 第三次遍历所有的runtime chunk,收集对应的子chunk下所有的运行时依赖,之后遍历所有依赖并发布runtimeRequirementInTree钩子,RuntimePlugin插件订阅该钩子并根据依赖类型创建对应的 RuntimeModule 子类实例,并将对象加入到 ModuleGraph 或者ChunkGraph 体系中管理

也就是说,进行到第三步的时候,运行时依赖已经被提到ChunkGraph层级。最终跟随着转译后的业务代码,一起被和合并到最终产物

模块合并

在这个流程中,会把之前步骤生成的module和运行时依赖,按照按照对应的chunk和规则,放到框架之中,最终行程完整的打包文件。

而这个框架,包含以下关键部分

  • 外层是一个立即执行函数包裹

  • 记录除了entry以外的其他模块的__webpack_modules__ 对象,对象的 key 为模块标志符,值为模块转译后的代码

  • 用来适配ESM的__webpack_require__

  • 以及用立即执行函数包裹的entry代码

那么他们是怎么被填入到框架之中呢?

在生成Chunk的第6步:所有Module都执行完codeGeneration,我们就得到了打包后的模块代码,也就是模块代码的产物形态,紧接着就会调用createChunkAssets,生成最终chunk信息。

可以注意到,重点就是createChunkAssets方法,这个方法触发了renderManifest钩子,JavascriptModulesPlugin插件会监听这个钩子,并开始进行组装。

JavascriptModulesPlugin的核心逻辑就是,根据不同的chunk,调用不同的打包函数

  • renderMain,打包主chunk的时候使用

  • renderChunk,打包字chunk的时候使用,比如异步模块chunk

我们主要来看renderMain

根据源码,我们可以得到renderMain的逻辑是

  1. 先计算出bundle CMD代码,也就是__webpack_require__函数

  2. 计算出当前chunk下,除了entry外的模块代码

  3. 计算运行时模块代码

  4. 合并代码

  5. 合并CMD代码

  6. 合并运行时代码

  7. 遍历其他模块,合并除了entry以外的代码

  8. 最后合并entry代码

目前产物信息已经完整了,只不过是放到内存中,然后调用compilation.emitAsset提交产物信息,走完之后的流程就行了

sourceMap

到目前为止,我们已经大体摸索清楚webpack是如何将源码变为打包后的代码的,这个已经足够我们去通过webpack来打包我们的项目了,但这里面存在一个问题:如果代码报错,那么调试起来岂不是很麻烦——因为我们的浏览器读取的是打包后的代码。

因此我们需要一个功能:可以将压缩、混淆、合并后的代码还原成源码形态,这样作为开发者,我们就能精确定位问题。

sourceMap 协议正是为了解决此问题诞生的协议,最初的map文件非常大,V2版本引入base64编码等算法,体积减小20%~30%,V3版本又引入VLQ算法,体积进一步压缩50%,目前我们使用的正是V3版本。

结构

V3版本的Sourcemap文件由三个部分组成:

  • 原始代码

  • 经过处理后的打包代码,且产物文件中必须包含指向 Sourcemap 文件地址的 //# sourceMappingURL=XXX指令

  • 记录源码与打包代码位置映射关系的map文件

正常页面只加载打包后的代码,只有特定事件才会加载map文件——比如打开控制台。

map文件通常是json格式。

  • version:指的是sourcemap版本,目前最新版本是3

  • names:字符串数组,记录原始代码出现的变量名,这里需要注意的是,如果没有混淆原始代码的变量名,这一项是空的

  • file:sourcemap对应的打包产物

  • sourcesContent:记录元时代吗内容

  • sourceRoot: 源文件根目录

  • sources:源文件目录

  • mappings:与源氏代码的映射关系

在浏览器读取的时候,会根据mappings的数值关系,将代码映射到sourcesContent,从而还原到源码的文件、行、列,因此不难看出,map文件的重点就是mappings字段。

源码

打包产物

mappings

;;;;;AAAA,IAAMA,IAAI,GAAG,QAAb;AACAC,OAAO,CAACC,GAAR,CAAYC,GAAZ,E

我们可以看到,mappings中存在分号【;】和逗号【,】

其中的分号分割行,每一个分号都代表着打包产物应该映射到源码的哪一行。

其中的逗号,分割代码片段,每一个逗号对应该行每一个代码片段到源码的映射

依照这个逻辑,我们来分割下。

[
// 第1-5是webpack的runtime,因此对源码没有映射关系
'', '', '', '', '',
// 第6行
  ['AAAA','IAAMA',IAAI','GAAG','QAAb'],
// 第7行
  ['AACAC','OAAO','CAACC','GAAR','CAAYF','IAAZ,E'],
//之后依然是runtime,没有映射关系不会生成
]

那么这些字母是什么意思的呢?

  • 第一位是该代码片段在产物的列数

  • 第二位是源码文件的索引,对应的是sources数组的元素下标

  • 第三位是该代码片段在源码的行数

  • 第四位是该代码片段在源码的列数

  • 如果有第五位的话,对应的名称索引,就是该片段在names数组的元素下标,入前面所说,如果没有混淆等方式更改变量名称,此项为空,names也为空

  • 除了这些信息,还有个隐藏信息,那么就是mappings解析出来的行数是与产物一一对应的,因此通过产物所在的列数,就可以找到mappings对应的映射,再通过映射找到源码

这里需要注意的是,片段之间并非绝对定位,而是代码片段的相对偏移定位。

拿第一位来举例子,AACAC,OAAO 他们组合成为了一个代码片段,那么他们的第一位分别是

  • A,第A列

  • O,第A + O列

同时,不同行之间也有偏移,比如 AAAA,AACA,AACA,那么他们的第三位是

  • A,第A行

  • C,第A + C行

  • C,第A + C + C行

这样的好处是减少map文件的体积,以客户端进行运算的代价,提升整体性能。

VLQ

我们注意到,这里面是字母来标识数字单位,而非纯数字,这个就与我们之前提到的VLQ编码有关了。

VLQ是一种将整数数值转换为 Base64 的编码算法,它先将任意大的整数转换为一系列六位字节码,再按 Base64 规则转换为一串可见字符。VLQ 使用六位比特存储一个编码分组。

就拿4来举例,4经过VLQ编码后,结果是001000

  • 第一位是连续符号位,标识后续分组是否是同一数字,因为VLQ是六位比特为一个分组,存在一个数组用多个分组来表示的情况,因此除了最后一个分组为0,其他分组第一位都为1

  • 第六位标识改数字的正负号,0为正整数,1为负整数

  • 2-5标识实际数组,若不足,则左侧填充0

  • 先添加符号位,再分组,分组方式是从后往前分组,但分组也将颠倒,然后再填充不足的数字,最后添加连续符号位

经过变化,4变为了001000,是二进制的8,查表得,4的映射字符是I

为了加深理解,我们这次来按部就班写出-25的映射编码。

  1. 首先25的二进制是11001

  2. 由于是负整数,因此最右侧添加符号位1,变成110011

  3. 由于是六位一组,但没有添加连续符号位,因此针对数字是五位一组,所以空出一位来添加连续符号位,因此分组为【1 ,10011】,由于是从后往前分组,因此整理(也就是颠倒分组)一下,是【10011 ,1】

  4. 不足五位的需要左侧补充0,直到五位,也就是【10011,00001】

  5. 添加连续符号位,除了最后一组是0,其他组最后都是1,也就是【110011,000001】

  6. 然后转换成10进制,110011 => 51 000001 => 2

  7. 最后查表得,51是z,2是B,因此-25的VAL编码是zB

可以使用这个网站,来验证结论是否正确:BASE64 VLQ CODEC

经过上面的了解,我们已经了解了基本的VAL编码,这个时候我们再回头看看mappings【AACAC,OAAO】,由于【AACAC】个数为5,且每个都符合VAL的生成规则,因此我们可以认定为00101(存在多个字母只表示一个数的情况,比如上文的-25),同时他是mappings的第7行,因此代表着,产物的第7行,第【0】列,对应着sources中第【0】个元素,解析出来的第 1 + 0行(因为前一行是AAAA,A是0,C是1),同时源码的开头的列数是【0】,源码里面用到了变量,是names数组的第【1】下标。

综上信息,我们可以把AACAC和源码中console进行关联起来,同理OAAO7007,可以得到是源码中的【.】

devtool

webpack提供两种设置sourcemap的方式,一种是通过devtool的配置项进行设置,另一种就是使用SourceMapDevToolPluginEvalSourceMapDevToolPlugin 插件进行深度定制

devtool包含25种配置,可在官网了解到这25中配置的作用:devtool

devtool

performance

production

quality

comment

(none)

build: fastest

rebuild: fastest

yes

bundle

Recommended choice for production builds with maximum performance.

eval

build: fast

rebuild: fastest

no

generated

Recommended choice for development builds with maximum performance.

eval-cheap-source-map

build: ok

rebuild: fast

no

transformed

Tradeoff choice for development builds.

eval-cheap-module-source-map

build: slow

rebuild: fast

no

original lines

Tradeoff choice for development builds.

eval-source-map

build: slowest

rebuild: ok

no

original

Recommended choice for development builds with high quality SourceMaps.

cheap-source-map

build: ok

rebuild: slow

no

transformed

cheap-module-source-map

build: slow

rebuild: slow

no

original lines

source-map

build: slowest

rebuild: slowest

yes

original

Recommended choice for production builds with high quality SourceMaps.

inline-cheap-source-map

build: ok

rebuild: slow

no

transformed

inline-cheap-module-source-map

build: slow

rebuild: slow

no

original lines

inline-source-map

build: slowest

rebuild: slowest

no

original

Possible choice when publishing a single file

eval-nosources-cheap-source-map

build: ok

rebuild: fast

no

transformed

source code not included

eval-nosources-cheap-module-source-map

build: slow

rebuild: fast

no

original lines

source code not included

eval-nosources-source-map

build: slowest

rebuild: ok

no

original

source code not included

inline-nosources-cheap-source-map

build: ok

rebuild: slow

no

transformed

source code not included

inline-nosources-cheap-module-source-map

build: slow

rebuild: slow

no

original lines

source code not included

inline-nosources-source-map

build: slowest

rebuild: slowest

no

original

source code not included

nosources-cheap-source-map

build: ok

rebuild: slow

no

transformed

source code not included

nosources-cheap-module-source-map

build: slow

rebuild: slow

no

original lines

source code not included

nosources-source-map

build: slowest

rebuild: slowest

yes

original

source code not included

hidden-nosources-cheap-source-map

build: ok

rebuild: slow

no

transformed

no reference, source code not included

hidden-nosources-cheap-module-source-map

build: slow

rebuild: slow

no

original lines

no reference, source code not included

hidden-nosources-source-map

build: slowest

rebuild: slowest

yes

original

no reference, source code not included

hidden-cheap-source-map

build: ok

rebuild: slow

no

transformed

no reference

hidden-cheap-module-source-map

build: slow

rebuild: slow

no

original lines

no reference

hidden-source-map

build: slowest

rebuild: slowest

yes

original

no reference. Possible choice when using SourceMap only for error reporting purposes.

看起来比较多,但实际上了解命名规律后,就会了解其中的规则,它们由inlineevalsource-mapnosourceshiddencheapmodule七个关键字组成。

  • inline :当 devtool 包含 inline 时,webpack 会将 sourcemap 内容编码为 Base64 DataURL,直接追加到产物文件中

  • eval:当包含eval的时候,生成的代码会包裹进一段eval函数,且模块的sourcemap信息通过//# sourceURL 直接挂载在模块代码内,速度非常快,但产物很大

  • source-map :当 devtool 包含 source-map 时,webpack 才会生成 sourcemap 内容。

  • cheap :当 devtool 包含 cheap 时,生成的 sourcemap 内容会抛弃列维度的信息,这就意味着浏览器只能映射到代码行维度,可以用来简化sourcemap的体积

  • module :module 关键字只在 cheap 场景下生效,例如 cheap-module-source-mapeval-cheap-module-source-map。当 devtool 包含 cheap 时,webpack 根据 module 关键字判断按 loader 联调处理结果作为 source,还是按处理之前的代码作为 source

  • nosources 关:当 devtool 包含 nosources 时,生成的 sourcemap 内容中不包含源码内容 —— 即 sourcesContent 字段

  • hidden 关键字:通常,产物中必须携带 //# sourceMappingURL= 指令,浏览器才能正确找到 sourcemap 文件,当 devtool 包含 hidden 时,编译产物中不包含 //# sourceMappingURL= 指令,可以使浏览器不会自动加载map文件,而是手动加载

对于开发环境适合使用:

  • eval:速度极快,但只能看到原始文件结构,看不到打包前的代码内容

  • cheap-eval-source-map:速度比较快,可以看到打包前的代码内容,但看不到 loader 处理之前的源码

  • cheap-module-eval-source-map:速度比较快,可以看到 loader 处理之前的源码,不过定位不到列级别

  • eval-source-map:初次编译较慢,但定位精度最高

对于生产环境则适合使用:

  • source-map:信息最完整,但安全性最低,外部用户可轻易获取到压缩、混淆之前的源码,慎重使用;

  • hidden-source-map:信息较完整,安全性较低,外部用户获取到 map 文件地址时依然可以拿到源码

  • nosources-source-map:源码信息缺失,但安全性较高,需要配合 Sentry 等工具实现完整的 sourcemap 映射。

HMR

好的,同vite一样,让我们来了解一下webpackHMR

webpackHMR执行过程并不复杂

  1. 首先回事会用webpack-dev-server托管静态资源,然后以运行时的方式注入HMR客户端处理代码

  2. 页面加载之后,这些代码会与webpack-dev-server简历websocket链接

  3. webpack监听文件变化,进行增量构建,也就是前文说的构建Module步骤,并通过websocket发送hash事件

  4. 浏览器收到hash事件,会请求manifest资源文件,确认变更范围

  5. 浏览器加载发生变更的增量模块

  6. 运行时触发模块的module.hot.acccept回调,执行代码变更逻辑

可以看到,我们都可以在vite中找到对应的处理方法,因此webpackHMR对我们是很好上手的,

在前面,我们已经学习了在打包构建的时候,webpack如何注入运行时,与此对应的,是HMR场景下,webpack-dev-server首先会调用HotModuleReplacementPlugin插件,向主Chunk注入下面相关的HMR:

  • 用来建立websocket链接、处理hash的代码

  • 用于加载热更新的接口

  • 用于处理热更新的接口

然后,HotModuleReplacementPlugin还会借助webpackwatch能力,在源码发生变化后进行增量构建生成:

  • manifest 文件:JSON 格式文件,包含所有发生变更的模块列表,命名为 [hash].hot-update.json

  • 模块变更文件:js 格式,包含编译后的模块代码,命名为 [hash].hot-update.js

增量构建之后,webpack会触发 compilation.hooks.done 钩子,并传递本次构建的统计信息对象 stats。webpack-dev-server 则监听 done 钩子,在回调中通过 websocket 发送模块更新消息,

然后,加载更新的时候,客户端通过websocket接收到hash信息,会发出manifest请求获取本轮热更新涉及的chunk

获取到最新的chunk后,会进行加载,然后执行module.hot.accept的回调。

module.hot.acceptHMR暴露给用户重要接口之一,可以让用户自定义模块热替换的逻辑。

它接受两个参数

  • path:指定需要链接变更行为的模块路径

  • callback:模块更新后,将最新模块代码应用到运行环境的参数

但这里需要注意几个问题

  1. module.hot.accept只接受具体路径的path,而非glob风格的批量路径,一旦某个模块没有注册此函数,HMR会启用默认的行为,通常是刷新页面,因此页面刷新是module.hot.accept起没起作用的标志

  2. module.hot.accept函数只能捕获当前模块对应子模块的更新事件,而不能相邻模块的捕获,与DOM事件规范的冒泡

  3. module.hot.accept还支持无参数调用,最用是捕获当前文件的变更事件,并从第一行重复运行当前模块代码

小结

到这里,我们大致了解了webpack的流转逻辑,但并没有像vite一样进行源码层级的分析,其中比较重要的原因是webpack发展这几年,沉淀的逻辑实在是太多太大了,并且表现晦涩,很难用短短几篇文章,就介绍的比较详细,因此这也是文章名称带有【简易】二字的原因,本篇文章随时涉及些许底层,但初衷在应用层把webpack配置好就行了,而更多的原理和解释,还得通过阅读webpack的文档以及源码才能参悟,需要我们需要的东西有很多。这并非我们的终点,而是我们新的起点。

0
博主关闭了所有页面的评论