深入理解CommonJS规范及Node模块实现

作者:终端旅客时间:2026-10-23 00:41:36浏览:309

本文深入解析Node.js对CommonJS规范的实现机制,涵盖核心模块与文件模块的区别、require()方法的标识符分析与缓存策略、模块编译过程中的作用域隔离原理,以及JS、C/C++和JSON文件的加载差异,帮助开发者掌握Node模块系统的核心逻辑。

Node.js模块机制概述

前言

Node.js在实现模块系统时,并未完全照搬CommonJS规范,而是根据实际需求进行了取舍,并补充了部分特有功能。本文将详细解析Node.js中模块的具体实现方式。

引入

Node.js与浏览器端的JavaScript存在显著差异。在浏览器环境中,顶层对象是window;而在Node.js环境中,顶层对象则是global。

[注意]实际上,JavaScript标准中也存在global对象,但在浏览器中它并未直接暴露,而是通过window对象来指向global对象。

在浏览器JavaScript中,通过var a = 100声明的变量,可以通过window.a访问到值100。

Node.js模块机制概述

但在Node.js中,无法通过global.a访问到该变量,结果为undefined。

Node.js模块机制概述

这是因为var a = 100语句声明的变量a仅存在于当前模块的作用域内,而非挂载在global对象上。

在Node.js中,一个文件即视为一个独立的模块,每个模块拥有独立的作用域。使用var声明的变量属于当前模块局部变量,而非全局变量。

若需在全局作用域下声明变量,可采用如下方式:

Node.js模块机制概述

概述

Node.js中的模块主要分为两类:一类是Node内置的核心模块;另一类是由用户编写的文件模块。

核心模块在Node源代码编译过程中被编译进二进制执行文件。Node进程启动时,部分核心模块直接加载至内存,因此引入核心模块时可省略文件定位和编译执行步骤,且在路径分析中享有最高优先级,加载速度最快。

文件模块则在运行时动态加载,需经历完整的路径分析、文件定位及编译执行过程,因此加载速度相对较慢。

接下来,我们将详细展开模块的加载过程。

模块加载与缓存机制

模块加载

在浏览器JavaScript中,通常使用script标签加载模块;而在Node.js中,如何在一个模块中加载另一个模块?

Node.js使用require()方法来引入模块。

Node.js模块机制概述

【缓存加载】

在深入require()方法的标识符分析之前,需了解:与浏览器缓存静态脚本以提升性能类似,Node.js会对已引入的模块进行缓存,以减少二次引入的开销。区别在于,浏览器仅缓存文件,而Node.js缓存的是编译和执行后的模块对象。

无论是核心模块还是文件模块,require()方法对相同模块的二次加载均优先采用缓存机制,这是第一优先级。不同之处在于,核心模块的缓存检查优先于文件模块的缓存检查。

【标识符分析】

require()方法接受一个标识符作为参数。Node.js正是基于该标识符进行模块查找。模块标识符在Node中主要分为以下几类:[1]核心模块,如http、fs、path等;[2]以.或..开头的相对路径文件模块;[3]以/开头的绝对路径文件模块;[4]非路径形式的文件模块,如自定义的connect模块。

根据参数格式的不同,require命令会前往不同的路径寻找模块文件。

1、若参数字符串以“/”开头,表示加载位于绝对路径的模块文件。例如,require('/home/marco/foo.js')将加载/home/marco/foo.js。

2、若参数字符串以“./”开头,表示加载位于相对路径(相对于当前执行脚本位置)的模块文件。例如,require('./circle')将加载当前脚本同一目录下的circle.js。

3、若参数字符串不以“./”或“/”开头,则表示加载默认提供的核心模块(位于Node系统安装目录中),或位于各级node_modules目录中的已安装模块(全局或局部安装)。

[注意]若需加载当前路径下的文件模块,必须以./开头,否则Node.js会尝试加载核心模块或node_modules内的模块。


//a.js
console.log('aaa');

//b.js
require('./a');//'aaa'
require('a');//报错

【文件扩展名分析】

require()在分析标识符时,可能遇到标识符中不包含文件扩展名的情况。CommonJS模块规范允许标识符不带扩展名,此时Node会先查找无后缀的同名文件;若未找到,则依次尝试补足.js、.json、.node扩展名并查找。

在尝试过程中,需调用fs模块同步阻塞式地判断文件是否存在。由于Node是单线程模型,这可能引发性能问题。小诀窍是:若处理.node和.json文件,在传递给require()的标识符中显式带上扩展名,可略微提升速度。另一个诀窍是:同步调用配合缓存机制,可大幅缓解Node单线程中阻塞式调用的缺陷。

【目录分析和包】

在分析标识符时,require()在通过文件扩展名查找后若未找到具体文件,却定位到一个目录,这在引入自定义模块或逐个查找模块路径时常见。此时,Node会将该目录视为一个包进行处理。

在此过程中,Node对CommonJS包规范提供了部分支持。首先,Node在当前目录下查找package.json(CommonJS包规范定义的包描述文件),通过JSON.parse()解析出包描述对象,并从中提取main属性指定的文件名进行定位。若文件名缺少扩展名,将进入扩展名分析步骤。

若main属性指定的文件名错误,或根本不存在package.json文件,Node会将index作为默认文件名,依次查找index.js、index.json、index.node。

若在目录分析中未能定位到任何文件,自定义模块将进入下一个模块路径进行查找。若遍历完所有模块路径仍未找到目标文件,则抛出查找失败的异常。

模块间变量访问方式

访问变量

如何在一个模块中访问另一个模块中定义的变量?

【global】

最直观的方法是将一个模块定义的变量复制到全局环境global中,其他模块即可访问全局环境。


//a.js
var a = 100;
global.a = a;

//b.js
require('./a');
console.log(global.a);//100

此方法虽简单,但因会污染全局环境,不推荐使用。

【module】

常用方法是使用Node.js提供的模块对象Module,该对象保存了当前模块的相关信息。


function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  if (parent && parent.children) {
    parent.children.push(this);
  }
  this.filename = null;
  this.loaded = false;
  this.children = [];
}
  1. module.id:模块的识别符,通常带有绝对路径的模块文件名。
  2. module.filename:模块的文件名,带有绝对路径。
  3. module.loaded:返回布尔值,表示模块是否已完成加载。
  4. module.parent:返回对象,表示调用该模块的父模块。
  5. module.children:返回数组,表示该模块依赖的其他模块。
  6. module.exports:表示模块对外输出的值。

Node.js模块机制概述

【exports】

module.exports属性表示当前模块对外输出的接口,其他文件加载该模块时,实际上就是读取module.exports变量。


//a.js
var a = 100;
module.exports.a = a;

//b.js
var result = require('./a');
console.log(result);//'{ a: 100 }'

为方便使用,Node为每个模块提供一个exports变量,指向module.exports。这意味着在对外输出模块接口时,可以向exports对象添加方法。


console.log(module.exports === exports);//true

[注意]不能直接将exports变量指向一个新值,因为这会切断exports与module.exports之间的联系。

模块编译与执行原理

模块编译

编译和执行是模块实现的最后阶段。定位到具体文件后,Node会新建一个模块对象,并根据路径载入并编译。针对不同文件扩展名,载入方法有所不同,具体如下:

js文件——通过fs模块同步读取文件后编译执行。

node文件——这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件。

json文件——通过fs模块同步读取文件后,使用JSON.parse()解析返回结果。

其余扩展名文件——均被当作.js文件载入。

每个编译成功的模块都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。

根据不同的文件扩展名,Node会调用不同的读取方式,例如.json文件的调用如下:


// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
  var content = NativeModule.require('fs').readFileSync(filename, 'utf8'); 
  try {
    module.exports = JSON.parse(stripBOM(content));
  } catch (err) {
    err.message = filename + ': ' + err.message;
    throw err;
  }
};

其中,Module._extensions会被赋值给require()的extensions属性,因此通过代码访问require.extensions可获知系统中已有的扩展加载方式。编写如下代码测试:


console.log(require.extensions);

得到的执行结果如下:


{ '.js': [Function], '.json': [Function], '.node': [Function] }

在确定文件扩展名后,Node将调用具体的编译方式将文件执行后返回给调用者。

【JavaScript模块的编译】

回顾CommonJS模块规范,我们知道每个模块文件中存在require、exports、module这3个变量,但它们在模块文件中并未定义,那么它们从何而来?甚至在Node的API文档中,我们还知道每个模块中存在filename、dirname这两个变量,它们又是从何而来的?若将直接定义模块的过程置于浏览器端,会导致全局变量污染。

事实上,在编译过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了(function(exports, require, module, filename, dirname) {\n,在尾部添加了\n});。

一个正常的JavaScript文件会被包装成如下形式:


(function (exports, require, module, filename, dirname) {
  var math = require('math');
  exports.area = function (radius) {
    return Math.PI * radius * radius;
  };
});

这样每个模块文件之间进行了作用域隔离。包装后的代码会通过vm原生模块的runInThisContext()方法执行(类似eval,但具有明确上下文,不污染全局),返回一个具体的function对象。最后,将当前模块对象的exports属性、require()方法、module(模块对象自身),以及在文件定位中得到的完整文件路径和文件目录作为参数传递给该function()执行。

这就是这些变量并未在每个模块文件中定义却存在的原因。执行后,模块的exports属性被返回给调用方。exports属性上的任何方法和属性均可被外部调用,但模块中的其余变量或属性则不可直接调用。

至此,require、exports、module的流程已完整,这就是Node对CommonJS模块规范的实现。

【C/C++模块的编译】

Node调用process.dlopen()方法进行加载和执行。在Node的架构下,dlopen()方法在Windows和*nix平台下分别有不同的实现,通过libuv兼容层进行了封装。

实际上,.node的模块文件无需编译,因为它是编写C/C++模块后编译生成的,因此这里仅涉及加载和执行过程。在执行过程中,模块的exports对象与.node模块产生联系,然后返回给调用者。

C/C++模块给Node使用者带来的优势主要是执行效率,劣势则是C/C++模块的编写门槛比JavaScript高。

【JSON文件的编译】

.json文件的编译是3种编译方式中最简单的。Node利用fs模块同步读取JSON文件内容后,调用JSON.parse()方法得到对象,然后将其赋给模块对象的exports,以供外部调用。

JSON文件在用作项目配置文件时较为实用。若定义了一个JSON文件作为配置,便无需调用fs模块去异步读取和解析,直接调用require()引入即可。此外,还可享受模块缓存的便利,且二次引入时无性能影响。

CommonJS规范详解

CommonJS

在介绍完Node的模块实现后,回到CommonJS规范本身,相对容易理解。

CommonJS规范的提出,主要是为了弥补当前JavaScript缺乏标准的缺陷,使其具备开发大型应用的基础能力,而非仅停留在小脚本程序阶段。

CommonJS对模块的定义十分简单,主要分为模块引用、模块定义和模块标识3个部分。

【模块引用】


var math = require('math');

在CommonJS规范中,存在require()方法,该方法接受模块标识,以此引入一个模块的API到当前上下文中。

【模块定义】

在模块中,上下文提供require()方法来引入外部模块。对应引入的功能,上下文提供了exports对象用于导出当前模块的方法或变量,且它是唯一导出的出口。在模块中,还存在一个module对象,代表模块自身,而exports是module的属性。在Node中,一个文件就是一个模块,将方法挂载在exports对象上作为属性即可定义导出的方式:


// math.js
exports.add = function () {
  var sum = 0, i = 0,args = arguments, l = args.length;
  while (i < l) {
    sum += args[i++];
  }
  return sum;
};

在另一个文件中,我们通过require()方法引入模块后,即可调用定义的属性或方法。


// program.js
var math = require('math');
exports.increment = function (val) {
  return math.add(val, 1);
};

【模块标识】

模块标识即传递给require()方法的参数,必须是符合小驼峰命名的字符串,或以.、..开头的相对路径,或绝对路径。它可以没有文件名后缀.js。

模块的定义十分简单,接口也极为简洁。其意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出功能以顺畅连接上下游依赖。每个模块具有独立空间,互不干扰,引用时显得干净利落。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章