大家好,我叫念念。如果被问到“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.jsexportexports.a='一个模块'console.log('运行一个模块');我们使用require函数导入模块,使用exports对象导出模块,这里的requireexports是CommonJS规范提供给我们的。使用断点调试,我们可以看到这些核心变量:exports记录当前模块导出的变量module记录当前模块的详细信息requireimport模块exportsexport首先看exportsexport,面试经常问的问题之一exports和module.exports的区别是什么。两者都指向同一个内存,但用法并不完全等同。绑定一个属性时,两者是一样的exports.propA='A';module.exports.propB='B';不能直接赋值给exports,即exports={}的语法不能直接使用//Failedexports={propA:'A'};//Successmodule.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='原始值-模块b中的变量'vara=require('./a')console.log('模块b引用模块a',a)exports.b='修改后的值-variableinmoduleb'outputresult如下:这种AB模块之间的相互引用应该是死循环,其实不然,因为CommonJS做了特殊的处理——模块缓存。还是使用断点调试,可以看到变量require上有一个属性cache,就是模块缓存逐行看执行过程,[entrymodule]开始执行,将入口模块添加到缓存中,vara=require('./a')Executeaddmoduleatothecache,entermodulea,[amodule]exports.a='originalvalue-variableinmodulea'执行,初始化模块a缓存中的变量a为原值,执行varb=require('./b'),将b模块加入缓存,进入b模块[b模块]exports.b='原值-b模块变量',初始化变量b模块的缓存中的b为原来的值,vara=require('./a'),尝试导入一个模块,发现已经有一个模块的缓存,所以不会进入执行,而是直接取一个模块的缓存,此时print{a:'原始值-模块a中的变量'},exports.b='修改后的值-执行模块b中的变量,替换缓存中的变量bmoduleb修改后的值,[modulea]console.log('moduleareferencesmoduleb:',b)执行,取缓存中的值,打印{b:'moduleb:modifiedvalue-variableinmoduleb'}exports.a='modifiedvalue-variableinmodulea'执行,将模块a缓存中的变量a替换为修改值,[entrymodule]console.log('入口模块引用模块a:',a)Execute,取缓存中的值,print{a:'修改后的值-模块中的一个变量'}以上就是循环引用在处理的过程中,循环引用无非解决两个问题,如何避免死循环输出值是多少。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就能正确找到包的位置。仔细观察module变量,可以看到还有一个属性paths,首先对路径进行了简单的分类:内置核心模块、本地文件模块、第三方模块。对于核心模块,node已经编译成二进制代码,直接写上标识符fs和http。对于你写的文件模块,需要以'./''../'开头,require会把这个相对路径转换为真实路径,找到模块。对于第三方模块,即使用npm下载的包,会使用paths变量,依次查找当前路径下的node_modules文件夹。如果没有,那么将在父目录中搜索no_modules。转到根目录,直到找到它。在node_modules下找到对应的包后,会以package.json文件下的main字段为准,找到包的入口。如果没有主字段,则搜索index.js/index.json/index.nodeES模块,虽然名字叫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可以写成对象的集合,也可以是单独的变量,需要和名称对应通过导入导入的变量。默认import,export//importfunctionimportanyNamefrom'./a.mjs'exportdefaultfunction(){console.log(123)}//importobjectimportanyNamefrom'./a.mjs'exportdefault{name:'念念';location:'guangdong'}//importconstantimportanyNamefrom'./a.mjs'exportdefault1使用exportdefault语法实现默认导出,可以是函数,也可以是对象,也可以只是一个常量。默认表示使用import导入时可以使用任意名称,混合导入导出//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')}importall//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')}结果如下Renameimport//index.mjsimport{propAasrenameA,propB为renameB,propC为renameC,propD为renameD}from'./a.mjs'constpropA='a';letpropB=()=>{console.log('b')};varpropC='c';//a.mjsexport{propA,propB,propC};exportconstpropD='d'redirectionexportexport*from'./a.mjs'//第一类export{propA,propB,propC}from'./a.mjs'//第二种export{propAasrenameA,propBasrenameB,propCasrenameC}from'./a.mjs'//第三种第一种方式:重定向导出所有导出的属性,但是不是模块的默认导出第二种方式:使用相同的属性名称再次导出。第三种方式:从模块中导入propA,重命名为renameAexport只运行模块import'./a.mjs'exportexportESModuleexport是value的引用,CommonJS是value的拷贝。也就是说,CommonJS把暴露的对象复制一份,放到一块新的内存中,每次都是直接在新的内存中取值,所以没有办法同步变量的修改;而ESModule指向的是同一块内存,模块实际输出的是这块内存的地址,每当使用到它的时候,根据地址找到对应的内存空间,从而实现了所谓的“动态绑定”.可以看下面的例子,使用ESModule导出一个变量1和一个给变量加1的方法//b.mjsexportletcount=1;exportfunctionadd(){count++;}exportfunctionget(){returncount;}//a.mjsimport{count,add,get}from'./b.mjs';console.log(count);//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("aModulereferencesmoduleb:",b)a="modifiedvalue-variableinmodulea"//b.mjsletb="originalvalue-variableinmoduleb"export{b}import*asafrom"./a.mjs"console.log("B模块引用了一个模块:",a)b="Modifiedvalue-variableinmoduleb"运行代码,结果如下。值得一提的是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。此时,modulemap中b的记录modulerecord被标记为“fetching”,执行[bmodule]import*asafrom'./a.mjs',查看modulemap,modulea已经是在Fetching状态,不再进入,letb='originalvalue-bVariablesinthemodule'在module记录中,初始化存储b的内存块,console.log('bmodulereferencesamodule:',a)记录根据模块记录指向的内存中的值,即{a:
