前言
在前面三篇文章中,我们已经大致聊清楚了Rollup的构建流程,构建完成之后,其实这时候内存里面已经充斥了各个绑定着文件内容的Module类实例,接着要做的输出流程把这些内存中的数据写入到磁盘即可完成整个的前端工程构建工作。
之前我们已经聊过了,Rollup的Module类是管辖文件内容的类,但是最终内容会被合并起来,按Chunk输出,之前我们已经看到过Chunk这个类,接下来的篇幅里面,我们将着重和Chunk和Bundle类打交道。
好的,我废话就不多说了,接下来本文将开始向大家阐述Rollup的输出流程。
排序Module执行流程
首先我们先接上文,在这个位置已经拿到了所有的文件内容。
然后就要开始进行Module的排序。
为什么Module需要排序呢?很简单一个道理,JS的引用有先后顺序,如果我们事先访问一个不存在的变量,后面再去申明的话,是有可能报错的。
那么,又该怎么进行排序呢?假设我们有3个JS,分别是a.js->b.js->c.js这样的依赖关键,那么,因为c.js不依赖任何内容,所以c.js应该是可以最先被输出的,然后b.js依赖c.js,所以接下来就可以把b.js输出,因为a.js依赖b.js和c.js,所以a.js应该是最后被输出的。
这个过程,在数据结构里面有一个专题叫做拓扑排序
,我曾经写过一篇关于拓扑排序的文章大家可以作为参考学习:
拓扑排序在前端开发中的应用场景。
拓扑排序的使用条件是有向无环图(DAG)
,但是实际开发中是有可能写出循环依赖的,因此,Rollup内部肯定需要做一些错误case的判定。
接下来就看一下Rollup
的拓扑排序的过程。
我们得认真仔细看一下
analyseModuleExecution
这个函数,这个函数内部定义了一个analyseModule
函数,这个函数是用来做递归调用的,因为牵涉到递归调用,理解起来相对不是那么直观。
第一个for
语句,依次调用analyseModule
函数,那我们进入到analyseModule
看看这个函数完成了什么逻辑。
dependencies
存储了当前Module
依赖的内容,现在对其做深度优先遍历,之前有定义了一个记录引用的Map
,值为parents
,每处理一个Module,记录引用关系,大家要注意一下引用关系哦,a.js->b.js,那么b.js的parent是a.js。
nextExecIndex
是定义在最外层的变量,递归体在nextExecIndex++
语句的前面,也就是说,只有当递归处理到尽头的时候,nextExecIndex
的值开始增加,然后依次退栈,nextExecIndex
依次增加。
所以,还是回到之前我们举的例子:a.js->b.js->c.js,最终c.js的index就是1,b.js的index就是2,c.js的index就是3,将来排序的时候,c.js就可以被排到最前面了。
检测循环引用
循环引用在实际开发中是比较危险的操作,所以我们应当尽量减少编写循环引用的代码。
我们此时还没有聊到Chunk输出,不过现在可以给大家一些结论以示警:
当Module之间有循环依赖的时候,如果它们会被打包进入一个Chunk,这样的代码是没有问题的(回想一下是不是在上一篇文章中所看到的Rollup的fetchModule
,多个函数形成一个调用环),当循环依赖的Module被划分至多个Chunk的时候,生成的产物在加载的时候就会报错。
Rollup有自己的机制可以处理循环引用,以下是它的处理流程,我们来分析一下这段代码的执行过程:
首先这个函数执行的条件是已确定有循环依赖的哟,而不是去探测有无循环依赖。
这个逻辑就是一个典型的循环链表
的探测环,module
就是链表的头结点,我们不断地向后迭代,当下一个节点执行的节点就是module
的时候,我们读取到了一个完整的环,Rollup在处理的过程中,还往Module里面增加了一些节点信息,应该是为了后续处理的线索吧。
最后,我们看一下getCyclePath
函数的执行条件:
第一个if条件成立的时候,说明当前模块可能是已经处理过的模块,如果处理过的话,就不用再处理了,当这两个if条件同时成立的时候,说明有一大堆的Module节点无法完成拓扑排序,因此需要记录依赖环。
生成Chunk
在上述的工作完成之后,Rollup就要开始生成Chunk了,即决定把什么内容打包到一个代码块中去。
这儿我们暂时先不配置自定义分包的策略,我们就先看一下Rollup是如何自动进行分包的。
对于我这个测试项目文件的依赖关系,大家如果不清楚的话,可以查看我的第二篇文章,在那篇文章中我画了一个依赖图。
这儿是利用位运算用来生成键,当键相同的时候,Module就可以被划分到同一个Chunk内了。至于这个位运算的算法原理是什么,我不是很懂,所以我请教了一下Chatgpt。
以下是Chatgpt给我的答案:
在上述代码中,位运算(bitwise operations)的主要用途是通过位标记(bitmask)的方式为一组依赖入口(
dependentEntries
)生成一个独特的“签名”(chunkSignature
),从而将具有相同依赖入口集的模块归类到同一个输出分块中。具体来说:
let chunkSignature = 0n; for (const entryIndex of dependentEntries) { chunkSignature |= 1n << BigInt(entryIndex); }
这里的关键点在于:
使用
BigInt
和位移操作来创建位标记(Bitmask) :
1n << BigInt(entryIndex)
表示将一个初始值为1
的大整数左移entryIndex
位。在二进制层面,每左移一位,相当于在该数的二进制表示中新增一个位置标记。例如,若
entryIndex = 0
,1n << 0n
=1n
(二进制...0001
);
若entryIndex = 2
,1n << 2n
=100n
(二进制...0100
);
通过这种方式,每个不同的entryIndex
都对应二进制位图中的一个独特位置。使用按位或 (
|=
) 来叠加多个标志位:
当遍历所有dependentEntries
时,对chunkSignature
不断进行 OR 运算,把相应的位设为1。这相当于将所有出现在dependentEntries
集合中的entryIndex
转化为在二进制数chunkSignature
中置位。举个例子,如果
dependentEntries
= {0, 2, 5},则最终的chunkSignature
可能是(用二进制展示):
- 初始
chunkSignature = 0
(...000000
)entryIndex = 0
:chunkSignature |= 1n << 0n
=>...000001
entryIndex = 2
:chunkSignature |= 1n << 2n
=>...000101
entryIndex = 5
:chunkSignature |= 1n << 5n
=>...100101
最终
chunkSignature
是一个独特的二进制数,标识了本modules
所依赖的入口下标集合。意义:
当 Rollup 对多个ModulesWithDependentEntries
进行处理时,它以这个chunkSignature
(由位标记组成的二进制数)作为哈希键,将所有具有相同依赖入口集合的模块分为一组。这样,即使dependentEntries
是个集合,通过位运算,就可以直接用一个数字来唯一标识这组依赖集合,便于后续分包逻辑快速归类,避免使用复杂的集合比较。简而言之,位运算在这里的作用就是:
把一组依赖入口索引(Set)压缩成一个唯一的二进制标识(Bitmask),从而快速、高效地实现对具有相同依赖入口模式的模块进行分组。
当Chunk划分完成之后,接下来就要开始进行产物的输出准备了,到目前这些内容还只是挂载在Chunk的Module上的,还需要把这些Module的内容合并在一起(即打包)。
根据执行顺序进行一下排序,排序依据,我们在上一小节就已经处理完毕了的。
生成内容
目前的这一章节,我们先暂时不考虑TreeShaking,后续我们要拿一篇文章的篇幅专门对TreeShaking的处理逻辑进行分析,所以在这一章节我们主要是对Rollup的处理流程进行研究与分析。
调用每个Chunk的render函数,准备生成内容:
然后在render函数里面,开始处理Module内容的生成。
我们采用打断点的形式来看一下这个magicString:
好家伙,已经是打包合并的初加工产物了,但还是有些路径的哈希码还没有确定。
所以,我们得仔细瞧一瞧这个magicString是怎么得到的:
这一切的一切,都得归功于Module:
将AST转化成生成目标,进行输出。
目前我们暂时简简单单的分析这么多内容,后面我们专门来分析这个过程中Rollup完成的优化工作。
好了,接下来有个地方可以搞一些非常有趣的内容,即在生成的内容的header和footer插入一些自定义的内容。
/**
* _ooOoo_
* o8888888o
* 88" . "88
* (| -_- |)
* O\ = /O
* ____/`---'\____
* .' \\| |// `.
* / \\||| : |||// \
* / _||||| -:- |||||- \
* | | \\\ - /// | |
* | \_| ''\---/'' | |
* \ .-\__ `-` ___/-. /
* ___`. .' /--.--\ `. . __
* ."" '< `.___\_<|>_/___.' >'"".
* | | : `- \`.;`\ _ /`;.`/ - ` : | |
* \ \ `-. \_ __\ /__ _/ .-` / /
* ======`-.____`-.___\_____/___.-`____.-'======
* `=---='
* ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* 佛祖保佑 永无BUG
* 佛曰:
* 写字楼里写字间,写字间里程序员;
* 程序人员写程序,又拿程序换酒钱。
* 酒醒只在网上坐,酒醉还来网下眠;
* 酒醉酒醒日复日,网上网下年复年。
* 但愿老死电脑间,不愿鞠躬老板前;
* 奔驰宝马贵者趣,公交自行程序员。
* 别人笑我忒疯癫,我笑自己命太贱;
* 不见满街漂亮妹,哪个归得程序员?
*/
相信这段代码大家都看到过,我们可以把它配到我们的测试项目里面去,看看效果
源码是在这个位置,我们希望仅仅配置在入口文件处生成这个祈福注释,试试看看怎么根据Chunk信息来判断,嘿嘿。
最终的产物:
接下来,我们就要对之前的粗加工的打包代码进行精加工了,即把之前代码里面引用的文件路径替换成最终带有哈希码的真实的文件相对路径。
接下来,就根据生成的哈希码替换之前粗加工代码预留的标记位置即可:
以下是具体的替换过程:
到现在这个位置,Rollup已经完成了构建内容的打包分块,已经完成了所有生成内容的准备工作了,就差将打包的内容写入到磁盘了。
接着,把剩下的生命周期处理了,就完成磁盘内容写入。
输出生命周期总结
以下是Rollup官方文档上的输出生命周期流程图:
接下来,我为大家阐述我通过源码学习到的Rollup生命周期的执行流程。
首先,触发outputOptions
,然后开始触发renderStart
生命周期,这2个没有什么说的。
接着,Rollup开始根据我们之前在构建过程中得到的Module
信息,开始划分Chunk,划分好了Chunk之后,开始为输出准备工作,需要把Chunk上关联的每个Module的内容转换成可以合并的内容,这个时候,就会触发banner
、footer
、intro
、outro
等生命周期,可以在Module上追加一些版权信息的权利。
此刻,就可以把这些文件的内容合并成一个粗加工的内容了,此刻的内容的资源引用路径还是Rollup内部逻辑的标识,然后就可以对外触发renderChunk
生命周期,然后,根据用户配置的内容,得到生成哈希的依据,这个时候对外触发argumentChunkHash
生命周期,当哈希码准备好之后,之前粗加工的内容就可以根据生成的哈希码替换内容的引用规则,变成真正的成熟的对外引用的相对资源路径,然后对外触发generateBundle
的生命周期。
到这个位置,所有的内容都已经准备在内存中了,就差写入到磁盘了。
再然后,Rollup把之前内存中的内容写入到磁盘,对外触发writeBundle
生命周期,最后触发closeBundle
生命周期。
以上的所有逻辑均包裹在一个try-catch
的逻辑中执行的,一旦遇到任何错误,立即执行renderError
生命周期,提前终止构建流程。
以上生命周期流程没有分析renderDynamicImport
,resolveFileUrl
,resolveImportMeta
,在后文我们在抽时间分析。
结语
到这个位置为止,我们基本上就已经把Rollup的输出的大概流程搞清楚了。不过,本文中讲述的内容还是属于宏观层面上的,而对Rollup真正核心、复杂的处理逻辑(比如TreeShaking
)还是冰山一角。
在下一篇文章中,我们将向大家讲述TreeShaking的处理过程,在后续的文章中,我们将会详细向大家阐述生命周期的处理流程,并且向大家解释清楚Rollup那几个核心类的角色定位。
本文的内容还是相对丰富饱满的,希望大家可以喜欢,未完待续.....