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

绝对是最清晰的-NodeJS模块系统

时间:2023-03-29 13:09:42 HTML

亮点:a11y-darktheme:smartblueNodeJS目前有两个系统:一个是CommonJS(简称CJS),一个是ECMAScript模块(简称ESM);本文内容三个主要话题:CommonJS的内部原理、NodeJS平台的ESM模块体系、CommonJS与ESM的区别;如何在两个系统之间转换。首先,为什么我们需要模块系统?该系统,由于可以解决项目中遇到的基本需求,将功能拆分成模块,可以让代码更有条理,更容易理解,也可以让我们独立开发和测试各个子模块的功能,并进行封装functions,然后可以直接引入其他模块使用,提高复用性,实现封装:只需要对外提供简单的输入输出文档,对外屏蔽内部实现,降低理解依赖的成本。一些第三方模块可以轻松构建其他模块。此外,模块系统允许用户轻松导入他们想要的模块,并在依赖链上导入模块。一开始JavaScript没有很好的模块体系,页面主要通过多个script标签引入不同的资源。但是随着系统的逐渐复杂化,传统的script标签方式已经不能满足业务需求,于是我们开始打算定义一个模块系统,比如AMD、UMD等。NodeJS是一种运行在背景。浏览器的html缺少导入文件的script标签,完全依赖本地文件系统的js文件。所以NodeJS按照CommonJS规范实现了一个模块系统。ES2015规范于2015年发布,此时JS对模块系统有了正式的标准。按照这个标准构建的模块系统称为ESM系统。它允许浏览器和服务在commonJS模块的commonJS规划中有两个基本概念:用户可以通过requeire函数在本地文件系统中导入一个模块,并通过两个特殊变量exports和exports和module.exportsLoader让我们简单地实现一个简单的模块加载器。首先,它是一个加载模块内容的函数。我们把这个函数放在私有作用域中,避免污染全局环境,然后eval运行这个函数。functionloadModule(filname,module,require){constwrappedSrc=`(function(module,exports,require){${fs.readFileSync(filename,'utf-8')}})(module,module.exports,require)`eval(wrappedSrc)}在代码中我们通过同步方法readFileSync读取模块内容。一般来说,在调用文件系统API时,不应该使用同步版本,但这里确实使用了这种方式。Commonjs使用同步操作来确保可以安装多个模块。引入了正常的依赖顺序。现在实现require函数functionrequire(moduleName){constid=require.resolve(moduleName);if(require.cache[id]){returnrequire.cache[id].exports}//模块元数据constmodule={exports:{},id,}require.cache[id]=module;loadModule(id,module,require);//返回导出的变量returnmodule.exports}require.cache={};require.resolve=(moduleName)=>{//根据ModuleName解析出完整的模块ID}上面实现了一个简单的require函数。有几个自制的模块系统需要说明一下。说),然后将结果保存在id变量中。如果模块已经加载,缓存中的结果将立即返回。如果没有加载模板,则配置环境。具体来说,首先创建一个包含导出属性的模块变量。该对象的内容将由模块在导出API时使用的代码填充。缓存模块对象并执行loadModule函数,传入新创建的模块对象,使用该函数挂载另一个模块的内容返回给另一个模块上述导出内容的模块解析算法完整路径解析模块,我们传入模块名,模块解析函数可以返回模块对应的完整路径,然后通过路径加载对应模块的代码,并通过这个路径来识别模块的身份。resolve函数使用的resolve函数主要处理以下三种情况。是否要加载文件模块?如果moduleName以/开头,则认为是绝对路径。加载时只需要安装路径,原样返回即可。如果moduleName以./开头,则认为是相对路径,所以相对路径是从请求加载模块的目录开始计算的。是否要加载核心模块?如果moduleName不是以/或./开头,那么算法会首先尝试查找要加载的核心模块是否是NodeJS核心模块中的包模块。如果没有找到与moduleName匹配的核心模块,则从发送加载请求的模块开始,逐层搜索名为node_modules的怪路,看看里面有什么。没有匹配moduleName的模块,有则加载。如果没有,继续沿着目录上线,在对应的node_modules目录下查找,直到文件系统的根目录。这样,两个模块依赖不同版本的包,但仍然可以正常加载。例如下面的目录结构:myApp-index.js-node_modules-depA-index.js-depB-index.js-node_modules-depA-depC-index.js-node_modules-depA在上面的例子中,虽然myApp,depB,和depC都依赖于depA但加载在完全不同的模块中。例如:在/myApp/index.js中,加载的源是/myApp/node_modules/depB/index.js中的/myApp/node_modules/depA,加载的源是/myApp/node_modules/depB/node_modules/depA中的/myApp/node_modules/depC/index.js,加载/myApp/node_modules/depC/node_modules/depANodeJs能够很好的管理依赖,因为它背后有模块解析算法等核心部分,可以管理上千个包,不用冲突或版本不兼容问题。循环依赖很多人认为循环依赖是理论上的设计问题,但实际项目中很可能会出现这种问题,所以你应该知道CommonJS是如何处理这种情况的。您可以通过查看之前实现的require函数来了解风险。下面举例说明有一个mian.js模块,需要依赖a.js和b.js两个模块。同时a.js需要依赖b.js,而b.js又依赖a.js,造成循环依赖,源码如下://a.jsexports.loaded=false;constb=require('./b');module.exports={b,loaded:true}//b.jsexports.loaded=false;consta=require('./a')module.exports={a,loaded:false}//main.jsconsta=require('./a');constb=require('./b');console.log('A->',JSON.stringify(a))console.log('B->',JSON.stringify(b))运行main.js会得到如下结果从结果可以看出CommonJS存在由循环依赖引起的风险。b模块导入a模块时,内容不完整。具体来说,它只反映了b.js模块请求a.js模块时模块的状态,并不能反映a.js模块最终加载时的状态,下面用示例图来表示这个过程。下面以具体过程来讲解整个过程。从main.js开始,这个模块开始引入a.js模块。a.js需要做的第一件事是导出一个名为loaded的文件。值,并将值设置为false。js模块需要导入b.js模块,和a.js类似,b.js先把加载的变量也导出为falseb.js继续执行,需要导入a.js,因为系统已经启动了a.js模块处理完毕,b.js会立即将a.js导出的内容复制到这个模块中。b.js会将自己导出的加载值改为false。由于b已经执行完毕,控制权会返回到a.js,他会将b.js模块的状态复制到a.js继续执行,修改加载的导出值为true,最后执行main.js。可以看到因为是同步执行,所以引入了b.js,a.js模块并不完整,不能反映b.js的最终状态。在上面的例子中可以看到,循环依赖的结果对于大型项目来说更为严重。使用方法比较简单,限于篇幅,本文不再赘述。ESMESM是ECMAScript2015规范的一部分。该规范为Javascript提供了一个统一的模块系统,以适应各种执行环境。ESM和CommonJS的一个重要区别是ES模块是静态的,这意味着导入模块的语句必须写在顶层。另外,被引用的模块只能使用常量字符串,不能依赖需要在运行时动态求值的表达式。比如我们不能通过下面的方式导入ES模块:){module=require("module1")}else{module=require("module2")}看似比CommonJS严格,但也正是因为这种静态导入机制,我们才可以对依赖进行静态分析,去除不会执行逻辑,这称为tree-shaking模块加载过程。要了解ESM系统的运行原理以及它是如何处理循环依赖的,我们需要了解系统在每个阶段是如何解析和执行Javascript代码加载模块的。解释器的目标是建立一个图来描述要加载的模块之间的依赖关系。该图也称为依赖图。解释器正是通过这个依赖图来判断模块的依赖关系,并决定它应该以什么顺序执行代码。比如我们需要执行某个js文件,解释器就会从入口开始,查找所有的import语句。如果在查找过程中遇到import语句,会以深度优先的方式递归,直到解析完所有代码。完全的。这个过程可以细分为三个过程:分析:找到所有的import语句,递归地从相关文件中加载每个模块的内容实例化:对于一个导出的实体,在内存中保留一个命名的import,但是不给他赋值暂且。这时候应该根据import和export关键字建立依赖关系,此时并没有执行js代码执行:这个阶段NodeJS开始执行代码,让实际导出的实体获取到实际价值。在CommonJS中,就是Executethefilewhileparsingdependencies。所以当你看到require的时候,说明前面的代码已经执行完了。因为require操作不一定要出现在文件的开头,而是可以出现在task的地方。但是,ESM系统不同,这三个阶段是分开的。必须先完整构建依赖图,然后才开始执行代码循环依赖,之前提到的CommonJS循环依赖的例子是ESM修改的//a.jsimport*asbModulefrom'./b.js';exportletloaded=false;exportconstb=bModule;loaded=true;//b.jsimport*asaModulefrom'./b.js';exportletloaded=false;exportconsta=aModule;loaded=true;//main.jsimport*asafrom'./a.js';import*asbfrom'./b.js';console.log("A=>",a)console.log("B=>",b)需要注意的是,这里不能使用JSON.strinfy方式,因为这里使用了循环依赖,从中可以看出以上执行结果,a.js和b.js可以完全相互观察。与CommonJS不同,某些模块获取的状态是不完整的。下面分析一下流程:以上图为例:从main.js开始分析,先找到一个import语句,然后进入a.js从a.js开始执行,再找一个import语句,执行b.js在b.js中开始执行,找到一个import语句,引入a.js,因为之前依赖过a.js,我们不会再执行这条路径,b.js继续执行,发现有没有其他的import语句。返回a.js后,也发现没有其他import语句,然后直接返回main.js入口文件。继续执行,发现需要引入b.js,但是之前访问过这个模块,所以不会深度优先执行这条路径。模块依赖图已经形成了一个树状图,然后解释器通过这个依赖图来执行代码。在这个阶段,解释器从入口点开始,开始分析模块之间的依赖关系。这个阶段解释器只关心系统的导入语句,加载这些语句要引入的模块,以深度优先的方式探索依赖图。根据该方法,遍历依赖关系得到一个树结构实例化。在这个阶段,解释器将从树结构的底部开始,逐渐向顶部移动。在进入一个模块之前,它会搜索该模块所有要导出的属性,并在内存中建立一个映射表来存储该模块要导出的属性的名称和该属性将具有的值,如如下图所示:从上图可以看出,模块被实例化的顺序。解释器首先从b.js模块开始,发现这个模块需要导出loaded和a。然后解释器分析a.js模块,他发现这个模块要exportloaded和b最后分析main.js模块,他发现这个模块并没有导出任何在函数实例化阶段构建的exportsmap集合,只记录导出名称和名称将具有的值之间的关系。至于value本身,在这个阶段都没有初始化。经过以上过程,解析器需要重新执行。这次他会将每个模块导出的名字和导入它们的模块关联起来,如下图:这次的步骤是:模块b.js需要连接模块b.js导出的内容。此链接称为aModule。modulea.js需要和modulea.js导出的内容对接。此链接称为bModule。最后,模块main.js需要与模块b连接。.js导出的内容在这个阶段是连接的,所有的值都没有初始化,我们只是建立对应的链接,让这些链接指向对应的值,至于值本身,我们需要等到下一个阶段确定这个阶段的执行,系统最终执行每个文件中的代码。他按照深度优先的顺序,从下往上访问原始依赖图,将访问的文件一个一个执行。在此示例中,main.js将最后执行。这个执行结果保证了当程序运行主逻辑时,每个模块导出的所有值都被初始化了。上图中的具体步骤是:从b.js开始执行。第一行要执行的代码会将模块导出的loaded初始化为false然后往下执行,将aModule复制到a中。这时候a得到一个引用值,就是a.js模块然后设置加载的值为true。这个时候b模块的所有值都确定了,现在执行a.js。首先将loaded的导出值初始化为false,然后获取模块导出的b属性值的初始值,即bModule的引用,最后将loaded的值改为true。至此,我们将最终确定a.js模块系统导出的这些属性对应的值。完成这些步骤后,系统就可以正式执行main.js文件了。此时,每个模块导出的所有属性都已经评估完毕。由于系统是通过引用而不是复制来引入模块的,所以即使模块之间存在循环依赖,每个模块仍然可以完全看到另一个模块的最终状态。CommonJS和ESM的区别和交互这里我们说说CommonJSESM和ESM的几个重要区别,以及在需要的时候如何把这两个模块一起使用ESM不支持CommonJS提供的一些参考CommonJS提供了一些关键的参考,CommonJS不支持ESM,包括require、exports、module。导出,__文件名,__diranme。如果在ES模块中使用这些,程序中会出现引用错误的问题。在ESM系统中,我们可以通过特殊对象import.meta获取引用,引用当前文件的URL。具体使用import.meta.url获取当前模块的文件路径,类似于file:///path/to/current_module.js。基于这个路径,我们可以构造出__filename和__dirname表示的两个绝对路径:import{fileURLToPath}from'url';从“路径”导入{目录名};const__dirname=fileURLToPath(import.meta.url);const__dirname=目录名(__filename);CommonJS的require功能也可以在ESM模块中实现,方法如下:import{createRequire}from'module';constrequire=createRequire(import.meta.url)现在,你可以使用这个require()函数在ES模块系统环境中加载Commonjs模块。在其中一个模块系统中使用另一个模块。上面提到,使用ESM模块中的module.createRequire函数加载commonJS模块。除了这种方法,还可以通过importlanguage引入CommonJS模块。但是该方法只会导出默认导出的内容;importpkgfrom'commonJS-module'import{method1}from'commonJS-module'//会报错但是反过来也没办法,我们没办法在commonJS中引入ESM模块另外ESM不支持将json文件作为模块导入,但是commonjs可以轻松实现如下import语句,会报错importjsonfrom'data.json'如果需要导入json文件,还需要使用createRequire函数:import{createRequire}from'module';constrequire=createRequire(import.meta.url);constdata=require("./data.json");console.log(data)总结本文主要讲解了两个模块系统在NodeJS中是如何工作的,通过了解这些原因可以帮助我们编写bug避免一些难以发现的问题