Node.js:深入探秘 CommonJS 模块化的奥秘

在Node.js出现之前,服务端JavaScript基本上处于一片荒芜的境况,而当时也没有出现ES6的模块化规范。因此,Node.js采用了当时比较先进的一种模块化规范来实现服务端JavaScript的模块化机制,它就是CommonJS,有时也简称为CJS。

本文由Node.js部署神器-Servbay 工具赞助,开发环境管理神器!3分钟部署好你的项目开发环境。

一、CommonJS规范

在Node.js采用CommonJS规范之前,还存在以下缺点:

  • 没有模块系统
  • 标准库很少
  • 没有标准接口
  • 缺乏包管理系统

这些问题的存在导致Node.js难以构建大型项目,生态环境也十分贫乏,亟待解决。CommonJS的提出主要是为了弥补当前JavaScript没有模块化标准的缺陷,以达到像Java、Python、Ruby那样能够构建大型应用的阶段,而不是仅仅作为一门脚本语言。Node.js能够拥有今天这样繁荣的生态系统,CommonJS功不可没。

1.1 CommonJS的模块化规范

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

1.1.1 模块引用

示例如下:

const fs = require('fs');

在CommonJS规范中,存在一个require全局方法,它接受一个标识,然后把标识对应的模块的API引入到当前模块作用域中。

1.1.2 模块定义

在Node.js上下文环境中提供了一个module对象和一个exports对象。module代表当前模块,exports是当前模块的一个属性,代表要导出的一些API。一个文件就是一个模块,把方法或者变量作为属性挂载在exports对象上即可将其作为模块的一部分进行导出。

// add.js
exports.add = function(a, b) {
    return a + b;
};

在另一个文件中,我们可以通过require引入之前定义的这个模块:

const { add } = require('./add.js');
add(1, 2); // 输出 3
1.1.3 模块标识

模块标识就是传递给require函数的参数,在Node.js中就是模块的id。它必须是符合小驼峰命名的字符串,或者是以...开头的相对路径,或者绝对路径,可以不带后缀名。

模块的定义十分简单,接口也很简洁。它的意义在于将类聚的方法和变量限定在私有的作用域中,同时支持引入和导出功能以顺畅的连接上下游依赖。CommonJS这套模块导出和引入的机制使得用户完全不必考虑变量污染。

二、Node.js的模块化实现

Node.js在实现中并没有完全按照规范实现,而是对模块规范进行了一定的取舍,同时也增加了一些自身需要的特性。接下来我们会探究一下Node.js是如何实现CommonJS规范的。

在Node.js中引入模块会经过以下三个步骤:

  1. 路径分析
  2. 文件定位
  3. 编译执行

在了解具体的内容之前我们先了解两个概念:

  • 核心模块:Node.js提供的内置模块,比如fsurlhttp等。
  • 文件模块:用户自己编写的模块,比如Koa、Express等。

核心模块在Node.js源代码的编译过程中已经编译进了二进制文件,Node.js启动时会被直接加载到内存中,所以在我们引入这些模块的时候就省去了文件定位、编译执行这两个步骤,加载速度比文件模块要快很多。

文件模块是在运行的时候动态加载,需要走一套完整的流程:路径分析、文件定位、编译执行等,所以文件模块的加载速度比核心模块要慢。

2.1 优先从缓存加载

在讲解具体的加载步骤之前,我们应当知晓的一点是,Node.js对于已经加载过一遍的模块会进行缓存,模块的内容会被缓存到内存当中,如果下次加载了同一个模块的话,就会从内存中直接取出来,这样就省去了第二次路径分析、文件定位、加载执行的过程,大大提高了加载速度。无论是核心模块还是文件模块,require()对同一文件的第二次加载都一律会采用缓存优先的方式,这是第一优先级的。但是核心模块的缓存检查优先于文件模块的缓存检查。

我们在Node.js文件中所使用的require函数,实际上就是在Node.js项目中的lib/internal/modules/cjs/loader.js所定义的Module.prototype.require函数,只不过在后面的makeRequireFunction函数中还会进行一层封装,Module.prototype.require源码如下:

Module.prototype.require = function(id) {
    validateString(id, 'id');
    if (id === '') {
        throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
    }
    requireDepth++;
    try {
        return Module._load(id, this, /* isMain */ false);
    } finally {
        requireDepth--;
    }
};

可以看到它最终使用了Module._load方法来加载我们的标识符所指定的模块,找到Module._load

Module._cache = Object.create(null);

// Check the cache for the requested file.
Module._load = function(request, parent, isMain) {
    let relResolveCacheIdentifier;
    if (parent) {
        const filename = relativeResolveCache[relResolveCacheIdentifier];
        if (filename !== undefined) {
            const cachedModule = Module._cache[filename];
            if (cachedModule !== undefined) {
                updateChildren(parent, cachedModule, true);
                return cachedModule.exports;
            }
            delete relativeResolveCache[relResolveCacheIdentifier];
        }
    }

    const filename = Module._resolveFilename(request, parent, isMain);

    const cachedModule = Module._cache[filename];
    if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
    }

    const mod = loadNativeModule(filename, request, experimentalModules);
    if (mod && mod.canBeRequiredByUsers) return mod.exports;

    const module = new Module(filename, parent);

    if (isMain) {
        process.mainModule = module;
        module.id = '.';
    }

    Module._cache[filename] = module;
    if (parent !== undefined) {
        relativeResolveCache[relResolveCacheIdentifier] = filename;
    }

    let threw = true;
    try {
        module.load(filename);
        threw = false;
    } finally {
        if (threw) {
            delete Module._cache[filename];
            if (parent !== undefined) {
                delete relativeResolveCache[relResolveCacheIdentifier];
            }
        }
    }

    return module.exports;
};

Node.js先会根据模块信息解析出文件路径和文件名,然后以文件名作为Module._cache对象的键查询该文件是否已经被缓存,如果已经被缓存的话,直接返回缓存对象的exports属性。否则就会使用Module._resolveFilename重新解析文件名,再查询一遍缓存对象。否则就会当做核心模块来加载,核心模块使用loadNativeModule方法进行加载。

如果经过了以上几个步骤之后,在缓存中仍然找不到require加载的模块对象,那么就使用Module构造方法重新构造一个新的模块对象。加载完毕之后还会缓存到Module._cache对象中,以便下一次加载的时候可以直接从缓存中取到。

2.2 路径分析与文件定位

在Node.js中,路径分析与文件定位是通过Module._resolveFilename方法来实现的。该方法会根据传入的模块标识符和当前模块路径,确定模块文件的完整路径。

  1. 路径分析

    如果模块标识符是核心模块的名称,例如fshttp等,那么Module._resolveFilename会直接返回该核心模块的名称,而不需进一步分析。

  2. 文件定位

    如果是文件模块,Node.js会按照以下顺序进行文件定位:

    • 相对路径 :如果标识符以./../开头,Node.js会将其视为相对路径,从当前模块文件所在目录开始解析。
    • 绝对路径 :如果标识符以/开头,Node.js会将其视为绝对路径。
    • 模块路径 :如果标识符不是以./开头,Node.js会将其视为一个模块路径,按顺序在node_modules目录中查找。

    Node.js会尝试为文件模块添加.js.json.node后缀进行匹配,直到找到一个存在的文件为止。

2.3 编译执行

一旦文件定位完成,Node.js会根据文件扩展名选择不同的编译执行策略:

  • JavaScript 文件 :通过fs模块读取文件内容,并使用vm模块将内容包装在一个函数中执行。
  • JSON 文件 :通过fs模块读取文件内容,并使用JSON.parse解析。
  • C/C++ 扩展文件 :使用process.dlopen加载并执行。

Node.js将模块的内容包装在一个函数中,以提供模块作用域隔离。这个函数接收exportsrequiremodule__filename__dirname作为参数,使得模块内部可以使用这些变量。

三、模块加载优化与扩展

3.1 模块缓存

如前所述,Node.js使用Module._cache缓存已加载的模块,以提高加载速度。缓存机制确保每个模块文件在一次加载后,后续的加载请求都能直接从缓存中获取,避免重复加载。

3.2 扩展模块加载

Node.js允许用户自定义模块加载行为,通过require.extensions扩展模块加载方式。虽然不推荐在生产环境中使用,但在某些场景下可以用于加载自定义格式的文件。

require.extensions['.txt'] = function(module, filename) {
    const content = fs.readFileSync(filename, 'utf8');
    module.exports = content;
};

上面的代码示例展示了如何扩展.txt文件的加载方式,使得文本文件可以被require引入。

3.3 包装与作用域

在Node.js中,每个模块的代码实际上都被包装在一个函数中。这个函数提供了模块作用域隔离,防止变量污染全局作用域。模块包装器类似于以下形式:

(function(exports, require, module, __filename, __dirname) {
    // 模块代码在这里
});

这种机制确保每个模块都有自己的私有作用域,同时可以通过exports对象导出模块接口。

四、核心模块与文件模块的区别

  1. 加载速度

    核心模块在Node.js启动时已经加载到内存中,可以立即使用,加载速度非常快。文件模块需要经过路径解析、文件定位和编译执行等步骤,速度相对较慢。

  2. 优先级

    在解析模块标识符时,Node.js会优先检查核心模块。如果标识符匹配核心模块,则直接返回核心模块,而不进行文件系统操作。

  3. 缓存机制

    核心模块和文件模块都使用缓存机制,但核心模块的缓存检查优先于文件模块。

五、总结

Node.js的模块系统基于CommonJS规范,但在实现上进行了优化和扩展。通过模块缓存、路径解析、文件定位和编译执行等机制,Node.js实现了高效的模块加载。同时,Node.js的模块系统支持自定义扩展,允许开发者根据需要调整模块加载行为。

这种模块化设计不仅提升了代码的可维护性和可复用性,还支持了Node.js在服务器端的广泛应用。通过对Node.js模块系统的深入理解,开发者可以更有效地组织和管理项目代码,提高开发效率。

相关推荐
bjzhang754 小时前
Depcheck——专门用于检测 JavaScript 和 Node.js 项目中未使用依赖项的工具
javascript·node.js·depcheck
你不讲 wood9 小时前
使用 Axios 上传大文件分片上传
开发语言·前端·javascript·node.js·html·html5
tryCbest9 小时前
Nodejs安装配置及创建vue项目
vue.js·node.js
Luckyfif11 小时前
Webpack 是什么? 解决了什么问题? 核心流程是什么?
前端·webpack·node.js
熊的猫13 小时前
如何封装一个可取消的 HTTP 请求?
前端·javascript·vue.js·网络协议·http·webpack·node.js
黑金IT16 小时前
为什么使用Node.js爬虫更优
javascript·爬虫·node.js
API开发1 天前
APISQL企业版离线部署教程
sql·node.js·api·restful·graphql·apisql
前端小垃圾(找工作真难呐)1 天前
将项目从 Webpack 到 Vite 迁移的步骤
前端·webpack·node.js
黑金IT2 天前
Node.js与Python的交互:使用node-pyrunner模块
开发语言·python·node.js·交互
Fan_web2 天前
Node.js——初识Node.js
开发语言·前端·node.js