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的对象

js 复制代码
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不会直接执行你写的代码,而是把你的代码放进一个函数体内:

js 复制代码
(function(exports, require, module, __filename, __dirname) {
  // 你写的模块代码在这里
});

这正是你可以在模块里直接使用 requiremoduleexports__dirname__filename 的原因------它们是函数参数,不是全局变量。

这样做的好处是:每个模块有独立的作用域,顶层 var 声明不会污染全局。

  • require:用于加载模块的功能;
  • module:只想当前模块
  • exports:只想module.exports的引用,用于从模块导出对象;
  • __filename:当前模块文件的绝对路径;
  • __dirname:当前模块的目录名
  1. 执行 & 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置为true

5.循环依赖:返回不完整的导出

假设a.js和b.js互相require

js 复制代码
// a.js
console.log('a 开始');
exports.done = false;
const b = require('./b');
console.log('在a中,b.done=', b.done);
exports.done = true;
console.log('a结束');
js 复制代码
// b.js
console.log('b 开始');
exports.done = false;
const a = require('./a');
console.log('在 b 中,a.done =', a.done);
exports.done = true;
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,自己执行结束并设置done:true。

因此,循环依赖在CommonJS中只是拿到了一个"不完整"的导出对象,一般不会死锁定,但需要在设计上注意。

二、ES Modules(ESM):import 的运作

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直接返回已求职的模块记录,导出的实时半丁依然生效。

js 复制代码
// ES 模块循环依赖示例
// a.mjs
import { b } from './b.mjs';
console.log('a 执行');
export const a = 'a-value';
console.log('b =', b);

// b.mjs
import { a } from './a.mjs';
console.log('b 执行');
export const b = 'b-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,其内部也是按上述步骤完成。
相关推荐
iDao技术魔方11 小时前
Node.js v26.5.0 深度解读:import Text、流式 ReadableStreamTee、以及隐藏的安全加固浪潮
安全·node.js
BAIGAOa13 小时前
还在手写 if-else 响应按键?我用 React-Ink 搭配键盘引擎解决了
node.js
Excel效率人16 小时前
飞书自动回复机器人开发:@机器人自助查询物流信息
机器人·node.js·飞书
先吃饱再说1 天前
从“地狱”到“楼梯”再到“同步写法”:Node.js 异步的二十年与Node内置fs模块详解
node.js
知识收割者1 天前
Nodejs也能写Agent - 10.LangChain篇 - 初识LangChain
node.js
HjhIron1 天前
Node.js 内置模块之 path 与 fs:从路径拼接到异步演进
node.js·promise
触底反弹1 天前
🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!
javascript·后端·node.js
sugar__salt1 天前
Node.js path 与 fs 核心模块完全指南 —— 从路径处理到异步流程控制
javascript·node.js
屑曦晨1 天前
npm 全局包迁移指南:从系统目录到用户目录
npm·node.js