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. 选择合适的模块系统 并遵循最佳实践,可以提高代码质量和可维护性

推荐阅读

相关推荐
Light609 小时前
CSS逻辑革命:原生if()函数如何重塑我们的样式编写思维
前端·css·响应式设计·组件化开发·css if函数·声明式ui·现代css
蜡笔小嘟10 小时前
宝塔安装dify,更新最新版本--代码版
前端·ai编程·dify
ModyQyW10 小时前
HBuilderX 4.87 无法正常读取 macOS 环境配置的解决方案
前端·uni-app
bitbitDown11 小时前
我的2025年终总结
前端
五颜六色的黑11 小时前
vue3+elementPlus实现循环列表内容超出时展开收起功能
前端·javascript·vue.js
wscats12 小时前
Markdown 编辑器技术调研
前端·人工智能·markdown
EnoYao12 小时前
Markdown 编辑器技术调研
前端·javascript·人工智能
JIngJaneIL12 小时前
基于java+ vue医院管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
JIngJaneIL12 小时前
基于java + vue校园跑腿便利平台系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
前端要努力12 小时前
月哥创业3年,还活着!
前端·面试·全栈