当前位置: 首页 > Web前端 > HTML

本来想搞清楚ESM和CJS模块之间的相互转换,没想到写完之后还有更多的疑问

时间:2023-03-28 19:57:56 HTML

我只是好奇打包工具是怎么转换ESM和CJS模块的。代码之后,我有更多的问题。目前主流的模块语法有两种,一种是Node.js专用的CJS,一种是浏览器和Node.js都支持的ESM。在ESM规范出来之前,Node.js模块都是使用CJS编写的,但现在ESM已经逐渐取代CJS成为浏览器和服务器的通用模块解决方案。那么问题来了,比如我前期开发了一个CJS包,现在想转成ESM语法支持浏览器端使用,或者现在用ESM开发的一个包,想转换itintoCJSsyntaxtosupport老版本Node.js的转换工具有很多,比如Webpack、esbuild等,所以你有没有仔细查看过它们的转换结果是什么,没关系,本文会发现出去。ESM模块语法让我们简要回顾一下常用的ESM模块语法。export://esm.jsexportletname1='JayChou'//等同于letname2='Hackberry'export{name2}//renameexport{name1asname3}//默认export//一个模块只能有一个default输出,所以exportdefault命令只能用一次//exportdefault本质上就是输出一个叫default的变量或方法,所以可以直接用一个值,导入的时候随便用什么名字exportdefault'中文musicclassiccharacter'import://namedimportimporttitle,{name1,name2,name3,name1asname4}from'./esm.js';//整体importimporttitle,*asnamesfrom'./esm.js';CJS模块语法CJS模块语法会简单一点,export://方法一exports.name2='Hackberry'//等同于module.exports.name1='周杰伦'//方法二module.exports={name1:'JayChou',name2:'Hackberry'}import://整体constnames=require('./cjs.js')console.log(names)//解构const{name1,name2}=require('./cjs.js')console.log(name1,name2)从我们肉眼的结果来看,CJS的exports.xxx类似于ESM的exportletxxx,CJS的module.exports=xxx类似于exportdefaultxxxESM的,但是它们的导入形式不同,ESM的importxxx的xxx只代表exportdefaultxxx的值。如果没有默认导出,导入会报错。你需要使用import*asxxx语法,但CJS实际上使用exports.xxx=ormodule.exports=,其实就是导出属性module.exports的最终值,所以只导入这个属性的值。其实CJS和ESM主要有3个不同点:CJS模块输出值的副本,ESM模块输出值的引用,CJS模块在运行时加载,ESM模块在编译时输出接口时间。CJS模块的require()是一个同步加载模块。ESM模块的导入命令是异步加载,有独立的模块依赖解析阶段。那么,两者在转换过程中如何处理这些差异,然后我们使用esbuild进行转换,为什么不使用webpack,没别的,只简单,看是如何处理的,安装:npminstallesbuild添加一个转换文件://build.jsrequire("esbuild").buildSync({entryPoints:[''],//转换文件outfile:"out.js",format:'',//转换目标转换格式、cjs、esm});然后我们在命令行输入node./build.js命令可以看到转换结果输出到out.js文件中。ESMtoCJSconversion导出要转换的内容:exportletname1='JayChou'letname2='PackTree'export{name2}export{name1asname3}exportdefault'Chinesemusicclassic'接下来我们看一下转换结果代码,核心导出语句如下:module.exports=__toCommonJS(esm_exports);导出的数据是调用__toCommonJS方法返回的结果,先看参数esm_exports:varesm_exports={};__export(esm_exports,{default:()=>esm_default,name1:()=>name1,name2:()=>name2,name3:()=>name1});letname1="周杰伦";letname2="朴树";varesm_default="中国音乐经典人物";先定义一个空对象esm_exports,然后调用__export方法:var__defProp=Object.defineProperty;var__export=(target,all)=>{//遍历对象for(varnameinall)//给对象添加一个属性,并将属性描述符的值functionget设置为属性对应的函数在all对象上,那么属性的值就是函数的返回值__defProp(target,name,{get:all[name],enumerable:true});};上面所做的是向esm_exports对象添加四个属性。这四个属性显然就是我们使用ESM导出导出的所有变量。exportdefault是默认导出的,本质上就是导出一个名为default的变量,没什么特别的:exportdefaulta//等同于export{aasdefault},所以默认导出的变量会被定义为一个名为default的属性和添加到这个对象,这是非常显然,因为我们知道CJS的导出其实就是module.exports属性的值,那么我们使用ESM导出多个变量,只能添加到一个对象中进行导出。注意两点:1.添加的属性不是直接使用esm_exports.xxx,而是使用Object.defineProperty方法,并且只定义属性的取值函数get,不定义赋值函数set,也就是说esm_exports这个属性的值是不能修改的,这其实就是CommonJS和ESM的区别:ESM导出的接口不能修改,但是CJS可以,所以下面的ESM方法会报错:import*asnamesfrom'./esm.js';names.name1='徐玮';//报错importtitle,{name1,name2,name3,name1asname4}from'./esm.js';title='徐玮';//报错name1='徐伟';//报错,CJS不会:constnames=require('./cjs.js');names.name1=1;//successlet{name1,name2}=require("./cjs.js");name1=1;//成功2.设置属性的描述符时不直接使用该值,例如:var__export=(target,all)=>{for(所有变量名称)__defProp(target,name,{value:all[name],enumerable:true});};__export(esm_exports,{默认值:esm_default,name1:name1,name2:name2,name3:name1,setName1:setName1});而是定义了获取值函数,以函数的形式返回同名变量的值,这其实还有一个区别:CJS模块的输出是值的副本,而ESM模块的输出是对值的引用。例如ESM模块中://esm.jsexportletname='JayChou'exportconstsetName=(newName)=>{name=newName}//other.jsimport{name,setName}form'./esm.js'console.log(name)//周杰伦setName('徐峥')console.log(name)//徐峥可以看到导入的地方的值也变了,但是在CJS模块里没有://cjs.jsletname='周杰伦'constsetName=(newName)=>{name=newName}module.exports={name,setName}//other.jslet{name,setName}=require("./cjs.js")console.log(name)//周杰伦setName('徐峥')console.log(name)//周杰伦就是这样,所以需要设置get函数才能真正获取到值时间,否则转为CJS后,变量的值只会复制一次copy,后续的变化不会更新。回到这一行:module.exports=__toCommonJS(esm_exports);读完esm_exports,再来看__toCommonJS方法:var__toCommonJS=(mod)=>__copyProps(__defProp({},"__esModule",{value:true}),mod);先创建一个空对象,然后使用Object.defineProperty添加一个__esModule=true属性,这个属性用来在导入的时候做一些判断,然后调用__copyProps方法:var__getOwnPropNames=Object.getOwnPropertyNames;//返回一个数组指定对象的所有属性的属性名(包括不可枚举的属性但不包括以Symbol值作为名称的属性)var__hasOwnProp=Object.prototype.hasOwnProperty;//返回一个布尔值,表示对象是否具有指定的property(即是否有指定的key),该方法会忽略那些继承自原型链的propertyvar__getOwnPropDesc=Object.getOwnPropertyDescriptor;//返回对象上某个自身属性对应的指定A属性描述符。(自有属性是指直接赋给对象的属性,不需要从原型链中查找)var__copyProps=(to,from,except,desc)=>{if(from&&typeoffrom==="object"||typeoffrom==="function"){for(letkeyof__getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],可枚举:!(desc=__getOwnPropDesc(from,key))||desc.enumerable});}还给;};这个方法所做的是将from对象的所有属性添加一份拷贝到to对象中,但是如果to对象上有同名属性,则不会被覆盖,会出现下面的情况://cjs.jsexportletfoo=1//cjsUse.jsexport*from'./cjs.js'exportletfoo=2有一个同名的export,cjsUse模块会覆盖cjs模块的同名export,所以最终exportedfoo=2。同时设置新增属性的属性描述符,设置值函数get,返回值为from对象的属性值。因为没有设置get,所以不能修改添加的属性值。简单的说就是创建一个新的对象,将esm_exports的属性添加到新的对象中,但是在访问新对象的属性的时候,实际上访问的是from对象的属性值,相对于代理对象,然后外部导出这个新对象。疑惑1:为什么要新建对象而不是直接导出esm_exports对象?另外,我们可以发现默认不支持ESM导出到CJS。在ESM的默认导出中,我们可以这样导入:importdefaultValuefrom'xxx'但是转换成CJS后,我们不能这样导入:constdefaultValue=require('xxx')并且需要获取realdefaultValue形式为.default:constimportData=require('xxx')console.log(importData.default),所以尽可能少用defaultexport。转换导入接下来,查看导入的转换:importtitle,{name1,name2,name3,name1asname4}from"./esm.js";控制台日志(标题,名称1,名称2,名称3,名称4);转换结果:varimport_esm=__toESM(require("./esm.js"));控制台日志(import_esm.default,import_esm.name1,import_esm.name2,import_esm.name3,import_esm.name1);在导入的数据上调用了_toESM方法:var__create=Object.create;//创建一个新对象,使用现有对象作为新创建对象的原型(prototype)var__defProp=Object.defineProperty;var__getProtoOf=Object.getPrototypeOf;//返回指定对象的原型(内部[[Prototype]]属性的值)var__toESM=(mod,isNodeMode,target)=>(//如果导入的模块存在,则使用的原型模块创建一个新对象作为原型astarget(target=mod!=null?__create(__getProtoOf(mod)):{}),//复制导入模块的属性到目标对象__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:true}):target,mod));不解2:为什么要根据导入模块的原型创建一个新的对象?疑惑三:为什么importing会创建一个新对象?可以看到还创建了一个新对象,然后将导入模块的属性添加到这个新对象中。转换导出时,会在导出的对象中添加一个__esModule这里使用了=true的属性。如果为真,则说明该模块是ESM转换而来的CJS模块,否则就是原来的CJS模块。在这种情况下,会为目标对象添加一个默认属性,值为导入的数据。为什么呢,其实是为了兼容导入原来的CJS模块,例如://exportexportdefaultclassPerson{}//importimportPersonfrom'x'newPerson()转换成CJS后://exportmodule。exports={default:()=>Person}//importconstres=require('x')newres.default()但是如果x模块不是从ESM转换过来的,它本身就是一个CJS模块:module.exports=Person,那么res就是导出的类,获取它的默认属性显然是错误的,所以需要自己手动创建一个对象,添加一个默认属性来引用CJS转ESM来导出要转换的内容为如下:module.exports.name1='JayChou'exports.name2='Hackberry'转换结果如下://...exportdefaultrequire_cjs();为什么要将其转换为默认导出而不是命名导出?一是require本身很像importxxx默认导入语法,二是不方便转换成具名导出,比如下面的export:constres={name1:'JayChou'}module.exports=resif(Math.random()>0.5){res.name2='许玮'}else{res.name3='Hackberry'}并没有实际执行代码,也不知道最后导出的是什么,所以命名导出为不可能,只能用默认导出。这样我只导出module.exports属性,我不关心上面有什么。向上。看一下require_cjs方法:var__getOwnPropNames=Object.getOwnPropertyNames;var__commonJS=(cb,mod)=>function__require(){return(mod||(0,cb[__getOwnPropNames(cb)[0]])((mod={exports:{}}).exports,mod),mod.出口);};varrequire_cjs=__commonJS({"cjs.js"(exports,module){module.exports.name1="\u5468\u6770\u4F26";exports.name2="\u6734\u6811";},});疑惑4:为什么要弄成这么奇怪的格式,直接传函数不行吗?因为CJS的export是给module.exports对象添加属性,或者重写module.exports属性,所以直接把原来模块的代码放到一个函数中,然后传入module对象和exports中的属性形式的参数。这样就不需要关心代码做了什么,只要在最后导出module.exports属性即可,同时还增加了一个缓存机制,这也是CJS的一个特性,即,同一个模块只会在第一次导入的时候执行,拿到导出的数据后会缓存起来,然后导入这个模块会直接从缓存中获取导出的数据,这也是CJS区别于环境稳定机制。转换导入要转换的代码:constres=require('./cjs.js')console.log(res);转换结果:报错,说明目前不支持require到esm的转换,这是为什么,其实是因为require是同步的,运行时的,所以可以动态导入和条件导入,可以出现在非顶层。可以当成一个普通的函数,但是不能导入import。它是静态编译的,必须出现在顶层,所以不能转换,那怎么办呢,很简单,去掉require就行了,也就是把所有模块打包到同一个文件中,假设导入文件的两个模块如下://cjs.jsmodule.exports={name1:'周杰伦',name2:'Hackberry'}//cjs2.jsmodule.exports={name3:'徐峥',name4:'梁博'}引入他们的模块如下:constres=require('./cjs.js')console.log(res);constres2=require('./cjs2.js')console.log(res2);module.exports={res,res2}然后我们修改转换执行build.js文件://build.jsrequire("esbuild").buildSync({entryPoints:[''],outfile:"out.js",format:'',bundle:true//++});那么转换后不会报错,结果如下://...//cjs.jsvarrequire_cjs=__commonJS({"cjs.js"(exports,module){module.exports.name1="\u5468\u6770\u4F26";exports.name2="\u6734\u6811";}});//cjs2.jsvarrequire_cjs2=__commonJS({"cjs2.js"(exports,module){module.exports={name3:"\u8BB8\u5DCD",name4:"\u6881\u535A"};}});//cjsUse.jsvarrequire_cjsUse=__commonJS({"cjsUse.js"(exports,module){varres=require_cjs();console.log(res);varres2=require_cjs2();console.log(res2);module.exports={res,res2};}});导出默认require_cjsUse();可见转换和导出的逻辑其实是一样的。每个模块的内容都会被包装成一个函数,然后生成一个函数。执行该函数时,会执行该模块的代码,并挂载导出的数据。去module.exports,不管是在module中使用还是exported,都可以总结一下。温馨提示,本文内容纯属作者个人观点,不保证正确无误~另外,以上问题可能没有所谓的原因。换一个转换工具。比如babel、rollup等可能生成的代码不一样,有兴趣的可以自己试试。总结一下:ESM转CJS:所有导出的变量都挂载在一个对象上,然后module.exports这个对象。如果是导入,会判断是ESM转换的CJS模块还是原始CJS模块,先创建一个对象。如果原来的CJS模块会添加一个默认属性来保存导入的数据,如果不是原来的CJS模块,会直接把属性复制到新对象上,最后这个新对象作为导入的结果。CJS转CSM:将模块的内容包装成一个函数,以参数的形式传入模块对象和module.exports属性,函数的执行结果为module.exports属性的值,增加以高阶函数的形式缓存导出的函数,如果转换导出,会直接导出默认函数的执行结果。如果是导入,则不能单独转换,需要打包成同一个文件,所以没有转换的import语句。