在之前的文章,我们从webapck的应用层来了解它是怎么进行工作的,而这篇文章,我们将从更加深入的底层,来学习webapck ,我们会了解到它的编译流程,以及它的依赖管理方式,同时再次从底层出发,来回它他的分包规则和可能存在的问题,到最后,我们如同Vite一样,了解webpack的热更新。
构建流程
我们必须承认,webapck是相当复杂的,但也不能忘记一点:它是一个模块打包工具,也就是说,它最核心的能力就是模块打包能力。
而这个流程基本可以看做以下逻辑。
流程开始
合并配置项中的参数
创建Compiler对象,Compiler对象是一个全局单例,负责把控整个构建流程、文件的监听、启动的编译。如果是热更新或者重新构建,那么Compiler会生成一个新的compilation对象,来负责此次构建过程,而compilation包含当前构建所需要的所有信息
创建compilation对象
从入口文件进行解析(entry)。
根据依赖类型,使用不同的ModuleFactry工厂类。
创建Module对象
Module构建
根据Module解析loaders,然后使用loader转译
模块内容转为AST,找出依赖,如果存在依赖,那么就遍历依赖,跳转到第6步。
Module Graph构建完毕
调用compilation.seal进行模块封装
再次遍历entry,创建Chunk对象
构建ChunkGraph,然后进行优化
生成运行时代码
创建Assets
如果配置允许,那么把产物写入磁盘
如果存在热更新,跳转到第6步。
如果忽略webpack的分支逻辑以及各种边界情况,那么它最核心的构建流程就如上文一样,当然我们可以把他们简化成为一个流程图,来更加直观了解到它的流程。
初始化配置,就在1 - 5步骤,在这一阶段,webpack会整理配置,创建 Compiler、Compilation 等基础对象,并初始化插件及若干内置工厂、工具类,并最终根据 entry 配置,找到所有入口模块。
构建Module,对应6 - 10步骤,在这一阶段,webpack会使用loader将模块转译为JS,然后转译为AST,从AST中遍历依赖,之后递归依赖,直至构建完整的模块依赖图。
生成Chunk,对应第11 - 16步骤,根据entry,将模块进行组装,成为一个个Chunk,并将Chunk代码封窗为Asset,最后根据配置,是否写入磁盘。
watch模式下,不会退出webpack进程,而是持续监听文件变动,然后从第6步重新构建。
初始化配置
初始化配置阶段,主要完成三个功能:整理、校验配置,运行插件,调用compiler.compile方法构建操作。
在启动的时候,
会将命令行的参数与配置文件中的参数进行合并
然后校验参数是否是合法的
最后在内部整理出最终的配置。
之后,创建Compiler对象
遍历配置中的插件,执行插件的apply方法
然后根据配置内容,动态注入对应插件,比如根据entry的值,注入DynamicEntryPlugin 或 EntryPlugin 插件,根据devtool的值注入Sourcemap插件,注入RuntimePlugin插件,最后调用compiler.compile。
调用compiler.compile,虽然它的代码没有产生实质的业务逻辑,但它创建了之后所有流程的框架
调用 newCompilation 方法创建 compilation 对象
触发 make 钩子,紧接着 EntryPlugin 在这个钩子中调用 compilation 对象的 addEntry 方法创建入口模块,也就是启动了构建流程,到目前逻辑,初始化配置的流程已经结束了。
make 执行完毕后,触发 finishMake 钩子
执行 compilation.seal 函数,进入生成阶段,开始封装 Chunk,生成产物
seal 函数结束后,触发 afterCompile 钩子,开始执行收尾逻辑
可以说,compiler.compile就是webpack的核心流程架构。
构建Module
构建Module阶段是从entry开始递归解析模块内容、找出依赖,然后构建出整个module的依赖关系图,整个阶段的开始就是上文我们说的触发compilation的addEntry方法。
那么触发了addEntry之后,就会进行一下逻辑
调用handleModuleCreation,根据文件类型构建module子类,一般都是NormalModule。
调用loader-runner转译module内容,也就是转译为js,这个时候执行了loader。
调用 acron把js转译为AST
遍历AST,如果是import,就触发exportImportSpecifier 钩子,然后在HarmonyExportDependencyParserPlugin监听处,将依赖资源设置为Dependency 对象,然后调用module对象的addDependency,将Dependency 对象转化为Module对象并添加到依赖数组。
AST遍历结束,使用module对象的handleParseResult处理依赖数组
新增的依赖,使用handleModuleCreate,回到第一步,也就是递归。
所有依赖解析完毕,构建阶段就结束
从流程上我们不难看出,一个模块经历了从源码到js(即使是css,被loader处理后也会被解释为js),再到ast,然后转译为dependences对象,最后转化为module对象的历程。
而其中的重点就是递归。这里需要解释一下,dependences对象转化为module对象,并不会删除dependences对象,他们的依赖关系依然会保留,而是在compilation对象中增加对应的module对象。
生成Chunk
目前我们在内存中已经存储了目标项目的依赖图,那么接下来就讲这些依赖,也就是module对象拆分编排到Chunk对象中,打包成合适目标环境的产物。
在初始化配置中,我们可以了解到当前阶段,是在make阶段执行完毕,执行 compilation.seal 函数开始的。
创建本次构建的ChunkGraph对象
遍历入口集合,在这个时候,会调用addChunk方法为每个入口创建Chunk对象,然后以这个入口为起点,遍历该入口对应的dependences集合,关联到该Chunk。
此时我们得到了粗糙的Chunk,因此需要调用 buildChunkGraph 方法将这些 Chunk 处理成 Graph 结构,方便后续处理。
之后触发optimizeModules/optimizeChunks 等钩子,由插件(比如 SplitChunksPlugin)进一步修剪、优化 Chunk 结构。
如果所有Optimize钩子调用结束,就调用compilation.codeGeneration 方法生成 优化后的Chunk 代码,在这里我们需要注意,codeGeneration会遍历每一个Chunk的Module对象,都会调用_codeGenerationModule,而_codeGenerationModule会继续往下调用module.codeGeneration生成单个Module代码,这里还要提醒下,虽然我们一直在说Module对象,但Module对象之间,也因为模块的不同,内部相同函数也有不同的实现,也对应不同的产物结果,也就是说module.codeGeneration之间也可能是不同的。
所有Module都执行完codeGeneration,我们就得到了打包后的模块代码,也就是模块代码的产物形态,紧接着就会调用createChunkAssets,生成最终chunk信息,注意,这里是信息,而非操作系统的文件。
对于这些信息,webpack会调用compilation.emitAssets进行提交,不过这里记录的依然是文件信息,也就是Assets。
然后触发callback回调,控制流回到了compiler函数。
最后,调用compiler对象的emitAssets,输出文件。
总结一下,就是entry与chunk一一对应的,chunk与输出的最终资源一一对应的,而其中的分包逻辑,比如SplitChunksPlugin插件,就是在第4步优化了chunk结构。
在整个流程中,会不断调用compilation.emitAssets提交文件信息,直到结束后,调用compiler.emitAssets函数,在函数内部,调用了compiler.outputFileSystem.writeFile 方法将 assets 集合写入文件系统,Webpack 完成从源码到资产文件的转换,构建工作至此结束。
流程小结
我们将执行步骤拆开来,从模块的角度,来看看它面临着什么变化
入口文件会以 dependence 对象形式加入 compilation 的依赖列表,dependence 对象记录了 entry 的类型、路径等信息
根据依赖类型,调用不同的工厂函数创建 module 对象,之后读入module对应的文件内容,使用loader进行内容转化,之后读取到新的依赖,会重复这个过程,直到所有依赖都变成module对象
遍历module集合,根据入口配置以及引入资源的方式,将这些module分配不同的chunk
将chunk形成ChunkGraph结构
再遍历ChunkGraph,调用 compilation.emitAsset 方法标记 chunk 的输出规则,即转化为 assets 集合
将 assets 写入文件系统
这里面使用了webpack的基础对象,包括
entry:入口文件
Compiler:编译管理器,Webpack 启动后会创建 compiler 对象,该对象一直存活直到构建结束进程退出,也就是说这个是一个单例对象
Compilation:单次构建过程的管理器,运行过程中只有一个compiler,但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象
Dependence:依赖对象,记录模块间依赖关系
Module:Webpack 内部所有资源都会以 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的容器,同时还会记载构建过程中所有的module和dependency,这些对象之间是相互引用的
ModuleGraphConnection:记录模块间引用关系的数据结构,内部用originModule来记录父模块,module来记录子模块
ModuleGraphModule:补充信息,包括incomingConnections——指向模块本身的ModuleGraphConnection集合,也就是谁引用了模块自身,outgoingConnections ——模块对外的的依赖,也就是引用了其他哪些模块。
乍一看,这些逻辑似乎不太好懂,因为不清楚他们存在怎样的互动逻辑,那么接下来就来介绍一下
首先,Compilation会再内部维护一个全局唯一单例的ModuleGraph
每次解析出新的模块,那么就将模块之间的关系,也就是ModuleGraphConnection,记录到ModuleGraph之中
ModuleGraph是一个对象,因此还提供许多工具方法,它有两个关键的属性:
通过 _dependencyMap 属性记录 Dependency 对象与 ModuleGraphConnection 连接对象之间的映射关系,后续的处理中可以基于这层映射迅速找到 Dependency 实例对应的引用与被引用者,也就是依赖关系的上下文。
通过 _moduleMap 属性记录 Module 与 ModuleGraphModule 之间的映射关系,就是上文说的补充信息。
那么什么时候进行依赖收集呢?
在模块转义为AST的时候,webpack会解析出应用内部依赖关系,这个时候会通过module.addDependency创建Dependency 对象并记录到ModuleGraph。
调用handleModuleCreation的时候,当模块解析完毕,如果发现新依赖,就会回到构建Module第一步调用此方法,此方法会创建依赖的Dependency 对象,之后就会再次进行依赖收集,调用moduleGraph.setResolvedModule 方法将父子引用信息记录到 moduleGraph 对象上,而记录的属性就是_dependencyMap和ModuleGraphModule。
那么这个有什么用呢?
它的主要作用在于信息索引和辅助构建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 对象的呢?
同前面多次提到过的一样,在构建Chunk阶段,会遍历entry配置,为每个入口创建一个空的Chunk和EntryPoint对象(EntryPoint是特殊的ChunkGroup,因为存在entry可以在对象中使用数组的情况——即多个入口合并到一处,因此在webpack中,每一个entry对应一个Chunk,但并非一定会生成对应数量的Assets),并设置好基本的ChunkGraph结构关系。
如果entry中设置了runtime,那么还会创建对应运行时代码的Chunk,分配个这个ChunkGroup。
调用buildChunkGraph ,在内部调用了visitModules函数,遍历ModuleGraph,此时会将Module按照依赖关系分配给不同的Chunk,如果遇到异步模块,那么就为此异步模块创建新的ChunkGroup和Chunk对象
在buildChunkGraph中调用connectChunkGroups方法,建立ChunkGroup 之间、Chunk 之间的依赖关系,生成完整的 ChunkGraph 对象
在buildChunkGraph中调用cleanupUnconnectedGroups,清除无效的ChunkGroup。
最终行程的关系图见下图。
经过上面的步骤,ModuleGraph中的模块,会按照本身的性质,分配到entry、async、runtime三个chunk中,并将chunk只见的依赖关系,储存在到ChunkGraph和ChunkGroup中,然后,在后续的流程中,会在这些对象的基础上修改分包策略,实现优化。
在这里,我们提到了三个概念,chunk、chunkGroup、chunkGraph,我们来了解一下他们的概念
chunk,构建Module阶段,从module读取内容,记录模块间依赖,chunk就是根据这些依赖,把多个module合并起来,形成一个中间产物。
chunkGroup,会包含一个或者多个chunk,chunkGroup和chunkGroup只见也会形成依赖关系。
chunkGraph,将上面chunk之间的关系、chunkGroup之间的关系,存储到compilation.chunkGraph之中,就是chunkGraph
分包
好的,我们又回来讲分包了,这次我们已经有了依赖图的知识,从更底层而非应用层,来看待分包的逻辑。
默认分包逻辑
我们之前讲过,module会被组织成三种不同类型的chunk:entry、async、runtime,这个是默认的分包逻辑。
那么他们是怎样进行划分的呢?
Entry
在上文中,我们了解到,webpack会对每一个entry创建chunk对象,初始化结束后,webpack会根据模块依赖关系,将entry下的,所有可触达的module塞入chunk,就是visitModules所做的事情,这样,入口文件以同步方式引用了这些module,然后webpack会为入口文件创建Chunk 与 EntryPoint,最终就形成了一个粗糙的打包产物。
Async
我们在上文的entry中,使用了这样的描述:入口文件以同步方式引用了这些module,那么异步模块呢?webpack会单独将每一个异步导入语句,都处理成一个单独的chunk,并且子模块也将加入到这个chunk之中,这个chunk就是async chunk。
这里需要注意的是,如果异步模块的子模块是同步引入的,那么也会被加入到async chunk之中,这样在chunk层面,就形成了单项的依赖关系,从而避免循环引用,然后webpack会把这个依赖关系保存在ChunkGroup._parents、ChunkGroup._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,然后又生成了产物代码,这个过程中,如果使用了新特性,会补充对应的运行时代码,最后行程最终产物。
那么模块是如何进行转译的呢?以及合包、分包这些逻辑是怎样与ModuleGraph、ChunkGraph互动的呢?
首先我们来思考一下,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就是负责生成代码的方法,这个方法有三个步骤
单模块转译:如同前文所说,这个方法会调用每个module.codeGeneration,而codeGeneration会调用JavascriptGenerator的generate 方法,然后遍历module对象的dependencies 与 presentationalDependencies 数组,然后执行每个数组项 dependeny 对象对应的 template.apply 方法。但会产生副作用,首先会修改soucre数据,然后将结果记录到initFragments,然后将运行时依赖记录到runtimeRequirements
收集运行依赖:在上一个步骤中,我们记录了运行时依赖,因此,在这一步需要将这些依赖收集起来,通过compilation.processRuntimeRequirements把runtimeRequirements转换成RuntimeModule 对象,并挂载到 ChunkGroup 中。
模块合并:此时我们有了运行时依赖,也有了模块转译后的代码,那么接下里就是以chunk为单位,将这俩按照规则塞到里面,并最后输出产物文件。
总结一下,整个流程是先遍历所有模块的依赖对象,收集编译结果和运行时依赖,然后将内容合并为打包产物。
单模块转译
既然大体流程搞清楚了,那我们深入了解步骤的详细情况,单模块转译是调用每个模块的codeGeneration开始,之后,会调用JavascriptGenerator.generate,遍历模块的dependencies 数组,依次调用依赖对象对应的 Template 子类 apply 方法更新模块内容。
我们来看一块JavascriptGenerator对应的源码
从源码中,可以看到,generate没有操作源码,而是提供了一个执行框架,转译是在Template.apply中进行的,而Template的工作除了转译,还有操作initFragments,也就是在模块源码之外补充代码片段,以及将运行时依赖记录到runtimeRequirements,也就是收集依赖步骤的依赖来源。
然后代码的翻译结果,也就是新代码,会在所有Dependency执行完毕后,跟着initFragments一起传入InitFragment.addToSource。
addToSource的逻辑分为三个部分
遍历InitFragment,按顺序合并运行时代码的开头
合并模块翻译后的
遍历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的逻辑是
先计算出bundle CMD代码,也就是__webpack_require__函数
计算出当前chunk下,除了entry外的模块代码
计算运行时模块代码
合并代码
合并CMD代码
合并运行时代码
遍历其他模块,合并除了entry以外的代码
最后合并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 |
|
我们可以看到,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的映射编码。
首先25的二进制是11001
由于是负整数,因此最右侧添加符号位1,变成110011
由于是六位一组,但没有添加连续符号位,因此针对数字是五位一组,所以空出一位来添加连续符号位,因此分组为【1 ,10011】,由于是从后往前分组,因此整理(也就是颠倒分组)一下,是【10011 ,1】
不足五位的需要左侧补充0,直到五位,也就是【10011,00001】
添加连续符号位,除了最后一组是0,其他组最后都是1,也就是【110011,000001】
然后转换成10进制,110011 => 51 000001 => 2
最后查表得,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进行关联起来,同理OAAO是7007,可以得到是源码中的【.】
devtool
webpack提供两种设置sourcemap的方式,一种是通过devtool的配置项进行设置,另一种就是使用SourceMapDevToolPlugin 或 EvalSourceMapDevToolPlugin 插件进行深度定制
devtool包含25种配置,可在官网了解到这25中配置的作用:devtool
devtool | performance | production | quality | comment |
(none) | build: fastest rebuild: fastest | yes | bundle | Recommended choice for production builds with maximum performance. |
| build: fast rebuild: fastest | no | generated | Recommended choice for development builds with maximum performance. |
| build: ok rebuild: fast | no | transformed | Tradeoff choice for development builds. |
| build: slow rebuild: fast | no | original lines | Tradeoff choice for development builds. |
| build: slowest rebuild: ok | no | original | Recommended choice for development builds with high quality SourceMaps. |
| build: ok rebuild: slow | no | transformed | |
| build: slow rebuild: slow | no | original lines | |
| build: slowest rebuild: slowest | yes | original | Recommended choice for production builds with high quality SourceMaps. |
| build: ok rebuild: slow | no | transformed | |
| build: slow rebuild: slow | no | original lines | |
| build: slowest rebuild: slowest | no | original | Possible choice when publishing a single file |
| build: ok rebuild: fast | no | transformed | source code not included |
| build: slow rebuild: fast | no | original lines | source code not included |
| build: slowest rebuild: ok | no | original | source code not included |
| build: ok rebuild: slow | no | transformed | source code not included |
| build: slow rebuild: slow | no | original lines | source code not included |
| build: slowest rebuild: slowest | no | original | source code not included |
| build: ok rebuild: slow | no | transformed | source code not included |
| build: slow rebuild: slow | no | original lines | source code not included |
| build: slowest rebuild: slowest | yes | original | source code not included |
| build: ok rebuild: slow | no | transformed | no reference, source code not included |
| build: slow rebuild: slow | no | original lines | no reference, source code not included |
| build: slowest rebuild: slowest | yes | original | no reference, source code not included |
| build: ok rebuild: slow | no | transformed | no reference |
| build: slow rebuild: slow | no | original lines | no reference |
| build: slowest rebuild: slowest | yes | original | no reference. Possible choice when using SourceMap only for error reporting purposes. |
看起来比较多,但实际上了解命名规律后,就会了解其中的规则,它们由inline、eval、source-map、nosources、hidden、cheap、module七个关键字组成。
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-map、eval-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一样,让我们来了解一下webpack的HMR。
webpack的HMR执行过程并不复杂
首先回事会用webpack-dev-server托管静态资源,然后以运行时的方式注入HMR客户端处理代码
页面加载之后,这些代码会与webpack-dev-server简历websocket链接
webpack监听文件变化,进行增量构建,也就是前文说的构建Module步骤,并通过websocket发送hash事件
浏览器收到hash事件,会请求manifest资源文件,确认变更范围
浏览器加载发生变更的增量模块
运行时触发模块的module.hot.acccept回调,执行代码变更逻辑
可以看到,我们都可以在vite中找到对应的处理方法,因此webpack的HMR对我们是很好上手的,
在前面,我们已经学习了在打包构建的时候,webpack如何注入运行时,与此对应的,是HMR场景下,webpack-dev-server首先会调用HotModuleReplacementPlugin插件,向主Chunk注入下面相关的HMR:
用来建立websocket链接、处理hash的代码
用于加载热更新的接口
用于处理热更新的接口
然后,HotModuleReplacementPlugin还会借助webpack的watch能力,在源码发生变化后进行增量构建生成:
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.accept是HMR暴露给用户重要接口之一,可以让用户自定义模块热替换的逻辑。
它接受两个参数
path:指定需要链接变更行为的模块路径
callback:模块更新后,将最新模块代码应用到运行环境的参数
但这里需要注意几个问题
module.hot.accept只接受具体路径的path,而非glob风格的批量路径,一旦某个模块没有注册此函数,HMR会启用默认的行为,通常是刷新页面,因此页面刷新是module.hot.accept起没起作用的标志
module.hot.accept函数只能捕获当前模块对应子模块的更新事件,而不能相邻模块的捕获,与DOM事件规范的冒泡
module.hot.accept还支持无参数调用,最用是捕获当前文件的变更事件,并从第一行重复运行当前模块代码
小结
到这里,我们大致了解了webpack的流转逻辑,但并没有像vite一样进行源码层级的分析,其中比较重要的原因是webpack发展这几年,沉淀的逻辑实在是太多太大了,并且表现晦涩,很难用短短几篇文章,就介绍的比较详细,因此这也是文章名称带有【简易】二字的原因,本篇文章随时涉及些许底层,但初衷在应用层把webpack配置好就行了,而更多的原理和解释,还得通过阅读webpack的文档以及源码才能参悟,需要我们需要的东西有很多。这并非我们的终点,而是我们新的起点。