CommonJS 模块
本教程共 76 篇 · 第 6 篇 · 更新于 2026-07-25 · 约 6 分钟阅读
6. CommonJS 模块
本节目标:require 与 module.exports 的机制、模块查找规则和缓存,理解 CommonJS 存量知识。
Node.js 诞生在 2009 年,那时候 JavaScript 还没有原生的模块系统。CommonJS 就是那个年代的解决方案,也是 Node.js 默认使用了十几年的模块规范。
今天写新项目,我推荐你用 ESM(import/export),这是下一章的主角。但 CommonJS 并没有消失——npm 上大量的老包、公司里的遗留项目,甚至 Node.js 内部的很多机制,都还建立在 CommonJS 之上。你可以不主动写它,但必须能读懂它。
一个文件就是一个模块
CommonJS 的核心思想简单粗暴:每个 .js 文件都是一个独立的模块,文件里的变量不会污染到其他文件。
Node.js 在加载你的模块时,会悄悄把它包进一个函数里:
(function(exports, require, module, __filename, __dirname) {
// 你写的代码在这里
});
这意味着你在模块顶部定义的 const、let、var,作用域只限于当前文件。不用担心命名冲突,放心写就好。
require:拿来吧你
CommonJS 用 require() 导入模块。它支持三种来源:
1. 内置模块
const fs = require('fs');
const path = require('path');
直接写名字,Node.js 会从内置模块列表里找。
2. 自己写的文件模块
const utils = require('./utils'); // 相对路径
const config = require('/app/config'); // 绝对路径(很少用)
注意相对路径必须以 ./ 或 ../ 开头,否则 Node.js 会当成第三方模块处理。文件名可以省略 .js、.json、.node 后缀,Node.js 会按这个顺序尝试。
3. 第三方包
const express = require('express');
只写包名时,Node.js 会从当前目录的 node_modules 开始,逐层向上查找。这个算法很经典,后面细说。
Tip从 Node.js v18.20+ / v20+ 开始,内置模块也可以用
require('node:fs')这种带node:前缀的形式。这样写意图更清晰,而且能避免和用户自定义的fs包冲突。
导出:exports 与 module.exports 的坑
CommonJS 提供两个导出对象:exports 和 module.exports。
新手最容易栽的坑,就是以为它们完全等价。看看这段代码:
// ❌ 错误示范
exports = { name: 'Bob' };
这样写外部是拿不到 name 的。因为 exports 只是 module.exports 的一个引用,直接给它赋值会切断联系,相当于你换了个对象,但模块对外暴露的还是原来的 module.exports。
正确的姿势:
// ✅ 给 exports 添加属性,没问题
exports.name = 'Bob';
exports.sayHi = () => console.log('Hi');
// ✅ 直接替换整个导出对象,用 module.exports
module.exports = { name: 'Bob', age: 25 };
// ✅ 导出一个函数或类
module.exports = class User {
constructor(name) {
this.name = name;
}
};
我总结了个口诀:「加属性用 exports,整个换用 module.exports」。记住这个,能避开 90% 的导出 Bug。
模块查找算法:Node.js 怎么找到你的包
当你写 require('lodash') 时,Node.js 内部会做一套相当复杂的查找。理解它,能帮你排查「模块找不到」这种诡异错误。
简化版的流程如下:
- 检查缓存 — 这个模块之前加载过?直接返回缓存,跳过所有步骤。
- 判断类型 —
lodash是内置模块名吗?是就直接加载。 - 解析路径 — 以
/开头按绝对路径找;以./或../开头按相对路径找。 - 查找文件 — 如果
require('./utils'),依次尝试:utils(无后缀,当成文件或目录)utils.jsutils.jsonutils.node(C++ 扩展)utils/index.js(当成目录,找其入口)
- 进入 node_modules — 如果以上都不是,从当前目录的
node_modules/lodash开始找,找不到就往父目录的node_modules爬,一直爬到文件系统根目录。
Warning循环依赖(A require B,B 又 require A)在 CommonJS 里不会报错,但可能导致「拿到不完整对象」。比如你 A 文件执行到一半去加载 B,B 回头加载 A,此时 A 的
module.exports可能还是空对象。这种情况最好重构代码结构,用事件或回调解耦。
模块缓存:加载一次,到处复用
require() 有缓存机制。同一个模块第一次加载后,Node.js 会把它的 module.exports 缓存起来。后续再 require,直接拿缓存,不会重新执行文件。
const a = require('./config');
const b = require('./config');
console.log(a === b); // true,同一个对象
大部分时候这是好事,性能高、状态共享。但偶尔你需要热更新,可以手动清缓存:
delete require.cache[require.resolve('./config')];
const fresh = require('./config'); // 重新加载
Note生产环境别随便清缓存。这玩意是最后手段,开发环境调试用用可以。
实战:写一个简单的工具模块
来,动手写个能跑的例子。创建两个文件:
// math-utils.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// main.js
const { add, multiply } = require('./math-utils');
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
运行:
node main.js
就这么简单。CommonJS 的语法本身没什么门槛,真正的难点在于理解它的加载时机、缓存机制和查找规则。
CommonJS 的局限
CommonJS 是运行时同步加载。require() 执行到那一行才会去磁盘找文件、读取、编译、执行。这在服务端没问题,文件都在本地,速度快。但到了浏览器端,网络请求是异步的,CommonJS 这套同步逻辑就行不通了。
另外,CommonJS 的依赖关系只能在运行阶段确定,工具链没法做静态分析。像 Tree Shaking(摇树优化,把没用到的代码删掉)这种现代打包技术,CommonJS 基本无能为力。
这些局限,正是 ESM 要解决的问题。下一章我们聊 import/export,那才是 v24 时代写 Node.js 的推荐姿势。