MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js中的CommonJS模块系统

2023-12-041.9k 阅读

一、CommonJS 模块系统概述

在 Node.js 的生态中,CommonJS 模块系统扮演着极为重要的角色。它为开发者提供了一种将代码分割成可复用模块的方式,使得大型项目的代码组织和管理变得更加容易。

(一)模块化的意义

在没有模块化之前,开发大型项目时,代码往往都写在一个文件里,随着功能的增加,代码量迅速膨胀,变量命名冲突、代码维护困难等问题接踵而至。模块化就是将代码按照功能、用途等维度拆分成一个个独立的模块,每个模块都有自己独立的作用域,模块之间通过特定的接口进行交互。这样做不仅提高了代码的可维护性,还增强了代码的复用性,使得开发大型项目变得更加高效。

(二)CommonJS 的诞生背景

JavaScript 最初是作为浏览器端的脚本语言,其运行环境相对简单,不需要模块化系统。但随着 Node.js 的出现,JavaScript 开始在服务器端运行,应用规模越来越大,对模块化的需求也日益迫切。CommonJS 应运而生,它定义了一种简单易用的模块规范,Node.js 率先采用并实现了该规范,从而使得 Node.js 具备了强大的模块管理能力。

二、CommonJS 模块的基本结构

(一)模块定义

在 Node.js 中,每个 JavaScript 文件都可以看作是一个独立的模块。模块内部定义的变量和函数默认是私有的,外部无法直接访问。要想让模块对外暴露接口,需要使用 exportsmodule.exports 对象。

例如,创建一个名为 mathUtils.js 的模块,用于实现一些简单的数学运算:

// mathUtils.js
// 定义一个私有变量
let privateVar = 10;

// 定义一个私有函数
function privateFunction() {
    console.log('This is a private function');
}

// 定义一个公有函数,通过 exports 对象暴露出去
exports.add = function(a, b) {
    return a + b;
};

exports.subtract = function(a, b) {
    return a - b;
};

在上述代码中,privateVarprivateFunction 是模块内部的私有成员,外部无法访问。而 addsubtract 函数通过 exports 对象暴露出去,其他模块可以引用并使用。

(二)模块引用

当我们定义好一个模块后,其他模块可以通过 require 方法来引用它。require 方法的参数是模块的标识符,通常是模块文件的路径(相对路径或绝对路径),如果是核心模块或已安装的 npm 模块,则直接使用模块名。

例如,创建一个 main.js 文件来引用 mathUtils.js 模块:

// main.js
const mathUtils = require('./mathUtils');

console.log(mathUtils.add(5, 3)); 
console.log(mathUtils.subtract(5, 3)); 

在上述代码中,通过 require('./mathUtils') 引用了 mathUtils.js 模块,并将其赋值给 mathUtils 变量。之后就可以通过 mathUtils 变量访问到 mathUtils.js 模块暴露出来的 addsubtract 函数。

三、exports 与 module.exports 的区别与联系

(一)exports 本质

exports 是一个普通的 JavaScript 对象,它在每个模块内部被自动创建。我们通过给 exports 对象添加属性和方法,来实现模块接口的暴露。例如在前面的 mathUtils.js 模块中,我们就是通过 exports.addexports.subtract 来暴露函数的。

(二)module.exports 本质

module.exports 也是一个对象,它同样用于定义模块的对外接口。实际上,exportsmodule.exports 的一个引用,即 exports === module.exports 最初是 true

(三)区别与使用场景

虽然 exportsmodule.exports 一开始指向同一个对象,但如果直接给 exports 赋值一个新的对象,就会切断它与 module.exports 的引用关系。例如:

// 错误示例
exports = {
    newFunction: function() {
        console.log('This is a new function');
    }
};

在上述代码中,给 exports 赋值了一个新对象,此时 exports 不再指向 module.exports,外部通过 require 引入该模块时,无法获取到 newFunction

而正确的做法是使用 module.exports 来赋值新对象:

// 正确示例
module.exports = {
    newFunction: function() {
        console.log('This is a new function');
    }
};

通常情况下,如果只是向模块外部暴露少量的函数或属性,使用 exports 会更简洁,例如 exports.add = function() {}。但如果需要暴露一个完整的对象、函数或数组等复杂结构,建议使用 module.exports,这样可以避免因 exports 赋值新对象而导致的引用问题。

四、模块的加载机制

(一)优先从缓存加载

Node.js 为了提高模块加载的效率,采用了缓存机制。当一个模块第一次被 require 时,Node.js 会将该模块的 exports 对象缓存起来。之后再次 require 同一个模块时,会直接从缓存中获取,而不会重新执行模块代码。

例如,创建一个 cachedModule.js 模块:

// cachedModule.js
console.log('This module is being loaded');
exports.message = 'Hello from cached module';

main.js 中多次引用该模块:

// main.js
const cachedModule1 = require('./cachedModule');
const cachedModule2 = require('./cachedModule');

console.log(cachedModule1.message); 
console.log(cachedModule2.message); 

运行 main.js 时,你会发现 This module is being loaded 只打印了一次,说明第二次 require 时直接从缓存中获取了模块。

(二)核心模块的加载

Node.js 内置了许多核心模块,如 fs(文件系统)、http(HTTP 服务器)等。加载核心模块非常简单,直接使用模块名即可,不需要指定路径。例如:

const fs = require('fs');
const http = require('http');

核心模块在 Node.js 启动时就已经加载并缓存,所以加载速度非常快。

(三)文件模块的加载

  1. 相对路径模块:如果 require 的参数是相对路径(以 ./../ 开头),Node.js 会根据当前模块所在的目录来查找目标模块。例如,在 src 目录下有 main.jsutils 目录,utils 目录下有 helper.js,在 main.js 中引用 helper.js 可以这样写:
const helper = require('./utils/helper');
  1. 绝对路径模块:使用绝对路径加载模块时,直接指定模块文件的完整路径。例如:
const absoluteModule = require('/Users/user/projects/myModule.js');

但在实际开发中,使用绝对路径不够灵活,相对路径更为常用。

(四)包模块的加载

require 的参数既不是核心模块,也不是相对路径或绝对路径时,Node.js 会将其视为包模块。包模块通常是通过 npm 安装的第三方模块。Node.js 会按照以下顺序查找包模块:

  1. 在当前目录的 node_modules 文件夹中查找。
  2. 如果没找到,向上级目录的 node_modules 文件夹查找,直到根目录。

例如,安装了 lodash 模块后,在项目中的任何模块都可以通过 const _ = require('lodash'); 来引用它,Node.js 会自动在合适的 node_modules 目录中找到并加载该模块。

五、模块的作用域

(一)模块作用域的概念

每个模块都有自己独立的作用域,在模块内部定义的变量、函数等只在该模块内有效,不会污染全局作用域,也不会与其他模块的变量和函数发生命名冲突。

例如,在 module1.js 中定义一个变量 count

// module1.js
let count = 0;
function increment() {
    count++;
    console.log(count);
}
exports.increment = increment;

module2.js 中也定义一个同名变量 count

// module2.js
let count = 100;
function decrement() {
    count--;
    console.log(count);
}
exports.decrement = decrement;

main.js 中分别引用这两个模块:

// main.js
const module1 = require('./module1');
const module2 = require('./module2');

module1.increment(); 
module2.decrement(); 

这里 module1module2 中的 count 变量互不干扰,各自在自己的模块作用域内独立存在。

(二)全局变量在模块中的表现

虽然模块有自己独立的作用域,但 Node.js 还是提供了一些全局变量,如 __dirname(当前模块所在目录的绝对路径)、__filename(当前模块的绝对路径)等。这些全局变量在每个模块内都可以直接使用,但它们的作用域仅限于模块内部,不会影响到其他模块或全局环境。

例如:

// 打印当前模块所在目录的绝对路径
console.log(__dirname);
// 打印当前模块的绝对路径
console.log(__filename);

此外,global 对象是 Node.js 中的全局对象,在浏览器端 JavaScript 中,全局对象是 window。在模块内部可以通过 global 对象访问或定义全局变量,但不推荐这样做,因为可能会导致命名冲突和代码可维护性问题。

六、模块的循环引用

(一)循环引用的情况

在实际开发中,可能会出现模块之间的循环引用。例如,模块 A 引用模块 B,而模块 B 又引用模块 A

假设有 a.jsb.js 两个模块:

// a.js
const b = require('./b');
console.log('In module A, b value:', b.value);
let value = 'A';
exports.value = value;
// b.js
const a = require('./a');
console.log('In module B, a value:', a.value);
let value = 'B';
exports.value = value;

main.js 中引用 a.js

// main.js
const a = require('./a');

当运行 main.js 时,会发现 In module A, b value: undefinedIn module B, a value: undefined。这是因为在循环引用中,当 a.js 引用 b.js 时,b.js 开始执行,而 b.js 又引用 a.js,此时 a.js 还没有完全执行完毕,a.value 还未赋值,所以 b.js 中获取到的 a.valueundefined。同理,a.js 中获取到的 b.value 也是 undefined

(二)解决循环引用的方法

  1. 调整模块结构:尽量避免模块之间形成循环引用,通过合理拆分模块、调整功能分布等方式来消除循环依赖。例如,可以将 a.jsb.js 中相互依赖的部分提取到一个新的模块 c.js 中,让 a.jsb.js 都依赖 c.js,而不是相互依赖。
  2. 使用中间变量:在模块中可以使用中间变量来暂存部分数据,避免在循环引用时直接获取未初始化的值。例如,在 a.js 中可以先定义一个中间变量,在模块执行完毕后再赋值给 exports.value
// a.js
let tempValue;
const b = require('./b');
console.log('In module A, b value:', b.value);
tempValue = 'A';
setTimeout(() => {
    exports.value = tempValue;
}, 0);

这样在 b.js 引用 a.js 时,虽然不能立即获取到 a.value 的最终值,但可以通过合理的逻辑处理,避免出现 undefined 的情况。不过这种方法相对复杂,且不够优雅,最好还是从根本上调整模块结构来解决循环引用问题。

七、CommonJS 模块与 ES6 模块的比较

(一)语法差异

  1. CommonJS:CommonJS 使用 exportsmodule.exports 来暴露模块接口,通过 require 来引入模块。例如:
// 暴露模块接口
exports.add = function(a, b) {
    return a + b;
};
// 引入模块
const mathUtils = require('./mathUtils');
  1. ES6 模块:ES6 模块使用 export 关键字来暴露模块接口,使用 import 关键字来引入模块。例如:
// 暴露模块接口
export function add(a, b) {
    return a + b;
}
// 引入模块
import { add } from './mathUtils.js';

ES6 模块的语法更加简洁和直观,而且支持多种导出和导入方式,如默认导出、命名导出等。

(二)加载机制差异

  1. CommonJS:CommonJS 是运行时加载,即模块在 require 时才会被加载和执行。这意味着在模块加载之前,无法获取模块的信息,而且模块可以根据运行时的条件动态加载。例如:
if (someCondition) {
    const moduleA = require('./moduleA');
} else {
    const moduleB = require('./moduleB');
}
  1. ES6 模块:ES6 模块是编译时加载,在编译阶段就确定了模块的依赖关系和导出接口。这使得 ES6 模块的静态分析更容易,并且可以进行一些优化,如 tree - shaking(摇树优化,去除未使用的代码)。但 ES6 模块不支持动态加载,导入语句必须放在模块的顶层,不能在函数或条件语句中使用。例如:
// 错误,ES6 模块导入语句不能在条件语句中
if (someCondition) {
    import { moduleA } from './moduleA.js';
}

(三)适用场景差异

  1. CommonJS:由于其动态加载的特性,更适合用于服务器端开发,如 Node.js 项目。在服务器端,模块的加载和执行通常是在运行时根据实际需求进行的,CommonJS 模块系统能够很好地满足这种需求。
  2. ES6 模块:ES6 模块的静态加载特性使其在前端开发中更具优势,尤其是在使用打包工具(如 Webpack)进行项目构建时,能够更好地进行代码优化和拆分。同时,现代浏览器也原生支持 ES6 模块,使得前端开发可以直接使用 ES6 模块进行模块化开发。

八、在实际项目中使用 CommonJS 模块系统

(一)项目结构组织

在实际项目中,合理的项目结构对于使用 CommonJS 模块系统至关重要。通常会将不同功能的模块放在不同的目录下,例如:

project/
├── src/
│   ├── controllers/
│   │   ├── userController.js
│   │   └── productController.js
│   ├── models/
│   │   ├── userModel.js
│   │   └── productModel.js
│   ├── utils/
│   │   ├── helper.js
│   │   └── logger.js
│   └── main.js
├── node_modules/
├── package.json
└── README.md

在上述项目结构中,controllers 目录存放与业务逻辑相关的控制器模块,models 目录存放数据模型模块,utils 目录存放一些通用的工具模块,main.js 是项目的入口文件。这种结构清晰的组织方式使得模块之间的关系一目了然,便于代码的维护和扩展。

(二)模块复用与依赖管理

  1. 模块复用:通过合理定义和暴露模块接口,可以实现模块的高度复用。例如,utils/helper.js 模块中定义了一些通用的函数,如字符串处理、日期格式化等,项目中的其他模块都可以引用该模块来复用这些函数,避免了重复开发。
  2. 依赖管理:在 Node.js 项目中,使用 package.json 文件来管理项目的依赖。通过 npm install 命令安装的第三方模块会被记录在 package.jsondependenciesdevDependencies 字段中。例如:
{
    "name": "my - project",
    "version": "1.0.0",
    "dependencies": {
        "express": "^4.17.1",
        "mongoose": "^5.11.10"
    },
    "devDependencies": {
        "jest": "^26.6.3"
    }
}

这样,当项目在不同环境部署时,只需要在项目目录下执行 npm install,npm 就会根据 package.json 中的依赖信息自动安装相应的模块,确保项目的正常运行。

(三)优化模块加载性能

  1. 合理使用缓存:由于 Node.js 本身已经提供了模块缓存机制,在开发中应尽量避免不必要的模块重复加载。例如,在一些频繁调用的函数中,如果需要引用模块,应将 require 语句放在函数外部,确保模块只被加载一次。
  2. 减少模块体积:对于一些较大的模块,可以通过代码拆分、去除无用代码等方式来减小模块体积,从而提高模块的加载速度。例如,将一些不常用的功能从主模块中拆分出来,在需要时再动态加载。

通过以上在实际项目中的应用,可以充分发挥 CommonJS 模块系统的优势,提高项目的开发效率和可维护性。