模块系统概述
Node.js 支持两种模块系统:
- CommonJS (CJS) :Node.js 的默认模块系统,使用
require和module.exports - ES Modules (ESM) :JavaScript 的官方标准模块系统,使用
import和export
为什么需要模块系统?
模块系统解决了以下问题:
- 代码组织:将代码拆分为可管理的单元
- 命名空间隔离:避免全局变量污染
- 依赖管理:明确模块之间的依赖关系
- 代码复用:在不同项目中共享代码
CommonJS 模块系统
CommonJS 是 Node.js 最早采用的模块系统,也是默认的模块系统。每个文件被视为一个独立的模块。
基本语法
导出模块
使用 module.exports 导出模块内容:
javascript
// math.js
module.exports = {
add: function(a, b) {
return a + b;
},
subtract: function(a, b) {
return a - b;
}
};
// 或者使用 exports 简写(注意:不能直接赋值给 exports)
exports.multiply = function(a, b) {
return a * b;
};
// 导出单个函数
module.exports = function(a, b) {
return a + b;
};
// 导出类
module.exports = class Calculator {
add(a, b) {
return a + b;
}
};
重要提示:
module.exports和exports指向同一个对象- 不能直接给
exports赋值(如exports = {}),这不会改变module.exports - 如果要导出单个值,必须使用
module.exports
导入模块
使用 require 导入模块:
javascript
// 导入本地模块(相对路径)
const math = require('./math');
console.log(math.add(2, 3)); // 5
// 导入本地模块(绝对路径)
const utils = require('/absolute/path/to/utils');
// 导入内置模块
const fs = require('fs');
const http = require('http');
const path = require('path');
// 导入 node_modules 中的包
const express = require('express');
const lodash = require('lodash');
// 解构导入
const { add, subtract } = require('./math');
// 导入 JSON 文件
const config = require('./config.json');
require 的特点
- 同步加载 :
require是同步的,会阻塞代码执行直到模块加载完成 - 运行时加载:模块在运行时被加载和执行
- 动态导入 :可以在条件语句中使用
require
javascript
// 动态导入示例
if (process.env.NODE_ENV === 'development') {
const devTools = require('./dev-tools');
devTools.enable();
}
// 在函数中使用
function loadModule(moduleName) {
return require(moduleName);
}
ES Modules (ESM)
ES Modules 是 JavaScript 的官方标准模块系统,Node.js 从 v12 开始正式支持 ESM。
启用 ESM
有两种方式启用 ESM:
方式 1:使用 .mjs 扩展名
javascript
// math.mjs
export function add(a, b) {
return a + b;
}
// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3));
方式 2:在 package.json 中设置 "type": "module"
json
{
"name": "my-project",
"version": "1.0.0",
"type": "module",
"main": "index.js"
}
设置后,所有 .js 文件都会被当作 ESM 模块处理。
基本语法
导出模块
javascript
// math.mjs
// 命名导出
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
// 默认导出(只能有一个)
export default class Calculator {
add(a, b) {
return a + b;
}
}
// 或者先定义后导出
function multiply(a, b) {
return a * b;
}
export { multiply };
// 重命名导出
export { multiply as mul };
// 导出所有(re-export)
export * from './other-module.mjs';
export { specific } from './other-module.mjs';
导入模块
javascript
// app.mjs
// 命名导入
import { add, subtract, PI } from './math.mjs';
// 默认导入
import Calculator from './math.mjs';
// 混合导入
import Calculator, { add, subtract } from './math.mjs';
// 导入所有(命名空间导入)
import * as math from './math.mjs';
console.log(math.add(2, 3));
// 重命名导入
import { add as sum } from './math.mjs';
// 仅执行模块(不导入任何内容)
import './initialize.mjs';
// 动态导入(返回 Promise)
const math = await import('./math.mjs');
const { add } = await import('./math.mjs');
// 条件动态导入
if (condition) {
const module = await import('./module.mjs');
}
ESM 的特点
- 静态分析 :
import和export语句在编译时被分析 - 异步加载 :ESM 支持顶层
await - 严格模式:ESM 模块默认在严格模式下运行
- 文件扩展名必需:导入本地模块时必须包含文件扩展名
javascript
// ✅ 正确
import { add } from './math.mjs';
// ❌ 错误(缺少扩展名)
import { add } from './math';
模块查找机制
CommonJS 模块查找机制
当使用 require('module-name') 时,Node.js 按以下顺序查找模块:
1. 内置模块(Core Modules)
首先检查是否为 Node.js 内置模块:
javascript
const fs = require('fs'); // 内置模块
const http = require('http'); // 内置模块
const path = require('path'); // 内置模块
2. 文件或目录模块
如果提供了相对路径(./ 或 ../)或绝对路径(/),Node.js 会查找对应的文件或目录:
文件查找顺序:
- 精确匹配:
require('./math')→math - 添加
.js:require('./math')→math.js - 添加
.json:require('./math')→math.json - 添加
.node:require('./math')→math.node(原生扩展)
目录查找顺序:
- 查找
package.json中的main字段 - 查找
index.js - 查找
index.json - 查找
index.node
javascript
// 示例目录结构
// my-module/
// ├── package.json (main: "lib/index.js")
// ├── index.js
// └── lib/
// └── index.js
const myModule = require('./my-module'); // 加载 lib/index.js
3. node_modules 目录查找
如果前两步都没有找到,Node.js 会在 node_modules 目录中查找:
查找顺序:
- 当前目录的
node_modules - 父目录的
node_modules - 继续向上查找,直到文件系统根目录
javascript
// 项目结构
// /home/user/project/
// ├── node_modules/ ← 第1优先级
// │ └── express/
// ├── src/
// │ ├── node_modules/ ← 第2优先级
// │ │ └── lodash/
// │ └── app.js
// └── package.json
// 在 src/app.js 中
const express = require('express'); // 查找 /home/user/project/node_modules/express
const lodash = require('lodash'); // 查找 /home/user/project/src/node_modules/lodash
4. 模块路径缓存
Node.js 会缓存模块路径的解析结果,提高后续查找速度。
ESM 模块查找机制
ESM 的模块查找机制与 CommonJS 类似,但有以下区别:
1. 文件扩展名必需
javascript
// ✅ 正确
import { add } from './math.mjs';
import { utils } from './utils.js';
// ❌ 错误
import { add } from './math';
2. 目录查找
ESM 查找目录时,会查找:
package.json中的exports字段(优先)package.json中的main字段index.mjs或index.js(取决于package.json中的type)
3. package.json 的 exports 字段
exports 字段提供了更精确的模块导出控制:
json
{
"name": "my-package",
"type": "module",
"exports": {
".": "./index.js",
"./utils": "./utils.js",
"./package.json": "./package.json"
}
}
javascript
// 使用
import pkg from 'my-package';
import utils from 'my-package/utils';
4. 相对路径解析
ESM 要求使用明确的相对路径:
javascript
// ✅ 正确
import { add } from './math.mjs';
import { utils } from '../utils/index.mjs';
// ❌ 错误
import { add } from 'math';
模块查找示例
javascript
// CommonJS
require('./math'); // 查找 ./math.js
require('./math.js'); // 直接加载 ./math.js
require('express'); // 查找 node_modules/express
require('/absolute/path'); // 绝对路径
// ESM
import './math.mjs'; // 必须包含扩展名
import 'express'; // 查找 node_modules/express
import '/absolute/path.mjs'; // 绝对路径
模块缓存机制
CommonJS 模块缓存
Node.js 会缓存所有已加载的模块,以提高性能。每个模块只执行一次,后续的 require 调用会返回缓存的结果。
缓存的工作原理
javascript
// moduleA.js
console.log('Module A 被加载');
let count = 0;
module.exports = {
increment: function() {
count++;
return count;
},
getCount: function() {
return count;
}
};
// app.js
const moduleA1 = require('./moduleA'); // 输出: "Module A 被加载"
console.log(moduleA1.getCount()); // 0
const moduleA2 = require('./moduleA'); // 无输出,使用缓存
moduleA1.increment();
console.log(moduleA2.getCount()); // 1(共享同一个实例)
console.log(moduleA1 === moduleA2); // true
缓存的存储位置
模块缓存存储在 require.cache 对象中:
javascript
// 查看缓存
console.log(require.cache);
// 删除缓存(不推荐,仅用于测试)
delete require.cache[require.resolve('./moduleA')];
const moduleA = require('./moduleA'); // 重新加载
缓存的影响
- 性能优化:避免重复加载和执行模块
- 单例模式:天然支持单例模式
- 状态共享:模块的状态在所有引用之间共享
javascript
// counter.js(单例示例)
let count = 0;
module.exports = {
increment: () => ++count,
decrement: () => --count,
getCount: () => count
};
// app.js
const counter1 = require('./counter');
const counter2 = require('./counter');
counter1.increment();
console.log(counter2.getCount()); // 1(共享状态)
ESM 模块缓存
ESM 也有模块缓存机制,但实现方式不同:
- 模块图缓存:ESM 在解析阶段构建模块图并缓存
- 实例缓存:每个模块只实例化一次
- 不可清除:ESM 的缓存不能像 CommonJS 那样手动清除
javascript
// math.mjs
console.log('Module loaded');
export const value = 42;
// app.mjs
import { value as v1 } from './math.mjs'; // 输出: "Module loaded"
import { value as v2 } from './math.mjs'; // 无输出,使用缓存
console.log(v1 === v2); // true
循环依赖处理
循环依赖是指两个或多个模块相互依赖,形成循环引用的情况。
CommonJS 中的循环依赖
在 CommonJS 中,循环依赖的处理方式可能导致意外的行为。
示例 1:基本循环依赖
javascript
// a.js
console.log('a.js 开始执行');
const b = require('./b');
console.log('a.js 中,b.done =', b.done);
module.exports.done = true;
console.log('a.js 执行完毕');
// b.js
console.log('b.js 开始执行');
const a = require('./a');
console.log('b.js 中,a.done =', a.done);
module.exports.done = true;
console.log('b.js 执行完毕');
// main.js
const a = require('./a');
const b = require('./b');
console.log('在 main.js 中,a.done =', a.done, 'b.done =', b.done);
执行结果:
css
a.js 开始执行
b.js 开始执行
b.js 中,a.done = undefined
b.js 执行完毕
a.js 中,b.done = true
a.js 执行完毕
在 main.js 中,a.done = true b.done = true
原因分析:
a.js开始执行,遇到require('./b')b.js开始执行,遇到require('./a')- 此时
a.js还未执行完,module.exports是空对象{} b.js获得的是a.js的部分导出(空对象)b.js执行完毕,导出{ done: true }a.js继续执行,获得完整的b.js导出
示例 2:函数导出(延迟执行)
javascript
// a.js
const b = require('./b');
module.exports = {
getB: function() {
return b;
},
done: true
};
// b.js
const a = require('./a');
module.exports = {
getA: function() {
return a; // 此时 a 可能还是空对象
},
done: true
};
// main.js
const a = require('./a');
const b = require('./b');
console.log(a.getB().done); // true
console.log(b.getA().done); // true(因为函数延迟执行)
避免循环依赖的最佳实践
- 重构代码结构:提取公共功能到独立模块
- 延迟引用 :在函数内部使用
require - 依赖注入:通过参数传递依赖
javascript
// 好的做法:延迟引用
// a.js
module.exports = {
getB: function() {
const b = require('./b'); // 延迟加载
return b;
}
};
// 更好的做法:重构
// common.js(提取公共功能)
module.exports = {
sharedFunction: function() {
// 共享逻辑
}
};
// a.js
const common = require('./common');
module.exports = {
// 使用 common
};
// b.js
const common = require('./common');
module.exports = {
// 使用 common
};
ESM 中的循环依赖
ESM 对循环依赖的处理更加严格和可预测。
ESM 循环依赖示例
javascript
// a.mjs
console.log('a.mjs 开始执行');
import { bDone } from './b.mjs';
console.log('a.mjs 中,bDone =', bDone);
export const aDone = true;
console.log('a.mjs 执行完毕');
// b.mjs
console.log('b.mjs 开始执行');
import { aDone } from './a.mjs';
console.log('b.mjs 中,aDone =', aDone);
export const bDone = true;
console.log('b.mjs 执行完毕');
// main.mjs
import { aDone } from './a.mjs';
import { bDone } from './b.mjs';
console.log('main.mjs 中,aDone =', aDone, 'bDone =', bDone);
执行结果:
ini
a.mjs 开始执行
b.mjs 开始执行
b.mjs 中,aDone = undefined(绑定存在但值未初始化)
b.mjs 执行完毕
a.mjs 中,bDone = true
a.mjs 执行完毕
main.mjs 中,aDone = true bDone = true
ESM 的特点:
- 绑定(Binding):ESM 导出的是值的绑定,不是值的副本
- 提升(Hoisting):导入的绑定在模块执行前就存在
- 实时绑定(Live Binding):导入的值会随着导出模块的变化而变化
javascript
// counter.mjs
export let count = 0;
export function increment() {
count++;
}
// app.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1(实时绑定)
ESM 循环依赖的优势
- 可预测性:行为更加可预测
- 静态分析:可以在编译时检测循环依赖
- 更好的工具支持:IDE 和工具可以更好地分析依赖关系
模块最佳实践
1. 选择合适的模块系统
使用 CommonJS 的场景
- 需要与大量现有 CommonJS 代码兼容
- 需要动态导入(条件导入)
- 项目主要运行在 Node.js 环境
使用 ESM 的场景
- 新项目(推荐)
- 需要在浏览器和 Node.js 中运行
- 需要利用静态分析和 Tree Shaking
- 需要顶层
await
2. 避免循环依赖
设计原则:
- 保持模块的单一职责
- 提取公共功能到独立模块
- 使用依赖注入
检测工具:
bash
# 使用 madge 检测循环依赖
npm install -g madge
madge --circular --extensions js ./src
3. 模块组织最佳实践
目录结构
bash
project/
├── src/
│ ├── modules/ # 功能模块
│ │ ├── user/
│ │ │ ├── index.js
│ │ │ ├── model.js
│ │ │ └── service.js
│ │ └── product/
│ ├── utils/ # 工具函数
│ │ ├── index.js
│ │ └── helpers.js
│ ├── config/ # 配置文件
│ │ └── index.js
│ └── index.js # 入口文件
├── node_modules/
├── package.json
└── README.md
导出模式
javascript
// 好的做法:清晰的导出
// utils/index.js
export { formatDate } from './date';
export { validateEmail } from './validation';
export { debounce } from './performance';
// 不好的做法:导出过多
export * from './date';
export * from './validation';
export * from './performance';
4. 性能优化
利用模块缓存
javascript
// ✅ 好的做法:利用缓存
const heavyModule = require('./heavy-module');
function processData() {
return heavyModule.process(); // 使用缓存的模块
}
// ❌ 不好的做法:重复加载
function processData() {
const heavyModule = require('./heavy-module'); // 每次都加载
return heavyModule.process();
}
延迟加载(Lazy Loading)
javascript
// CommonJS:延迟加载
function getHeavyModule() {
if (!heavyModule) {
heavyModule = require('./heavy-module');
}
return heavyModule;
}
// ESM:动态导入
async function processData() {
const { process } = await import('./heavy-module.mjs');
return process();
}
5. 错误处理
javascript
// CommonJS:错误处理
try {
const module = require('./module');
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
console.error('模块未找到');
} else {
console.error('加载模块时出错:', error);
}
}
// ESM:错误处理
try {
const module = await import('./module.mjs');
} catch (error) {
if (error.code === 'ERR_MODULE_NOT_FOUND') {
console.error('模块未找到');
} else {
console.error('加载模块时出错:', error);
}
}
6. 模块测试
javascript
// 测试模块导出
// math.test.js
const math = require('./math');
test('add function', () => {
expect(math.add(2, 3)).toBe(5);
});
// 模拟模块依赖
jest.mock('./dependency', () => ({
someFunction: jest.fn()
}));
7. 文档和注释
javascript
/**
* 数学工具模块
* @module math
*/
/**
* 两数相加
* @param {number} a - 第一个数
* @param {number} b - 第二个数
* @returns {number} 两数之和
*/
module.exports.add = function(a, b) {
return a + b;
};
8. 版本管理
json
{
"name": "my-package",
"version": "1.0.0",
"main": "index.js",
"exports": {
".": "./index.js",
"./utils": "./utils/index.js"
},
"engines": {
"node": ">=14.0.0"
}
}
9. 混合使用 CommonJS 和 ESM
在 CommonJS 中使用 ESM
javascript
// app.js (CommonJS)
(async () => {
const { default: esmModule } = await import('./esm-module.mjs');
// 使用 ESM 模块
})();
在 ESM 中使用 CommonJS
javascript
// app.mjs (ESM)
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const commonjsModule = require('./commonjs-module.js');
10. 安全检查
javascript
// 检查模块是否存在
function moduleExists(moduleName) {
try {
require.resolve(moduleName);
return true;
} catch (e) {
return false;
}
}
// 条件导入
if (moduleExists('optional-module')) {
const optional = require('optional-module');
// 使用可选模块
}
总结
CommonJS vs ESM 对比
| 特性 | CommonJS | ESM |
|---|---|---|
| 语法 | require / module.exports |
import / export |
| 加载时机 | 运行时 | 编译时(静态) |
| 文件扩展名 | 可选 | 必需(.mjs 或 .js with type: module) |
| 动态导入 | 原生支持 | import() 函数 |
| 顶层 await | 不支持 | 支持 |
| 循环依赖 | 部分支持(可能有问题) | 更好的支持 |
| Tree Shaking | 不支持 | 支持 |
| 严格模式 | 默认否 | 默认是 |
关键要点
- CommonJS 是 Node.js 的默认模块系统,适合大多数 Node.js 项目
- ESM 是 JavaScript 的标准,适合新项目和需要浏览器兼容的项目
- 模块缓存 提高了性能,但也意味着模块状态是共享的
- 循环依赖 应该避免,如果无法避免,要理解其行为
- 选择合适的模块系统 并遵循最佳实践,可以提高代码质量和可维护性