Node.js 模块系统

Node.js 采用模块化设计,每个文件都是一个独立的模块。模块系统是 Node.js 的基石,它允许你将代码拆分成可复用的单元,并通过 requiremodule.exports 进行交互。本章将全面解析 Node.js 的模块机制,包括 CommonJS 和 ES Modules。

1. 为什么需要模块?

在复杂的应用程序中,将代码分割成多个文件有助于:

  • 避免命名冲突
  • 提高代码的可维护性和可读性
  • 实现代码复用
  • 管理依赖关系

Node.js 最初采用 CommonJS 模块规范,后来也逐渐支持 ECMAScript 模块(ESM)。

2. CommonJS 模块

CommonJS 是 Node.js 默认的模块系统(直到 ESM 出现)。每个文件都被视为一个模块,模块内的变量、函数、类都是私有的,除非显式导出。

2.1 导出模块:module.exports 与 exports

每个模块都有一个 module 对象,其中包含 exports 属性。初始时 module.exports 是一个空对象。你可以通过两种方式导出:

  • 直接给 module.exports 赋值(可以导出任意值:对象、函数、字符串等)。
  • 使用 exports 快捷方式(exportsmodule.exports 的引用)。

示例: 创建 math.js

// 方式一:给 exports 添加属性
exports.add = (a, b) => a + b;
exports.subtract = (a, b) => a - b;

// 方式二:直接赋值给 module.exports(会覆盖 exports)
// module.exports = {
//   add: (a, b) => a + b,
//   subtract: (a, b) => a - b
// };
注意: 不要同时使用 exportsmodule.exports,因为 exports 只是对 module.exports 的引用,一旦 module.exports 被重新赋值,exports 就不再指向同一个对象。

2.2 导入模块:require()

使用 require() 函数加载其他模块。它返回目标模块的 module.exports 的值。

// app.js
const math = require('./math.js');
console.log(math.add(5, 3)); // 8

require() 的参数可以是:

  • 核心模块:如 require('fs')require('http')
  • 文件模块:以 .//../ 开头的路径,指向具体的文件或目录。
  • 第三方模块:如 require('express'),从 node_modules 中查找。

2.3 模块加载机制

当调用 require() 时,Node.js 按照以下步骤解析模块:

  1. 核心模块:优先检查是否是内置模块(如 fspath),如果是则直接返回。
  2. 文件模块:如果路径以 .//../ 开头,则将其解析为绝对路径,并加载对应文件。如果找不到文件,会尝试添加 .js.json.node 扩展名。
  3. 目录模块:如果路径指向一个目录,Node.js 会查找该目录下的 package.json 文件,并读取其 main 字段指定的入口文件;如果没有 package.json,则默认加载 index.jsindex.jsonindex.node
  4. node_modules 查找:如果标识符不是核心模块也不是绝对/相对路径,Node.js 会从当前目录开始,逐级向上查找 node_modules 目录,直到根目录。
// 假设项目结构
// project/
// ├── node_modules/
// │   └── lodash/
// ├── lib/
// │   └── util.js
// └── app.js

// 在 app.js 中:
const _ = require('lodash');        // 从 node_modules 查找
const util = require('./lib/util'); // 文件模块

2.4 模块缓存

模块在第一次加载后会被缓存,后续 require() 直接返回缓存的结果,不会重新执行模块代码。可以通过 console.log(require.cache) 查看缓存对象。如果希望重新加载模块,可以删除缓存:

delete require.cache[require.resolve('./math.js')];
const math = require('./math.js'); // 重新加载

2.5 循环依赖

当两个模块相互引用时,Node.js 会返回当前已经执行的 exports 部分(未完成的模块将获得一个不完整的对象)。例如:

a.js

console.log('a 开始');
exports.done = false;
const b = require('./b.js');
console.log('a 中,b.done =', b.done);
exports.done = true;
console.log('a 结束');

b.js

console.log('b 开始');
exports.done = false;
const a = require('./a.js');
console.log('b 中,a.done =', a.done);
exports.done = true;
console.log('b 结束');

运行 node a.js 输出:

a 开始
b 开始
b 中,a.done = false
b 结束
a 中,b.done = true
a 结束

可见 Node.js 能够处理循环依赖,但设计时应尽量避免。

3. ES Modules (ESM) 在 Node.js 中的支持

从 Node.js 12 开始,ES Modules 得到了稳定支持。ESM 使用 importexport 语句,是 JavaScript 官方的模块标准。

3.1 启用 ES Modules

有两种方式告诉 Node.js 将文件视为 ES 模块:

  • 文件扩展名使用 .mjs
  • 在最近的 package.json 中设置 "type": "module",此时 .js 文件会被视为 ESM(但 .cjs 仍为 CommonJS)。

在 ESM 中,不能直接使用 require()__dirname__filename 等 CommonJS 特有的变量。如果需要获取当前目录,可以使用 import.meta.url 结合 url 模块:

import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

3.2 导出与导入

命名导出:

// lib.js
export const name = 'ES Module';
export function greet() {
  return 'Hello';
}
// 或者统一导出
const age = 5;
export { age };

默认导出:

// default.js
export default function() {
  console.log('default export');
}

导入:

import { name, greet, age } from './lib.js';
import myDefault from './default.js';

3.3 混合使用 CommonJS 和 ESM

在 ESM 中,可以使用 import 加载 CommonJS 模块(只能默认导入)。例如:

import fs from 'fs'; // 内置模块是 CommonJS,但可以默认导入
import _ from 'lodash'; // 第三方 CommonJS 模块

在 CommonJS 中,不能使用 require() 加载 ESM(因为 ESM 是异步的)。如果需要,可以使用动态 import()

// CommonJS 中加载 ESM
(async () => {
  const esModule = await import('./es-module.mjs');
})();

4. package.json 与模块入口

package.json 是 Node.js 项目的核心文件,其中 main 字段指定了模块的入口文件。对于 ESM,还可以使用 exports 字段定义更精细的导出子路径:

{
  "name": "my-package",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}

这样用户可以通过 import 'my-package'import 'my-package/utils' 分别引入。

5. 核心模块与内置模块

Node.js 提供了一系列内置模块,如 fspathhttpevents 等。它们在 Node.js 源代码中编译而成,加载速度最快。可以通过 require('module')import 直接使用。

const fs = require('fs');
import fs from 'fs'; // ESM 中也可以默认导入

6. 模块封装器

在 CommonJS 中,Node.js 在执行模块代码之前,会将其包装在一个函数中,以实现模块作用域。这个包装函数如下:

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

这就是为什么在模块中可以直接使用 exportsrequiremodule__filename__dirname 的原因。

总结: Node.js 的模块系统强大而灵活。CommonJS 仍然是大多数现有项目的选择,而 ESM 是未来的标准。理解模块加载机制对调试和优化应用至关重要。

7. 常见问题

  • Q: exportsmodule.exports 到底有什么区别?
    A: exportsmodule.exports 的引用,初始指向同一个对象。如果给 exports 添加属性,会反映到 module.exports;但如果直接给 module.exports 赋值新对象,exports 就不再指向它了。
  • Q: 为什么 require() 是同步的?
    A: CommonJS 设计为同步加载,因为服务器端模块通常来自本地文件系统,同步加载是可行的。而浏览器环境需要异步,因此有了 AMD、ESM 等方案。
  • Q: 如何选择 CommonJS 还是 ESM?
    A: 新项目推荐使用 ESM,因为它已成为标准。但维护旧项目或需要与大量 CommonJS 包交互时,CommonJS 仍然可靠。

小结

本章涵盖了 Node.js 模块系统的方方面面,包括 CommonJS 的导出/导入、加载机制、缓存、循环依赖,以及 ES Modules 的基本用法。掌握这些知识后,你将能更好地组织代码并利用 npm 生态。下一章我们将深入 npm 包管理器,学习如何安装、发布和管理依赖。