likes
comments
collection
share

Webpack 源码调试过程记录 - 主要流程、最简情况(单入口,无其它引入)

作者站长头像
站长
· 阅读数 62

本文没有华丽的标题,也没有插图,主要是早前记录的一些笔记(所谓“文字直播”)。

相关步骤可能不准确,因为笔者也是第一次调试Webpack源码。如有错误的地方欢迎指正。

文章中有一些 TODO项,是当时、在程序执行到相关步骤时突然想到的一些问题。

本文或许可以给需要调试Webpack源码的读者提供一些参考。请对照源码、打好断点,阅读本文。

如果没有用过Webpack,或者是Webpack的初学者,本文可能不适合你。

调试版本

webpack@5.75.0

本次上下文

  1. 单入口打包,入口仅仅引用了Js脚本文件,没有其它文件。

  2. 初始化阶段已结束,进入构建阶段

当前断点位置

准备调用compilation.addEntry方法。

调试过程

进入_addEntryItem;此处进行一个entryData的判断(TODO1 这个判断是干什么的?与缓存有关吗?)然后调用addModuleTree

addModuleTree中调用handleModuleCreation,生成、创建模块

如果handleModuleCreation中通过毁掉抛出任何错误,且bail为真值(TODO2 那么bail是什么?),那么看起来 buildQueue、rebuildQueue、processDependenciesQueue、factoriesQueue这几个队列会被停掉?(TODO3 这几个队列是做什么的?)

进入handleModuleCreation,其中调用factorizeModule方法,用于……分解模块?(TODO3 到底是什么……)

看起来factorizeModule这个过程是一个异步过程,通过一个队列来进行调度,这里仅仅是一个函数的入口🤔 最终给到异步队列进行调用的,实际上是_factorizeModule方法 —— 里面包含了一些真实的操作

到这里,所有同步操作结束,此时调用栈会被依次清空。异步操作的回调将在后续可以执行的时候执行。

此时complier已经建立完成。

接下来开始处理队列。现在执行的是上面提到的_factorizeModule。其中会执行factory.create。factory由外部动态传入,类型不定,但均包含一个create方法。当前上下文中,factory是NormalModuleFactory(的实例?)。因此下一步将进入NormalModuleFactory.prototype.create方法

NormalModuleFactory.prototype.create方法,没细看……看起来就收集了一些模块相关信息,然后依次调用了beforeResolve、factorize钩子。factorize钩子调用过程中,将会通过回调,把当前factorize(创建?分析?)出来的模块,给传到某个地方(???)

跟丢了……我想想

看起来这里factorize钩子的调用(factorize.callAsync)不简单。 调用钩子时,传入了一些解析出来的数据。根据这些数据,可以生成module(TODO4 暂时不是很想看此处的过程……)生成module后传给factorize.callAsync的回调函数,加入到要返回的对象中。然后执行回调,依次往上调用回调,就回到了factorizeModule的回调中。此时就可以继续其它操作。

在factorizeModule回调中,将会调用addModule。addModule也只是一个把任务放进队列里的函数,具体操作是在_addModule中执行。

addModule后,执行一对乱七八糟的同步操作(TODO5 暂时没注意这些操作是什么……),然后才进入_addModule函数

_addModule中会判断一下当前模块是不是已有模块,即根据模块id,判断_modules中有没有这个模块,如有,直接调用回调函数并返回;没有的话,好像会进行一些操作,然后把模块加到_modules中。(TODO6 这些操作什么?看起来附近代码有一些cache相关的东西?) 后续将进入addModule的回调

在addModule回调函数中,将会调用applyFactoryResultDependencies这个函数。看了看这个函数,大致作用是将factoryResult中的fileDependencies、contextDependencies、missingDependencies设置到当前compilation的对应变量中。

之后对每个dependency调用moduleGraph.setResolveModule,大意应该是把当前依赖放到模块图上?

之后调用的是_handleModuleBuildAndDependencies。其中将会调用buildModule函数 —— 看起来终于要进入buildModule阶段了 此处buildModule函数,同样也仅仅是把buildModule这个操作放入了buildQueue这个异步队列。具体操作定义在_buildModule函数上。

在从buildModule进入_buildModules之前,也会进行一些乱七八糟的工作(看起来和Cache有关);这些工作执行完后,将开始依次处理异步队列中的任务。这样后续将会执行到_buildModules。

现在,进入_buildModule看看里面有什么……

_buildModule中首先会执行一个needModule函数,其回调结果needBuild将指明它要不要被build(我猜的,因为如果它是false后面流程就走不下去了),然后触发buildModule钩子、将当前模块插入已构建模块集合,然后执行module.build函数 —— 这里将进入NormalModule.prototype.build函数中

在build中进行一些信息收集后,将会进入_doBuild。首先执行_createLoaderContext获取loader上下文。接下来执行runLoaders(❗️TODO7 Webpack中的loader是在这里被调用的吗?看起来好像是的!!具体实现后续再看)。直接进入回调函数,再进入processResult中。目前,source是一个Buffer对象,其中包含了文件内容(这里目前只有一个Js文件,且未引入或导出任何内容)。这里通过createSource拿到文本内容,赋值给_source。接下来可以看到一个为_ast赋值的操作,看起来这里暂时为null。以上操作完成后,执行回调,进入_doBuild的回调函数。

_doBuild回调首先定义了一些函数,用于处理: parse 错误、成功 build 完成

之后通过调用parser.parse,来把代码文本转换为ast。在NormalModule中不能直接看到转换出来的ast结构,但在parse函数中可以看到。

parser.parse的定义在JavascriptParser.prototype.parse下。这里获得ast后,也会遍历ast。遍历到不同语句时,将执行各种不同的钩子(Webpack文档-API-JavascriptParser章节可以看到)

太困,跟丢了……但后续要跟的部分大致包括

  1. 模块之间的依赖关系是怎样确立的?(猜想应该是遍历ast过程中,拿到了import/require语句,这样的话模块之间可以产生一个图,最终遍历这个图,得到依赖关系)
  2. 编译好的模块是在哪里被写入硬盘的?
  3. Webpack是怎样对不同类型的导入导出语句做转换的?

继续文字直播。 (TODO8 parse.parser中为何会有形如oldScope = this.scope 这样的语句?这个语句的作用是什么?)

parser.parse调用的结束后,进入了handleParseResult函数中即前文说的再_doBuild中定义的处理parse成功的函数。这里看起来会对dependencies进行一次排序。我猜是会对模块进行排序。不过我怀疑,因为本次上下文中仅有一个Javascript文件(即入口文件),因此这个数组是空的。 接下来执行_initBuildHash,其中看起来会根据当前模块的代码进行计算,获得hash,并赋值给this.buildInfo.hash。

handleParseResult在函数末尾将会调用handleBuildDone函数。看起来这里会检查一下dependencies中包不包含非绝对路径的dep?没懂 之后会调用compilation.fileSystemInfo.createSnapshot来建立一个……文件系统的快照……?建立结束后,将给当前NormalModule的buildInfo.snapshot赋值为snapshot,buildInfo.fileDependencies、buildInfo.contextDependencies、buildInfo.missingDependencies将置为undefined;接下来将调用build中传入的回调函数,这将回到Compilation的module.build的回调函数中。

在module.build中,看起来将会在_modulesCache.store中缓存一下当前模块。缓存完后,将调用_modulesCache.store的回调函数。其中将会触发successModule钩子。接下来将调用_buildModule的回调函数。

_buildModule的回调函数即buildModule的回调函数。其中会调用processModuleDependencies。同样该函数也仅仅是将处理模块依赖这个地址放到异步队列中。真正的操作位于_processModuleDependencies。后续在处理队列时将会调用_processModuleDependencies。

断点来到_processModuleDependencies,这里可能是对模块进行排序的地方? 这里定义了: onDependenciesSorted onTransitiveTaskFinished processDependency processDependencyForResolving 函数。

首先看到这里有个队列,遍历时把元素弹出,然后对元素下方dependencies中的元素执行processDependency函数。如果当前元素下方blocks属性中有元素,则把执行元素放到队列中,继续迭代队列。

感觉这里涉及到的都是图的排序了吧?似乎是拓扑排序?

由于这里上下文中一直都只有一个Javascript文件,无其它导入导出,因此无需进行队列中的一些操作,直接来到函数末尾,进入onDependenciesSorted函数。

断点来到onDependenciesSorted函数,同样由于上下文中一直只有一个Javascript文件且其中没有导入或导出任何内容的关系,sortedDependencies将是一个空数组。

之后调用到processModuleDependencies的回调函数,然后再进入handleModuleCreation的回调函数,再进入handleModuleTree的回调函数并触发compilation的succeedEntry钩子,然后进入EntryPlugin中compilation.addEntry的回调函数,然后进入了compiler的make钩子的回调函数

接下来将会触发finishMake钩子(TODO9 此时触发它有什么具体作用吗?)nextTick后,将执行compilation.finish方法,在finish方法的回调函数中再调用compilation.seal方法,再在seal方法的回调函数中再触发complier的afterComplie钩子。最后,再调用compiler.complie中的回调函数,即onCompiled。

在compilation.seal中,第一次出现了chunk这个概念🤔

继续文字直播。 今天预计再看从compilation.finish调用compilation.seal,以及之后的一些过程。

进入compilation.finish方法后,首先会清空compilation.factorizeQueue队列,然后……计算受影响的模块(_computeAffectedModules)(TODO10 这是要做什么?没进入该函数细看,后续可以看看),接下来将触发compilation的finishModules钩子。然后获取这些模块中存在的警告或错误,分别放入当前compilation的error、warn数组中(TODO11 这是要做什么?莫非要在某个地方输出这些错误?后续可以关注一下)。 在获取模块警告或错误前后,分别调用了当前compilation的moduleGraph.freeze及moduleGraph.unfreeze(TODO12 为何要调用这两个方法?)。 接下来进入compilation.finish的回调函数中,在这里,将会调用compilation.seal方法。

断点现在来到了compilation对象上的seal函数。看起来,首先会根据moduleGraph创建一个chunkGraph(TODO13 后续看看相关过程),之后触发compilation的seal钩子,然后触发compilation的optimizeDependencies、afterOptimizeDependencies钩子(TODO14 这里面的优化操作是什么?压缩代码吗?),接下来触发compilation的是beforeChunks钩子

……接下来有300行代码堆在这里……🫠

首先将开始一个for循环。这个循环将循环webpack打包配置中的所有入口。由于本次上下文仅有一个入口,因此for循环只执行一次。 首先执行addChunk函数,来创建一个新chunk/复用已有chunk并且加入到chunks中。 然后建立一个entrypoint对象(TODO15 这个entrypoint对象是干什么的?),并以当前chunk来setRuntimeChunk、setEntrypointChunk;接下来在namedChunkGroups、entrypoints、chunkGroups中加入当前entrypoint的相关信息,然后执行connectChunkGroupAndChunk方法。

下面再进入一层for循环,这次遍历的是全局入口的依赖以及当前入口的依赖拼成的数组。在entrypoint中加入当前依赖的相关信息。然后根据当前依赖,从moduleGraph中获取对应模块。下面执行chunkGraph.connectChunkAndEntryModule,参数为当前chunk、module与entrypoint,然后在entryModule中加入当前module。接着根据entrypoint来从chunkGraphInit中获取此entrypoint对应的模块列表,并将当前module插入到模块列表中。 此时本层级for循环结束,继续外层for循环。

接下来调用assignDepths方法,(看起来好像是设置某个深度的值?)然后对所有依赖进行排序(mapAndSort方法:根据dep从moduleGraph中获取模块,并按照模块Id(看起来是文件路径)进行排序)。下面根据entrypoint从chunkGraphInit中获取模块列表,获取后,为includedModules中的module进行一次for循环,为每个module调用一次assignDepth方法,然后在模块列表中压入当前for循环遍历到的项目。然后此时本层级for循环结束,回到上一层级。

回到上一层级后,上一层级的for循环也结束。

上一个for循环结束后,下面来到的是另一个for循环。这里的for循环以outer为标记,循环的内容也是所有compilation的入口。好像里面是在处理dependOn、runtime两个配置的一些关系?这里后续再看吧,暂时不看了……

之后进入buildChunkGraph函数,大意是,根据ModuleGraph来建立ChunkGraph。函数入参是compilation及chunkGraphInit(TODO16 chubkGraphInit到底是什么?)

之后会触发一个compilation的optimize钩子(TODO17 所以上面的过程是在优化一些东西吗?还是其它一些操作?)

接下来主要都是compilation上各个钩子的一些调用,暂时不看了

不过根据网上的一些文章,如果配置了SplitChunksPlugin插件,那么在optimizeChunk钩子时,将会进行一些操作(TODO18 SplitChunksPlugin具体操作是指什么?待分析此插件)

下面就该进入codeGeneration函数了

继续开始文字直播~现在断点进入codeGeneration函数。

codeGeneration函数是compilation对象上的一个方法。首先创建一个map用以存储codeGenerationResults,接下来遍历compilation上modules中的每一个module,依次作为chunkGraph.getModuleRuntimes的参数进行调用,来获得runtimes。根据这些runtimes,生成一个job。接下来调用compilation上的_runCodeGenerationJobs,来……执行这些job(?)

下面断点来到_runCodeGenerationJobs函数中。这里可以看到一个runIteration函数的定义。看起来job将会在这个函数中被调度、执行;该函数中有一个asyncLib.eachLimit的函数调用,共传入了4个参数,分别是 jobs列表、 parallelism(并行任务数?)、 迭代函数(类似Array.prototype.filter的参数)、 任务处理结束/出错时的回调函数。 迭代函数中将会调用到compilation上的_codeGenerationModule方法,断点进入这一方法。暂时跳过缓存部分,来到module.codeGeneration的调用。这里来到的是NormalModule的codeGeneration方法。里面可以看到一个for循环,循环的是模块的类型。目前仅有一种类型,即javascript。来到此处for循环下的this.generator.generate调用。这里将来到JavascriptGenerator中的generate方法下。……不过这里不是重点,因此回到上面的for循环中(TODO19 JavascriptGenerator中的generate方法做了什么?)。接下来for循环结束,将{sources, runtimeRequirements, data}作为resultEntry返回到compilation中相关变量(_codeGenerationModule下result),同时断点回到对应函数(_codeGenerationModule)。之后为result进行缓存,再调用回调函数,回到compilation下codeGeneration方法的回调函数中。

在codeGeneration的回调函数中似乎还会再执行一次_runCodeGenerationJobs函数,第一个参数是刚刚新创建的codeGenerationJobs,第二个参数操作完了以后的回调函数。不过本次上下文中,这一数组为空,因此并不会做其它操作,将进入回调函数。

接下来将调用this.createChunkAssets函数。在此之前将触发shouldGenerateChunkAssets同步钩子来决定要不要执行这个函数。此上下文中该函数将会执行。断点进入this.createChunkAssets。 这里可以看到输出配置,然后异步地遍历this.chunks。其中将根据当前compilation的一些信息,产生一些manifest,然后再进入一层异步遍历,对每一个manifest(fileManifest)进行遍历。下面将进入一个try...catch块,首先确定要产生的文件文件名是什么,确定source应当从何处取值(已缓存的source、已被写入的chunk文件或通过fileManifest.render生成一个并将新的source缓存到cachedMap中)。

下一步执行的是当前compilation的emitAsset方法。(这是要开始写文件了吗?!不过进去看了好像还不到,因此先跳过)然后是触发compilation的chunkAsset钩子。然后在已被写入的chunk文件数组中加入当前项{hash, source, chunk}(实际上此时好像还没产生文件?)然后在已缓存的source中也缓存一下当前source。 之后一路执行回调函数,进入createChunkAssets的回调中。在这里,将执行cont函数。其中将会触发compilation的afterSeal的异步钩子。之后将调用finalCallback。finalCallback中将会清空掉 factorizeQueue、buildQueue、rebuildQueue、processDependenciesQueue、addModuleQueue,然后执行seal中传入的回调。 以上,compilation的seal过程结束

现在,断点回到了compiler.js调用compilation.seal时传入的回调函数中。下面将会触发compiler的afterCompile钩子。然后在这里将会执行到compiler的回调函数,即onCompiled中。

现在断点来到了onCompiled; 从这里开始,应该就有一些往文件系统写文件的操作了吧。 首先执行shouldEmit同步钩子,来确定compilation对应的文件该不该被输出,这里暂时认为文件都应该被输出。nextTick后,执行compiler的emitAssets。 现在断点进入emitAssets,触发compiler的emit钩子,在钩子中调用mkdirp(this.outputFileSystem, outputPath, emit),来输出最终文件。

现在继续文字直播……真困了

目前断点进入emitAssets下emitFiles方法,这个方法中将执行一个asyncLib.forEachLimit这样一个队列。

呃,好吧,凌晨一点,瞌睡来了,我也不知道我在写什么东西;晚安

首先还是来到mkdirp,这个函数看起来用于确保文件输出目录,即outputPath的存在。接下来将会进入mkdirp的回调,即emitFiles方法。

断点来到emitFiles方法中;首先会获取到compilation中的所有assets;通过asyncLib.forEachLimit,来遍历处理这些assets(TODO20 这里可能是异步处理?好像这里可以限制一次处理的次数?这里是15个)。 接下来进入第三个参数,即对于每个asset项都将会执行一次的函数。这里结构出了asset的name,source,info。然后执行writeOut函数。(TODO21 writeOut函数的具体内部实现后续再看)writeOut函数中存在一个doWrite函数,其中将会执行this.outputFileSystem.writeFile。在这里加断点,并执行到这里。不过,这个函数具体实现暂时也不看。直接进入回调函数中。在回调函数中第一行处加断点,断在这里,此时可以看到文件系统中已经出现了打包出来的文件(🤩),后面在这里进行一些操作,例如缓存(我猜是为了避免重复输出),然后触发compiler的assetEmitted钩子。触发的时候,会把用于标识当前队列项处理完成的callback作为参数。因此或许它就是这个钩子的处理函数?

由于本次上下文里仅包含一个js文件,因此队列处理函数仅执行一次,因此改项处理后,队列即处理完成;下面将进入队列处理完成的回调函数。这里会触发compiler的afterEmit钩子,在处理函数中,执行整个emitAssets钩子的回调函数,标识emitAssets完成。因此下面断点回到了onCompiled中定义的this.emitAssets的回调函数中。

现在继续文字直播 断点进入onCompiled中定义的this.emitAssets的回调函数中

在这里接下来会执行到emitRecords函数,好像是写一下构建记录?不过这里没有指定,因此直接进入回调。回调中会收集一下当前compilation的一些构建统计(开始时间、结束时间、Hash之类的)来赋值给stats变量,然后将触发compiler上的done钩子;在done钩子处理函数中,似乎会对缓存进行一些处理(this.cache.storeBuildDependencies调用),然后在缓存处理的回调函数中,将执行finalCallback,执行时将带着stats参数。

断点来到finalCallback函数,此处没有很多特别的逻辑。 其中将会调用:在执行compiler.run时,传入的回调函数,参数即是stats。断点进入这一回调函数,将执行compiler.close方法。该方法将关闭watching,并触发compiler的shutdown钩子,然后执行this.cache.shutdown方法 — 在这里将执行从compiler.close传入的回调,因此断点进入到compiler.close的回调中。 这里仅有一行调用回调的方法,参数是stats,这里的回调是webpack-cli在runWebpack中调用createCompiler(compiler = this.webpack 附近)时传入的。因此现在断点来到了这里。 进入这个回调,首先看看有没有错,有的话程序将可能会推出且exitCode不为0;接下来跳过一些步骤,来到this.logger.raw(printedStats),这里会在终端里打印经过格式化处理的stats信息(也就是我们平常看到的输出哪些文件、构建了多久一类的信息)

接下来一路单步调试(Step Over),回到finalCallback中,触发一下compiler的afterDone钩子,参数为stats。

接下来就没有过多实质性的内容了,可以看到调用栈里的调用(或者说应该是异步函数产生的调用?)一直在不断地变少,直到调用栈被清空。

此时进程退出。

读后

好了,马马虎虎走过了一遍,终于执行完了……8号到16号,看了差不多一个星期;而且中间一些重要步骤暂未覆盖,因为本次调试上下文真的很简单🫠

调试难点感觉主要是一大堆:

  1. 回调地狱
  2. asyncLib.forEach / asyncLib.forEachLimit
  3. 钩子的执行
  4. 任务调度 真心头大,这些让调试之路变得十分崎岖。🤯

参考资料

  1. [万字总结] 一文吃透 Webpack 核心原理 由范文杰撰写 这篇文章帮我厘清了Webpack的基本逻辑,我在调试无助的时候总能在这篇文章里找到一些答案
  2. Webpack 官方网站 Plugins相关章节 主要大致了解了一下各个钩子的作用

后续总结(TL;DR)

根据上文,再次跟踪相关断点,对Webpack执行过程进行大体总结。

1. 初始化阶段

这一阶段要做的事情是,得到编译过程中将要使用的compiler。将webpack.config.js中的配置与Webpack的默认配置进行合并,同时执行所有插件的apply函数,然后将返回本次编译使用的compiler。

2. 构建阶段

compiler.compile的执行标志着构建阶段的开始。这一阶段要做的事情是,创建module。进入compiler.compile后将触发compiler的beforeCompile、compile钩子;接下来将创建本次编译对应的compilation对象,并将其作为参数依次触发此阶段compiler的make、finishMake钩子。make钩子触发后,finishMake钩子触发前,将执行已注册的EntryPlugin中对应钩子的一些逻辑。其中将调用compilation.addEntry;经过一些调用后,将进入compilation.handleModuleCreation后的逻辑,其中将会 ① 运行loader,将任意导入的文件转换为Js代码 ② 遍历Js ast的导出、导入语句,确定各module之间的的依赖关系 在compilation.handleModuleCreation执行完成,进入其回调函数后,将可以看到已经得到的Js源码。之后再回调,将会进入make钩子的回调函数,并接着触发finishMake钩子。其中将会执行到compilation.finish函数。此时,构建阶段便已结束。

3. 生成阶段

compilation.finish的回调函数中的compilation.seal是这一阶段的开始。这一阶段要做的事情是,将module转换(整理?)为用于进行输出的chunk。其中的this.createChunkAssets函数调用,会执行fileManifest.render函数,从而获取到当前chunk对应文件的内容,相关信息会附加到当前chunk上。后续将触发到compilation的afterSeal钩子,并进入compilation.seal的回调函数中。在这里将会触发compiler的afterCompile钩子。此时就认为生成阶段结束了。

4. 文件输出阶段

compiler.run中onCompiled的调用标志着这一阶段的开始。这一阶段要做的事情是,将chunk作为文件,输出到文件系统。其中首先会触发compiler的shouldEmit钩子,来确定要不要输出文件到文件系统,然后在this.emitAssets中,触发emit钩子;接下来调用到emitFiles函数,来从compilation中获取到所有asset,再通过writeOut->doWrite这样一些调用,把文件写入文件系统,每写入一个文件,会调用触发一次assetEmitted钩子。后续将继续触发compiler的afterEmit、done钩子,然后在控制台打印一些已输出到文件的信息,然后触发compiler的afterDone钩子。

到此,所有过程全部结束。

转载自:https://juejin.cn/post/7189526259720781884
评论
请登录