探究 Node.js 中的 CommonJS 模块

理解 CommonJS

CommonJS 是 一种模块规范,是一种用于 JavaScript 模块化的标准Node.js 实现了这种规范,在 CommonJS 规范中,每个文件都被视为一个独立的模块,模块内部的变量和函数默认是私有的,如果需要在其他模块中使用,需要通过 module.exports 导出,然后通过 require 来引入其他模块。它允许 JavaScript 代码实现模块化,让开发者将代码拆分为可重用的模块。

创建一个 CommonJS 模块

要创建一个 CommonJS 模块,只需在文件中定义您的函数、对象或变量,并使用 module.exports 对象将它们导出。

// greet.js
function greet(name) {
    return `你好,${name}!`;
}

module.exports = greet;

导入一个 CommonJS 模块

要在另一个文件中使用上面的模块,您需要使用 require() 进行导入。以下是一个示例:

// app.js
const greet = require('./greet');

console.log(greet('小明')); // 输出:你好,小明!

CommonJS 的优势

  • 模块化: 通过将代码分为更小、可管理的模块,实现更好的组织。

  • 可重用性: 模块可以在应用程序的不同部分之间轻松重用。

  • 依赖管理: 模块之间的明确依赖关系使得代码更易于理解和维护。

  • 封装性: 每个模块都有自己的作用域,可以防止全局作用域的污染。

提几个问题

  1. 一个模块中 require exports 是哪里来的?

  2. require 方法到底干了什么?

  3. exports和module.exports 有什么区别?

  4. require 只能加载 js文件么?

  5. ...

手写require

// app.js
const greet = require('./greet');

console.log(greet('小明')); // 输出:你好,小明!

分析

从上面的代码const greet = require('./greet'); 可以看出 require 是一个函数 并且有返回值,返回值就是 module.exports 导出的值

所以require 的样子大概可能是这样的

function require(...,module){
    ...
    return module.export
}

而我们的模块 定义的时候代码

// greet.js
function greet(name) {
    return `你好,${name}!`;
}

module.exports = greet;

文件中的 module 是哪里来的,为什么可以在js 文件中可以直接使用,在前端 可以直接使用的变为分为两种

    全局变为

    自己定义的局部变为(包括函数的形参)

所以 module 可能是全局变量也可能是 函数的形参,通过看node源码 的loader https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

可以看到node对CommonJS的实现

伪代码如下

function Module(id = '',parent) {
  this.id = id;       // 是我们require的路径
  this.path = path.dirname(id);     // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径
  this.exports = {};        // 导出
  this.filename = null;     // 模块对应的文件名
  this.loaded = false;      // loaded用来标识当前模块是否已经加载
}
// require 真正的实现
Module.prototype.require = function (id) {

  return Module._load(id);
}

Module._cache = Object.create(null);

Module._load = function (request) {    
  const filename = Module._resolveFilename(request);  // 返回

  // 先检查缓存,如果缓存存在且已经加载,直接返回缓存
  const cachedModule = Module._cache[filename];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }

  // 如果缓存不存在,我们就加载这个模块
  const module = new Module(filename);

  // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
  Module._cache[filename] = module;

  module.load(filename);

  return module.exports;
}
Module.prototype.load = function (filename) {
  // 获取文件后缀名
  const extname = path.extname(filename);

  // 调用后缀名对应的处理函数来处理 对应下面的实现  (Module._extensions['.js'] = function (){} )
  Module._extensions[extname](this, filename);
  // 标记为已加载
  this.loaded = true;
}

Module._extensions['.js'] = function (module, filename) {
  //读取文件内容
  const content = fs.readFileSync(filename, 'utf8');
  // 具体处理js 文件
  module._compile(content, filename);
}

Module.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];
Module.wrap = function (script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};
Module.prototype._compile = function (content, filename) {
  // 将从fs读取出来的 js模块代码 包装在一个函数里面  函数有  function (exports, require, module, __filename, __dirname) 5个参数
  const wrapper = Module.wrap(content);    // 获取包装后函数体
  // 下文有介绍 
  // `vm.runInThisContext()`方法接受两个参数:要编译和执行的代码字符串`wrapper`,以及一个可选的选项对象`{ ... }`。
  // 执行完后返回一个 编译后 的函数体  将代码字符串变为  函数  类似于 eval()
  const compiledWrapper = vm.runInThisContext(wrapper, {
    filename,
    lineOffset: 0,
    displayErrors: true,
  });

  const dirname = path.dirname(filename);
  // 相当于compiledWrapper(this.exports, this.require, this,filename, dirname) 
  // 也就是 包装后函数体function (exports, require, module, __filename, __dirname) 的五个参数
  // exports可以直接用module.exports,即this.exports
  // this.require 也就是 Module类原型上的 require方法 Module.prototype.require
  // this 也就是module对象
  // __filename 是  文件的绝对路径 也就是传进来的filename 形参
  // __dirname是  path.dirname(filename); 的返回值
  // 通过 call 修改了函数的this 指向 指向module.exports,即this.exports 也就是在node中 写代码this 指向 module.exports
  compiledWrapper.call(this.exports, this.exports, this.require, this,
    filename, dirname);
}

// eg  demo.js demo 文件所在位置/Users/test/Desktop
console.log('this:',this,'__filename:',__filename, '__dirname:',__dirname,'test');
// 上面代码的输出为
// this: {} __filename: /Users/test/Desktop/demo.js __dirname: /Users/test/Desktop

  1. const compiledWrapper = vm.runInThisContext(wrapper, { ... });

    这行代码定义了一个常量compiledWrapper,它将保存编译后的代码的结果。vm.runInThisContext()方法接受两个参数:要编译和执行的代码字符串wrapper,以及一个可选的选项对象{ ... }

  2. wrapper

    wrapper是一个JavaScript代码字符串,它包含要执行的代码。这个字符串可以是任何有效的JavaScript代码,包括函数定义、变量声明等。

  3. { filename, lineOffset: 0, displayErrors: true }

    这是vm.runInThisContext()方法的第二个参数,一个选项对象。它包含了一些可选的配置项:

    • filename:指定代码的文件名,用于错误堆栈跟踪和调试目的。

    • lineOffset:指定代码的行偏移量,用于错误堆栈跟踪和调试目的。

    • displayErrors:一个布尔值,指定是否在控制台上显示错误信息。

通过调用vm.runInThisContext()方法,传入要执行的代码字符串和选项对象,代码将被编译并在当前上下文中执行。编译后的结果将被赋值给compiledWrapper常量,可以在后续的代码中使用。如果在执行过程中发生错误,并且displayErrors选项设置为true,则错误信息将被显示在控制台上。

上述代码要注意我们注入进去的几个参数和通过call传进去的this:

  1. this:compiledWrapper是通过call调用的,第一个参数就是里面的this,这里我们传入的是this.exports,也就是module.exports,也就是说我们js文件里面this是对module.exports的一个引用。
  1. exports: compiledWrapper正式接收的第一个参数是exports,我们传的也是this.exports,所以js文件里面的exports也是对module.exports的一个引用。
  1. require: 这个方法我们传的是this.require,其实就是MyModule.prototype.require,也就是MyModule._load
  1. module: 我们传入的是this,也就是当前模块的实例。
  1. __filename:文件所在的绝对路径。
  1. __dirname: 文件所在文件夹的绝对路径。

当执行 let testJson = require(test.json) 这段代码时 最后会走到下面的伪代码里面 通过fs模块读取json文件内容 然后JSONParse 返回。

Module._extensions['.json'] = function(module, filename) {
  const content = fs.readFileSync(filename, 'utf8');

  try {
    module.exports = JSONParse(stripBOM(content));
  } catch (err) {
    err.message = filename + ': ' + err.message;
    throw err;
  }
};

当执行 let test = require(test.node) 这段代码时 最后会走到下面的伪代码里面 (.node文件是C++编译后的二进制文件,纯前端一般很少接触这个类型)

    // Native extension for .node
    Module._extensions['.node'] = function(module, filename) {
      // Be aware this doesn't use `content`
      return process.dlopen(module, path.toNamespacedPath(filename));
    };

总结

//test.js
module.exports = "hello world";

// app.js
let hello = require(test.js)
console.log(hello) //hello world

上面这段代码 最终会被包裹为然后执行

(function (exports, require, module, __filename, __dirname) {
    module.exports = "hello world";
})() 
// 最终 return  module.exports 
解决了上面提问的  1  2  4

那么问题3 exports和module.exports 有什么区别?

在 没有给 exports重新赋值的情况下 exports 和 module.exports 一样

如果这样使用

exports = { num:10 }

他们就不再相等了 而require 始终返回的 是 module.exports

和下面这段代码表达了相同的意思

<script>
  let a1 = {num:1};
  a1.num = a1.num+1;
  let c1 = a1
  a1 = {num:3};
  console.log(a1,c1); //a1为 {num:3}  c1为 {num:2}  这里面 a1相当于 module.exports  c1 相当于 exports
</script>

参考

https://segmentfault.com/a/1190000023828613

https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,980评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,422评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,130评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,553评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,408评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,326评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,720评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,373评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,678评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,722评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,486评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,335评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,738评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,283评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,692评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,893评论 2 335

推荐阅读更多精彩内容