CommonJS模块
CommonJS模块
CommonJS是Node.js采用的模块系统。本文将介绍require和module.exports的用法,以及CommonJS模块的加载机制和与ES模块的区别。
CommonJS 简介
CommonJS 是一种模块规范,最初设计用于服务器端 JavaScript 环境。Node.js 采用并实现了这一规范,使其成为 Node.js 默认的模块系统。CommonJS 模块具有以下特点:
- 同步加载:模块在导入时同步加载和执行
- 单例模式:模块只会被加载一次,之后的导入会使用缓存
- 值拷贝:导入的是模块导出值的拷贝,而非引用
- 动态性:可以在运行时动态导入模块
- 封装性:每个模块有自己的作用域,不会污染全局命名空间
模块导出 (module.exports 和 exports)
在 CommonJS 中,每个模块都有一个 module
对象,其中包含一个 exports
属性,用于导出模块的公共 API。
使用 module.exports
module.exports
是模块导出的主要方式,可以导出任何 JavaScript 值:
// 导出一个对象
module.exports = {
name: 'calculator',
add: function(a, b) {
return a + b;
},
subtract: function(a, b) {
return a - b;
}
};
// 导出一个函数
module.exports = function(a, b) {
return a + b;
};
// 导出一个类
module.exports = class Calculator {
add(a, b) {
return a + b;
}
subtract(a, b) {
return a - b;
}
};
使用 exports
exports
是 module.exports
的引用,可以用来添加属性到导出对象:
// math.js
exports.add = function(a, b) {
return a + b;
};
exports.subtract = function(a, b) {
return a - b;
};
exports.PI = 3.14159;
需要注意的是,直接给 exports
赋值会破坏它与 module.exports
的引用关系,导致导出失败:
// 错误用法 - 这不会导出任何内容
exports = {
add: function(a, b) {
return a + b;
}
};
// 正确用法 - 使用 module.exports
module.exports = {
add: function(a, b) {
return a + b;
}
};
// 或者添加属性到 exports
exports.add = function(a, b) {
return a + b;
};
exports 和 module.exports 的关系
在模块内部,exports
最初是 module.exports
的引用:
// 模块系统内部大致是这样设置的
var module = { exports: {} };
var exports = module.exports;
// 你的模块代码在这里
// ...
// 最终返回 module.exports,而不是 exports
return module.exports;
因此,当你修改 exports
的属性时,实际上是在修改 module.exports
的属性。但如果你直接给 exports
赋值,就会切断这种引用关系。
模块导入 (require)
CommonJS 使用 require
函数导入模块。
基本用法
// 导入核心模块
const fs = require('fs');
const path = require('path');
// 导入本地模块
const math = require('./math');
console.log(math.add(2, 3)); // 5
// 导入 npm 包
const express = require('express');
const lodash = require('lodash');
导入解析规则
require
函数根据以下规则解析模块路径:
- 核心模块:如
fs
、path
、http
等,直接使用模块名 - 文件模块:以
./
或../
开头的相对路径,或以/
开头的绝对路径 - 包模块:不以
/
、./
或../
开头的模块名,通常是 npm 包
对于文件模块,Node.js 会按以下顺序尝试解析:
- 如果提供了确切的文件名,直接加载该文件
- 如果没有扩展名,尝试添加
.js
、.json
或.node
扩展名 - 如果是目录,查找目录中的
package.json
文件,并加载其main
字段指定的文件 - 如果没有
package.json
或main
字段,尝试加载目录中的index.js
、index.json
或index.node
// 这些都是有效的导入
const moduleA = require('./moduleA'); // moduleA.js
const moduleB = require('./moduleB.js'); // 明确的扩展名
const moduleC = require('./moduleC/index.js'); // 明确的文件
const moduleD = require('./moduleD'); // moduleD/index.js
const config = require('./config.json'); // JSON 文件
导入缓存
Node.js 会缓存已加载的模块,确保每个模块只被加载一次。这意味着,如果多次导入同一个模块,得到的是同一个对象:
// a.js
console.log('模块 A 被加载');
module.exports = { count: 0 };
// main.js
const a1 = require('./a');
a1.count += 1;
console.log(a1.count); // 1
const a2 = require('./a');
console.log(a2.count); // 1,而不是 0,因为 a1 和 a2 是同一个对象
// 输出:
// 模块 A 被加载
// 1
// 1
可以通过 require.cache
访问和操作模块缓存:
// 查看缓存的模块
console.log(Object.keys(require.cache));
// 删除缓存中的模块
delete require.cache[require.resolve('./a')];
// 再次导入模块(会重新执行模块代码)
const a3 = require('./a');
console.log(a3.count); // 0
循环依赖
CommonJS 允许模块之间存在循环依赖,但可能导致一些意外行为:
// a.js
console.log('a 开始加载');
const b = require('./b');
console.log('在 a 中,b.done =', b.done);
exports.done = true;
console.log('a 结束加载');
// b.js
console.log('b 开始加载');
const a = require('./a');
console.log('在 b 中,a.done =', a.done);
exports.done = true;
console.log('b 结束加载');
// main.js
console.log('main 开始加载');
const a = require('./a');
console.log('在 main 中,a.done =', a.done);
const b = require('./b');
console.log('在 main 中,b.done =', b.done);
输出结果:
main 开始加载
a 开始加载
b 开始加载
在 b 中,a.done = undefined
b 结束加载
在 a 中,b.done = true
a 结束加载
在 main 中,a.done = true
在 main 中,b.done = true
这是因为 Node.js 在检测到循环依赖时,会返回当前已导出的值,即使模块尚未完全执行完毕。
模块加载机制
CommonJS 模块的加载过程包括以下步骤:
- 解析:根据模块标识符解析出模块的绝对路径
- 加载:检查模块是否已缓存,如果已缓存则返回缓存的模块,否则继续
- 包装:将模块代码包装在一个函数中,提供
require
、module
、exports
等变量 - 执行:执行模块代码,填充
module.exports
- 缓存:将模块对象缓存起来
- 返回:返回
module.exports
模块包装
Node.js 会将模块代码包装在一个函数中,以提供模块级作用域:
// 原始模块代码
const name = 'calculator';
exports.add = function(a, b) {
return a + b;
};
// Node.js 内部包装后的代码
(function(exports, require, module, __filename, __dirname) {
const name = 'calculator';
exports.add = function(a, b) {
return a + b;
};
});
这个包装函数提供了五个参数:
exports
:导出对象的引用require
:导入模块的函数module
:当前模块对象__filename
:当前模块的文件名(绝对路径)__dirname
:当前模块的目录名(绝对路径)
CommonJS 与 ES 模块的区别
CommonJS 和 ES 模块有几个关键区别:
1. 语法差异
// CommonJS
const module = require('./module');
module.exports = { key: 'value' };
// ES 模块
import module from './module';
export default { key: 'value' };
2. 加载时机
- CommonJS:同步加载,运行时加载
- ES 模块:异步加载,编译时加载(静态分析)
3. 导入绑定
- CommonJS:导入的是值的拷贝,模块导出值变化不会影响已导入的值
- ES 模块:导入的是值的引用(实时绑定),模块导出值变化会反映到导入处
// CommonJS 值拷贝示例
// counter.js
let count = 0;
module.exports = {
count,
increment: function() {
count++;
return count;
}
};
// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment(); // 内部 count 变为 1
console.log(counter.count); // 仍然是 0,因为导入的是值的拷贝
// ES 模块实时绑定示例
// counter.js
export let count = 0;
export function increment() {
count++;
return count;
}
// main.js
import { count, increment } from './counter';
console.log(count); // 0
increment(); // 修改模块内的 count
console.log(count); // 1,因为导入的是引用
4. 模块对象
- CommonJS:
module.exports
可以导出任何值 - ES 模块:
export
只能导出命名绑定,export default
可以导出任何值
5. 顶级作用域
- CommonJS:顶级
this
指向module.exports
- ES 模块:顶级
this
是undefined
6. 循环依赖处理
- CommonJS:返回未完成的模块导出
- ES 模块:通过实时绑定处理循环依赖
在 Node.js 中混合使用 CommonJS 和 ES 模块
Node.js 允许在同一个项目中混合使用 CommonJS 和 ES 模块,但需要注意一些规则:
从 CommonJS 导入 ES 模块
CommonJS 模块可以使用动态 import()
导入 ES 模块:
// CommonJS 模块
async function loadESModule() {
const esModule = await import('./esmodule.mjs');
console.log(esModule.default);
console.log(esModule.namedExport);
}
loadESModule();
从 ES 模块导入 CommonJS 模块
ES 模块可以直接导入 CommonJS 模块:
// ES 模块
import commonjs from './commonjs-module.cjs';
// CommonJS 的 module.exports 会被视为默认导出
// 也可以使用命名导入,但只能导入 CommonJS 模块导出的属性
import { property } from './commonjs-module.cjs';
文件扩展名约定
Node.js 使用文件扩展名来区分模块类型:
.cjs
:强制作为 CommonJS 模块处理.mjs
:强制作为 ES 模块处理.js
:根据最近的package.json
中的"type"
字段决定(默认为 CommonJS)
// package.json
{
"type": "module" // 将 .js 文件视为 ES 模块
}
最佳实践
使用 CommonJS 模块时的一些最佳实践:
- 明确导出:使用
module.exports
而不是exports
进行导出,避免混淆 - 避免修改导入的模块:不要修改从其他模块导入的对象,这可能导致意外行为
- 处理循环依赖:尽量避免循环依赖,必要时使用延迟加载或重构代码
- 使用解构赋值:使用解构赋值简化导入语法
const { readFile, writeFile } = require('fs');
- 考虑迁移到 ES 模块:对于新项目,考虑使用 ES 模块,它是 JavaScript 的官方标准模块系统
- 使用路径别名:在大型项目中,考虑使用路径别名简化导入路径
// 使用 module-alias 包 require('module-alias/register'); const utils = require('@utils/string-utils');
- 按需导入:只导入需要的模块部分,减少内存使用
// 而不是导入整个 lodash const _ = require('lodash'); // 只导入需要的函数 const map = require('lodash/map'); const filter = require('lodash/filter');
常见问题和解决方案
1. 模块未找到错误
当 require
无法找到模块时,会抛出 MODULE_NOT_FOUND
错误:
Error: Cannot find module 'some-module'
解决方案:
- 检查模块名称和路径是否正确
- 确保已安装依赖(
npm install
) - 检查
node_modules
目录是否存在 - 检查
package.json
中的依赖列表
2. 循环依赖问题
循环依赖可能导致未初始化的对象或意外行为。
解决方案:
- 重构代码,消除循环依赖
- 使用事件发射器模式
- 将共享功能提取到第三个模块
- 使用延迟加载(在函数内部 require)
// 延迟加载示例
function needModule() {
// 只在需要时加载模块
const module = require('./module');
return module.doSomething();
}
3. 模块缓存问题
有时需要获取模块的新实例,而不是缓存的实例。
解决方案:
- 清除 require 缓存
- 使用工厂函数返回新实例
- 考虑使用依赖注入
// 清除缓存示例
delete require.cache[require.resolve('./module')];
const freshModule = require('./module');
// 工厂函数示例
// module.js
module.exports = function createInstance() {
return {
// 实例属性和方法
};
};
// 使用
const createInstance = require('./module');
const instance1 = createInstance();
const instance2 = createInstance();
Node.js 内置模块
Node.js 提供了许多内置模块,可以通过 CommonJS 导入:
// 文件系统操作
const fs = require('fs');
// 路径操作
const path = require('path');
// HTTP 服务器
const http = require('http');
// 事件发射器
const EventEmitter = require('events');
// 流操作
const { Readable, Writable } = require('stream');
// 加密功能
const crypto = require('crypto');
// 子进程
const { spawn, exec } = require('child_process');
// URL 解析
const url = require('url');
创建和发布 CommonJS 包
创建可重用的 CommonJS 包并发布到 npm:
初始化包:
mkdir my-package cd my-package npm init
创建入口文件:
// index.js module.exports = { // 包的公共 API };
指定入口点:在
package.json
中设置main
字段:{ "name": "my-package", "version": "1.0.0", "main": "index.js" }
发布到 npm:
npm login npm publish
总结
CommonJS 是 Node.js 的默认模块系统,提供了一种组织和重用 JavaScript 代码的方式。它的主要特点包括同步加载、模块缓存、值拷贝和模块级作用域。
通过 module.exports
和 require
函数,CommonJS 实现了模块的导出和导入。虽然 ES 模块正在成为 JavaScript 的标准模块系统,但 CommonJS 在 Node.js 生态系统中仍然广泛使用,并将继续支持。
理解 CommonJS 的工作原理、加载机制和最佳实践,对于开发高质量的 Node.js 应用程序至关重要。随着 JavaScript 生态系统的发展,掌握 CommonJS 和 ES 模块之间的区别和互操作性也变得越来越重要。