Node.js中模块引用是如何真正运作的 模块引用是如何真正运作的CommonJS模块系统每个JavaScript文件被视为一个独立的模块Node.js使用CommonJS模块系统 require()、export()或module.exports;ES模块系统在浏览器中使用import/export;之前已经尝试过将ES模块引入Node.js中.mjs那么我们写的require‘test-module’并不是标准的JavaScript语法它到底是如何运作的呢这些模块又是从哪里来的呢当我们require一个模块时会发生什么在node.js中模块引用的本质上的大致过程是解析-加载-包装-执行-缓存的过程但是具体的你是使用CommonJS还是ES Modules两者从原理到内存模型都有本质的区别一、CommonJS 模块require()的全过程比如当你写下一个require(‘test-module’)时Node并不是简单地去读取文件而且经过严格步骤在node.js中模块有三种不通来源第一种就是核心模块比如http这是自带的第二种就是开发者模块在本地中第三种就是来自于NPM的第三方模块1.路径解析-定位文件require的参数叫模板标识符Node会按以下优先级来进行解析核心模块(fs\http):直接返回编译进Node二进制里的模块不经过文件系统相对/绝对路径(以./、…/、/开头)直接根据路径查找非路径模块名如express会沿着module.paths一层层查找node_mudules,直到根目录对于文件路径会尝试补全扩展名.js➡.json➡.node,如果是目录的话则会在目录下查找package.json的main字段或者index.js/index.json/index.node,解析成功后会得到一个绝对路径比如/home/project/myMod.js这个路径会成为模块缓存的唯一键2.缓存检查-心脏机制Node维护了一个全局缓存Module._cache(可直接通过require.cache访问)它是一个以绝对路径为key的对象Module._cache{/home/project/myMod.js:Module实例,// ...};每次require的第一步就是检查这个缓存如果命中直接返回缓存的module.exports,不再执行任何代码。因此同一个文件不会被多次执行循环依赖时候返回的是当前已经执行到一般的导出对象3.创建Module实例并编译如果没有命中缓存的话Node会进行以下步骤创建一个Module对象这个对象带有id(绝对路径)exports(初始空对象)、filename、loaded:false等属性。然后将该实例立即存储缓存这和立即的意思就是这个代码并没有执行就会被存入缓存之中根据文件类型编译.js:读取文件内容包装成一个函数.json直接调用JSON.parse,赋值给module.exports.node通过process.dlopen加载C插件4.函数包装-----作用域隔离的实现对于.js文件Node不会直接执行你写的代码而是把你的代码放进一个函数体内(function(exports,require,module,__filename,__dirname){// 你写的模块代码在这里});这正是你可以在模块里直接使用require、module、exports、__dirname、__filename的原因——它们是函数参数不是全局变量。这样做的好处是每个模块有独立的作用域顶层var声明不会污染全局。require:用于加载模块的功能module:只想当前模块exports只想module.exports的引用用于从模块导出对象__filename:当前模块文件的绝对路径__dirname:当前模块的目录名执行 module.exports 的引用关系包装后的函数会被调用传入module.exports、require、module等。注意以下exports和module.exports的关系初始时exports module.exports {},它们指向同一个空对象。如果你给exports添加属性如exports.foo 1,如果引用相同外部能通过require拿到foo但如果你直接给exports赋一个新值比如exports function(){},那么你只是把参数exports指向了新对象而module.exports仍然是原来的空对象。最终require返回的是module.exports,而不是exports所以正确导出整个单一功能的时候必须用module.exports…执行外模块代码之后module.loade置为true5.循环依赖返回不完整的导出假设a.js和b.js互相require// a.jsconsole.log(a 开始);exports.donefalse;constbrequire(./b);console.log(在a中,b.done,b.done);exports.donetrue;console.log(a结束);// b.jsconsole.log(b 开始);exports.donefalse;constarequire(./a);console.log(在 b 中a.done ,a.done);exports.donetrue;console.log(b 结束);详细执行顺序a.js被require,Module实例立即存储缓存开始执行。执行到require(‘./b’),b.js同样被创建并缓存然后开始执行在b.js里有require(‘./a’),此时a.js的缓存已存在但执行未完成于是直接返回当前a的module.exports(此时done: false).b继续执行结束a拿到完整的b自己执行结束并设置donetrue。因此循环依赖在CommonJS中只是拿到了一个“不完整”的导出对象一般不会死锁定但需要在设计上注意。二、ES ModulesESMimport的运作Node.js中ESM遵循TC39标准使用异步加载、实时绑定live bindings,分为三个阶段1.解析-静态分析当Node加载一个ES模块入口时它会先仅解析源码构建模块记录不执行代码这时的import和export语句会被静态分析形成模块依赖图。正是因为静态分析ESM的import不能放入条件语句中动态import()除外2.实例化-建立内存空间这一步为每个模块创建模块环境记录核心工作为所有的export变量分配内存空间但此时值尚未填充处于’未初始化状态。把所有import绑定指向这些内存地址形成实时绑定。这一点和CommonJS的”赋值拷贝“完全不通例如导出模块里export let count 0;,导入方得到的是count变量的同一个存储位置而不是一个拷贝的值3.求值Evaluation—执行模块代码按照依赖图的后续顺序执行模块顶层的代码将值填入第二步分配的内存空间。由于导入方持有的是内存地址的引用当导出方修改变量时(比如count)导入方法可见新值反之导入方不能修改导入绑定是只读的除非导出对象并修改其属性循环依赖在 ESM 中更安全由于有了“未初始化”占位如果某个导入变量在模块执行前被访问会触发ReferenceError暂时性死区这使问题能尽早暴露。4.缓存机制ESM模块焕春基于解析后的URL或者绝对路径存储的是模块记录。每个模块只会被解析、实例化和求值一次后续import直接返回已求职的模块记录导出的实时半丁依然生效。// ES 模块循环依赖示例// a.mjsimport{b}from./b.mjs;console.log(a 执行);exportconstaa-value;console.log(b ,b);// b.mjsimport{a}from./a.mjs;console.log(b 执行);exportconstbb-value;console.log(a ,a);这个执行会被报错ReferenceError: Cannot access ‘a’ before initialization因为在b.mjs执行时a的内存空间已创建但尚未填充值访问导致 TDZ 错误。这比 CommonJS 的undefined更能提示问题。5.CommonJS互相操作在 ESM 中可以使用import cjsModule from xxx加载 CommonJS 模块。Node 会先将 CJS 执行完毕然后把module.exports作为默认导出提供。动态import()返回 Promise可用于加载 CJS 或 ESM其内部也是按上述步骤完成。