Node.js 模块系统

模块系统概述

Node.js 支持两种模块系统:

  • CommonJS (CJS) :Node.js 的默认模块系统,使用 requiremodule.exports
  • ES Modules (ESM) :JavaScript 的官方标准模块系统,使用 importexport

为什么需要模块系统?

模块系统解决了以下问题:

  • 代码组织:将代码拆分为可管理的单元
  • 命名空间隔离:避免全局变量污染
  • 依赖管理:明确模块之间的依赖关系
  • 代码复用:在不同项目中共享代码

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.exportsexports 指向同一个对象
  • 不能直接给 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 的特点

  1. 同步加载require 是同步的,会阻塞代码执行直到模块加载完成
  2. 运行时加载:模块在运行时被加载和执行
  3. 动态导入 :可以在条件语句中使用 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 的特点

  1. 静态分析importexport 语句在编译时被分析
  2. 异步加载 :ESM 支持顶层 await
  3. 严格模式:ESM 模块默认在严格模式下运行
  4. 文件扩展名必需:导入本地模块时必须包含文件扩展名
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 会查找对应的文件或目录:

文件查找顺序

  1. 精确匹配:require('./math')math
  2. 添加 .jsrequire('./math')math.js
  3. 添加 .jsonrequire('./math')math.json
  4. 添加 .noderequire('./math')math.node(原生扩展)

目录查找顺序

  1. 查找 package.json 中的 main 字段
  2. 查找 index.js
  3. 查找 index.json
  4. 查找 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 目录中查找:

查找顺序

  1. 当前目录的 node_modules
  2. 父目录的 node_modules
  3. 继续向上查找,直到文件系统根目录
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 查找目录时,会查找:

  1. package.json 中的 exports 字段(优先)
  2. package.json 中的 main 字段
  3. index.mjsindex.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'); // 重新加载

缓存的影响

  1. 性能优化:避免重复加载和执行模块
  2. 单例模式:天然支持单例模式
  3. 状态共享:模块的状态在所有引用之间共享
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 也有模块缓存机制,但实现方式不同:

  1. 模块图缓存:ESM 在解析阶段构建模块图并缓存
  2. 实例缓存:每个模块只实例化一次
  3. 不可清除: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

原因分析

  1. a.js 开始执行,遇到 require('./b')
  2. b.js 开始执行,遇到 require('./a')
  3. 此时 a.js 还未执行完,module.exports 是空对象 {}
  4. b.js 获得的是 a.js 的部分导出(空对象)
  5. b.js 执行完毕,导出 { done: true }
  6. 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(因为函数延迟执行)

避免循环依赖的最佳实践

  1. 重构代码结构:提取公共功能到独立模块
  2. 延迟引用 :在函数内部使用 require
  3. 依赖注入:通过参数传递依赖
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 的特点

  1. 绑定(Binding):ESM 导出的是值的绑定,不是值的副本
  2. 提升(Hoisting):导入的绑定在模块执行前就存在
  3. 实时绑定(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 循环依赖的优势

  1. 可预测性:行为更加可预测
  2. 静态分析:可以在编译时检测循环依赖
  3. 更好的工具支持: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 不支持 支持
严格模式 默认否 默认是

关键要点

  1. CommonJS 是 Node.js 的默认模块系统,适合大多数 Node.js 项目
  2. ESM 是 JavaScript 的标准,适合新项目和需要浏览器兼容的项目
  3. 模块缓存 提高了性能,但也意味着模块状态是共享的
  4. 循环依赖 应该避免,如果无法避免,要理解其行为
  5. 选择合适的模块系统 并遵循最佳实践,可以提高代码质量和可维护性

推荐阅读

相关推荐
于慨18 小时前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz18 小时前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
从前慢丶18 小时前
前端交互规范(Web 端)
前端
CHU72903519 小时前
便捷约玩,沉浸推理:线上剧本杀APP功能版块设计详解
前端·小程序
GISer_Jing19 小时前
Page-agent MCP结构
前端·人工智能
王霸天19 小时前
💥别再抄网上的Scale缩放代码了!50行源码教你写一个永不翻车的大屏适配
前端·vue.js·数据可视化
小领航19 小时前
用 Three.js + Vue 3 打造炫酷的 3D 行政地图可视化组件
前端·github
@大迁世界19 小时前
2026年React大洗牌:React Hooks 将迎来重大升级
前端·javascript·react.js·前端框架·ecmascript
PieroPc19 小时前
一个功能强大的 Web 端标签设计和打印工具,支持服务器端直接打印到局域网打印机。Fastapi + html
前端·html·fastapi
悟空瞎说19 小时前
深入 Vue3 响应式:为什么有的要加.value,有的不用?从设计到源码彻底讲透
前端·vue.js