当前位置: 首页 > 科技观察

为什么模块循环依赖不是无限循环?CommonJS和ESModule的处理有什么区别?_0

时间:2023-03-13 16:14:08 科技观察

大家好,我叫念念。如果被问到“CommonJS和ESModule的区别”,大概每个前端都会背几条:一是导出值的副本,二是对导出值的引用;一个是在运行时加载的,另一个是静态编译的。。。本文将重点讨论两者在处理“循环导入”方面的区别。本文将阐明:CommonJS和ESModule解决循环引用的原理是什么?CommonJS中的module.exports和exports有什么区别?导入模块时的路径解析规则是什么。JavaScript的模块化首先说说为什么会有两种模块化规范。众所周知,早期的JavaScript并没有模块的概念。引用第三方包时,变量直接绑定到全局环境。以axios为例,在引入script标签的时候,其实是给window对象绑定了一个axios属性。这种全局引入会导致两个问题,变量污染和依赖混乱。变量污染:所有脚本都在全局上下文中绑定变量。如果有重名,后面的变量会覆盖前面的变量。依赖混淆:当多个脚本相互依赖时,相互之间的关系不明确。所以你需要使用“模块化”来隔离不同的代码。事实上,模块化规范远不止这两类。JavaScript官方已经很久没有给出解决方案,所以社区实现了很多不同的模块化规范。按照出现的时间,有CommonJS、AMD、CMD、UMD。最后就是JavaScript在ES6中官方提出的ESModule。听起来很多,但其实我们只需要重点了解CommonJS和ESModule即可。一来面试基本上只问这两个,二来实际使用中用的最多的就是这两个。CommonJSCommonJS的发明者希望它能使服务端和客户端通用(Common)。但是如果你一直从事纯前端开发,应该不会对它很熟悉,因为它最初叫ServerJS,主要用在Node服务器上。本规范将每个文件视为一个模块,先看其基本用法://index.jsimportconsta=require("./a.js")console.log('runentrymodule')console.log(a)//a.jsexportsexports.a='amodule'console.log('runamodule');我们使用require函数导入模块,使用exports对象导出模块,其中requireexports是CommonJS规范提供给我们的。使用断点调试,我们可以看到这些核心变量:exports记录当前模块导出的变量。module记录当前模块的详细信息。需要导入模块。exportsexport首先看exportsexport,面试中经常被问到的一个问题是exports和module.exports有什么区别。两者都指向同一个内存,但用法并不完全等同。绑定属性时,两者是相同的。exports.propA='A';module.exports.propB='B';不能直接赋值给exports,即不能直接使用exports={}的语法。//导出失败={propA:'A'};//成功module.exports={propB:'B'};虽然都指向同一块内存,但是最后导出的是module.exports,所以不能直接赋值给exports。同理,只要最后直接给module.exports赋值,之前绑定的属性就会被覆盖。exports.propA='A';module.exports.propB='B';module.exports={propC:'C'};如上例所示,先绑定propA和propB这两个属性,然后给module.exports赋值,最后只有propC能成功导出。require导入CommonJS的特点是值复制。简单的说,就是将导出的值复制一份,放到一块新的内存中。循环引入接下来进入正题,CommonJS是如何处理循环引入的。我们先来看一个例子:入口文件引用模块a,模块a引用模块b,模块b引用模块a。你可以想想输出会是什么。//index.jsvara=require('./a')console.log('入口模块引用a模块:',a)//a.jsexports.a='原始值-a模块中的一个变量'varb=require('./b')console.log('a模块引用b模块:',b)exports.a='修改后的值-模块中的a变量'//b.jsexports.b='原始value-bmoduleinnervariable'vara=require('./a')console.log('bmodulereferencesamodule',a)exports.b='Modifiedvalue-bmoduleinternalvariable'输出结果如下:这个AB模块相互引用应该是死循环,其实不是,因为CommonJS做了特殊的处理——模块缓存。还是使用断点调试,可以看到变量require上有一个属性缓存,就是模块缓存。逐行看执行过程。【入口模块】开始执行,将入口模块加入缓存。vara=require('./a')执行将模块a加入缓存,进入模块a。[amodule]exports.a='originalvalue-variableinmodulea'被执行,变量a在模块a的缓存中被初始化为原始值。执行varb=require('./b'),将b模块加入缓存,进入b模块。[Moduleb]exports.b='originalvalue-variableinmoduleb',变量b在模块b的缓存中被初始化为原始值。vara=require('./a'),尝试导入一个模块,发现已经有一个模块的缓存,所以不会进入执行,而是直接去取一个模块的缓存,此时print{a:'原始值-模块变量'}。exports.b='Modifiedvalue-variableinmoduleb执行,用修改后的值替换模块b缓存中的变量b。[amodule]console.log('amodulereferencesbmodule:',b)执行,获取缓存中的值,打印{b:'Modifiedvalue-variableinmoduleb'}。exports.a='Modifiedvalue-variableinmodulea'执行,将模块a缓存中的变量a替换为修改后的值。[Entrymodule]console.log('Theentrymodulereferencesmodulea:',a)执行,取缓存中的值,并打印{a:'modifiedvalue-variablesinmodulea'}。以上就是处理循环引用的过程。循环引用无非就是解决两个问题,如何避免死循环和输出值是多少。CommonJS通过模块缓存来解决:每个模块在执行前都会加入缓存,每次遇到require都会检查缓存,这样就不会死循环;借助缓存,可以轻松找到输出值。多次导入也是由于缓存,一个模块不会被执行多次。我们看下面的例子:入口模块引用了a和b两个模块,a和b两个模块分别引用了c模块。没有循环引用,但是c模块被引用了两次。//index.jsvara=require('./a')varb=require('./b')//a.jsmodule.exports.a='原始值-模块中的a变量'console.log('a模块执行')varc=require('./c')//b.jsmodule.exports.b='原始值-模块b中的变量'console.log('b模块执行')varc=require('./c')//c.jsmodule.exports.c='Originalvalue-variablesinthecmodule'console.log('cmoduleexecution')执行结果如下:可以看到,c模块只执行一次,第二次引用c模块时,发现已经有缓存了,直接读取,不再执行。路径解析规则路径解析规则也是面试中经常考到的一个点,或者为什么我们在导入的时候干脆写个react就能正确找到包的位置。如果仔细查看模块变量,您会发现还有一个属性路径。首先对路径做一个简单的分类:内置核心模块、本地文件模块、第三方模块。对于核心模块,node已经编译成了二进制代码,可以直接写标识符fs和http。对于自己写的文件模块,需要以'./''../'开头,require会将这个相对路径转换成真实路径,找到模块。对于第三方模块,即使用npm下载的包,会使用可变路径,依次查找当前路径下的node_modules文件夹。如果没有,则在父目录中搜索no_modules,直到找到根目录。直到。在node_modules下找到对应的包后,会以package.json文件下的main字段为准,找到包的入口。如果没有main字段,则搜索index.js/index.json/index.node。ESModule虽然叫做CommonJS,但它并不是Common(通用),它的影响范围只是在服务端。前端开发比较常用的是ESModule。ESModule使用import命令导入导出导出。语法比较复杂,熟悉的可以跳过这部分。普通进出口。//index.mjsimport{propA,propB,propC,propD}来自'./a.mjs'//a.mjsconstpropA='a';letpropB=()=>{console.log('b')};varpropC='c';export{propA,propB,propC};exportconstpropD='d'usingexport可以写成对象的集合,也可以是单独的变量,需要和导入的变量名对应默认的导入导出。//导入函数importanyNamefrom'./a.mjs'exportdefaultfunction(){console.log(123)}//导入对象importanyNamefrom'./a.mjs'exportdefault{name:'niannian';location:'guangdong'}//导入常量importanyNamefrom'./a.mjs'exportdefault1使用exportdefault语法实现默认导出,可以是函数,也可以是对象,也可以只是常量。默认意味着导入时可以使用任何名称。混合导入、导出。//index.mjsimportanyName,{propA,propB,propC,propD}from'./a.mjs'console.log(anyName,propA,propB,propC,propD)//a.mjsconstpropA='a';letpropB=()=>{console.log('b')};varpropC='c';//commonexportexport{propA,propB,propC};exportconstpropD='d'//defaultexportexportdefaultfunctionsayHello(){console.log('hello')}导入所有。//index.mjsimport*asresNamefrom'./a.mjs'console.log(resName)//a.mjsconstpropA='a';letpropB=()=>{console.log('b')};varpropC='c';//Commonexportexport{propA,propB,propC};exportconstpropD='d'//DefaultexportexportdefaultfunctionsayHello(){console.log('hello')}结果如下:重命名导入。//index.mjsimport{propAasrenameA,propBasrenameB,propCasrenameC,propDasrenameD}from'./a.mjs'constpropA='a';letpropB=()=>{console.log('b')};varpropC='c';//a.mjsexport{propA,propB,propC};exportconstpropD='d'重定向导出。export*from'./a.mjs'//首先export{propA,propB,propC}from'./a.mjs'//第二个export{propAasrenameA,propBasrenameB,propCasrenameC}from'./a.mjs'//第三种也是第一种方式:重定向导出所有导出属性,但不包括模块的默认导出。第二种方式:用相同的属性名再次导出。第三种方式:从模块中导入propA,重命名为renameA导出。只需运行该模块。import'./a.mjs'exportexportESModule导出一个值的引用,而CommonJS是一个值的副本。也就是说,CommonJS把暴露的对象复制一份,放到一块新的内存中,每次都是直接在新的内存中取值,所以没有办法同步变量的修改;而ESModule指向的是同一块内存,模块实际输出的是这块内存的地址,每当使用到它的时候,根据地址找到对应的内存空间,从而实现了所谓的“动态绑定”.可以看到下面的例子,使用ESModule导出了一个变量1和一个给变量加1的方法。//b.mjsexportletcount=1;exportfunctionadd(){count++;}exportfunctionget(){returncount;}//a.mjsimport{count,add,get}from'./b.mjs';控制台日志(计数);//1add();console.log(count);//2console.log(get());//2可以看到调用add之后,导出的数字是同步增加的。但是使用CommonJS来实现这个逻辑://a.jsletcount=1;module.exports={count,add(){count++;},get(){返回计数;}};//index.jsconst{count,add,get}=require('./a.js');console.log(count);//1add();console.log(count);//1console.log(get());//2可以看出,调用add增加变量count后,导出的count没有变化,因为CommonJS是基于缓存实现的,入口模块中获取的是一份放在新内存中的副本,而模块a中的block是通过调用addMemory修改的,新的内存没有被修改,所以还是原来的值,只有改写成方法才能得到最新的值。import导入ES模块会根据import关系构建一棵依赖树,遍历到树的叶子模块,然后根据依赖关系反向找到父模块,将export/import指向同一个地址。循环引入和CommonJS一样,当发生循环引用时,不会造成死循环,只是两者的处理方式有很大区别。如果你看过上面的内容,你应该还记得,CommonJS是基于它的缓存来处理循环引用的,也就是:复制导出的值,放到一个新的内存中,使用的时候直接读取这块内存。但是ES模块导出的是一个索引——内存地址,没办法这么处理。它依赖于“模块映射”和“模块记录”。下面对模块映射进行说明,模块记录就像是每个模块的“身份证”,记录了一些关键信息——本模块导出的值的内存地址,加载状态,导入其他模块时,一个“connection”——根据模块记录,导入的变量会指向同一块内存,从而实现动态绑定。看下面的例子,和前面的demo逻辑是一样的:入口模块引用模块a,模块a引用模块b,模块b引用模块a。ab模块相互引用形成一个循环。//index.mjsimport*asafrom'./a.mjs'console.log('入口模块引用a模块:',a)//a.mjsleta="原值-模块中的a变量"export{a}import*asbfrom"./b.mjs"console.log("amodulereferencesbmodule:",b)a="Modifiedvalue-variablesinmodulea"//b.mjsletb=“模块b中的原始值变量”export{b}import*asafrom“./a.mjs”console.log(“模块b引用模块a:”,a)b=“模块b中的修改值变量"运行代码,结果如下。值得一提的是import语句有提升的效果,实际执行可以看成这样://index.mjsimport*asafrom'./a.mjs'console.log('入口模块指的是theamodule:',a)//a.mjsimport*asbfrom"./b.mjs"leta="Originalvalue-avariableinmodule"export{a}console.log("a模块引用b模块:",b)a="Modifiedvalue-variablesinmodulea"//b.mjsimport*asafrom"./a.mjs"letb="Originalvalue-variablesinmoduleb"export{b}console.log("bmodulereferencesaModule:",a)b="Modifyvalue-variableinmoduleb"可以看到在模块b中引用模块a时,获取到的值是未初始化的,接下来分析执行代码一步一步来。在执行代码之前,必须先对其进行预处理。这一步会建立一个基于导入导出的模块映射(ModuleMap)。它类似于一棵树。树中的每个“节点”都是一个模块记录。在这条记录上会标出导出变量的内存地址,导入变量和导出变量是相连的,即指向同一个内存地址。但是,此时这些内存是空的,即未初始化。下一步是逐行执行代码。import和export语句只能放在代码的最顶层,也就是说不能写在函数或者if代码块中。【入口模块】首先进入入口模块,在模块映射中将入口模块的模块记录标记为“Fetching”,表示已经进入,但还没有执行完毕。执行import*asafrom'./a.mjs'进入模块a,将模块映射中a的模块记录标记为“正在获取”。[a模块]import*asbfrom'./b.mjs'执行进入b模块。此时模块映射中b的模块记录被标记为“正在获取”。[Moduleb]import*asafrom'./a.mjs'执行,查看modulemap,modulea已经处于Fetching状态,不再进入。letb='originalvalue-variableinmoduleb'在模块记录中,初始化存储b的内存块。console.log('Bmodulereferstoamodule:',a)根据模块,指向的内存中记录的值为{a:}。b='Modifiedvalue-variableinmoduleb'在模块记录中,修改了存储b的内存块的值。[amodule]leta='originalvalue-avariableinmodule'在模块记录中,初始化存放a的内存块。console.log('amodulereferencesbmodule:',b)记录根据模块指向的内存中的值,即{b:'modulemodifiedvalue-bvariableinthemodule'}。a='Modifiedvalue-variableinmodulea'在模块记录中,修改了存储a的内存块的值。[入口模块]console.log('入口模块引用模块a:',a)根据模块记录,指向内存中的值为{a:'修改后的值-模块中的变量'}。总结一下:如上,循环引用无非要解决两个问题,保证不进入死循环,输出什么值。ESModule处理循环使用模块间的依赖映射解决死循环问题,将进入的模块标记为“acquiring”,循环引用时不会再次进入;使用模块记录标记去哪块内存取值,连接导入导出,解决输出什么值。结论回到前三个问题,文中不难找到答案:CommonJS和ESModule都是处理引入循环的,不会进入死循环,只是方式不同:CommonJS使用模块缓存先检查require函数是否有缓存,已有的不会执行,导出变量的复制值也记录在模块缓存中;ESModule使用modulemap将已经进入的模块标记为获取,遇到import语句会检查这个已经标记为获取的map,不会进入。map中的每个节点都是一个模块记录,上面有导出变量的内存地址,导入时会建立连接——即指向同一块内存。CommonJS的export和module.export指向同一块内存,但是由于最后一个export是module.export,所以不能直接给export赋值,会导致指向丢失。查找模块时,查找核心模块和文件模块比较简单。对于react/vue等第三方模块,会从当前目录下的node_module文件开始,递归向上查找。找到包后,根据package.json主字段找到入口文件。