Node.js 模块系统
上一篇 Node.js 第一个程序 里所有代码都写在一个文件里,代码一多就不行了。这篇解决拆分文件的问题,把功能拆成独立模块互相引用,这是 Node.js 新手最容易懵的知识点。你会学到 CommonJS 的导出导入规则,以及新版 ES Modules 和它的区别。
为什么需要模块
浏览器时代用多个 <script> 标签引入 JS,所有变量都挂在全局,两个文件都定义了 name 就互相覆盖,越写越乱,这就是全局污染。
Node.js 用 CommonJS 规范解决这个问题。每个文件是一个独立模块,文件里的变量默认不对外可见,只有明确导出的部分才能被别的文件引用。各模块互不干扰,想用哪个功能就引入哪个。
module.exports 导出
导出对象
// math.js
const add = (a, b) => a + b;
const sub = (a, b) => a - b;
module.exports = { add, sub };
导出函数
// greeting.js
module.exports = function (name) {
return "你好," + name;
};
导出类
// user.js
class User {
constructor(name) {
this.name = name;
}
}
module.exports = User;
导出什么类型都行,对象、函数、类、字符串都可以。文件底部统一写 module.exports 是常见惯例,容易看懂。
exports 别名陷阱
exports 是 module.exports 的快捷别名,两者指向同一个对象,所以 exports.add = add 也能导出。但直接给 exports 赋值是无效的。
// wrong.js
exports = { name: "错误写法" }; // 把 exports 换了个新对象
// index.js
const wrong = require("./wrong");
console.log(wrong); // 输出 {},空的!
原因在于 require 拿到的永远是 module.exports 的值,exports = xxx 只是让 exports 指向新对象,module.exports 还是原封不动的空对象。想整体替换导出内容,只能写 module.exports = xxx。
require 加载规则
require 按路径写法决定去哪里找模块,顺序是核心模块、相对路径、node_modules、逐级向上。
require("fs"); // 1. 核心模块,直接写名字,node 自带
require("./math"); // 2. 相对路径,./ 开头,找同目录文件
require("chalk"); // 3. 包名,去 node_modules 里找
前两类找不到,或者写的是包名,Node.js 就从当前目录的 node_modules 找,找不到就去上一级目录的 node_modules,一路向上直到根目录。本地文件必须写 ./ 或 ../ 开头,直接写 require("math") 会被当成包名去找 node_modules。
require 是同步的,还有缓存
require 同步执行目标文件,文件里的代码全部跑完才返回。同一个模块被多次 require,只执行一次。
// counter.js
console.log("counter.js 被执行了");
module.exports = { count: 0 };
// index.js
require("./counter");
require("./counter");
require("./counter");
// 只输出一次 "counter.js 被执行了"
第一次 require 执行代码并把结果缓存,之后直接返回缓存,所以模块里不依赖外部变化的初始化逻辑只会跑一遍。
循环引用
两个模块互相 require 对方就叫循环引用。CommonJS 遇到这种情况会返回对方尚未加载完的部分模块,拿到的可能是空对象,行为很难预测。实际开发中应尽量避免,把公共逻辑抽到第三个文件即可。
CommonJS 与 ES Modules
Node.js 的模块规范已经升级到 ES Modules(ESM),这是浏览器原生模块语法 import 和 export 的移植。
| 对比项 | CommonJS | ES Modules |
|---|---|---|
| 导入语法 | require |
import |
| 导出语法 | module.exports |
export |
| 加载方式 | 同步 | 异步 |
| 默认文件后缀 | .js(无 type 字段时) |
.mjs |
| __dirname | 可用 | 不可用 |
| 顶层 this | module.exports | undefined |
两种方式的切换
package.json 里加 "type": "module" 后,该目录下的 .js 文件都按 ESM 解析。不想要这个开关,也可以把文件后缀改成 .mjs(ESM)或 .cjs(强制 CommonJS),后缀优先级最高。
{
"name": "esm-demo",
"type": "module"
}
ESM 里没有 __dirname
CommonJS 里 __dirname 表示当前文件所在目录,ESM 里没有这个变量,要用 import.meta.url 自己算。
// ESM 中获取当前文件目录
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
console.log(__dirname);
涉及文件路径操作的代码,是 CJS 和 ESM 切换时最容易报错的地方。
实践
CommonJS 版本
// math.js
const add = (a, b) => a + b;
const sub = (a, b) => a - b;
module.exports = { add, sub };
// index.js
const math = require("./math");
console.log(math.add(3, 5)); // 8
console.log(math.sub(10, 4)); // 6
node index.js
# 8
# 6
ES Modules 版本
新建 esm 目录,放一个 package.json 声明 type。
{
"type": "module"
}
// esm/math.js
export const add = (a, b) => a + b;
export const sub = (a, b) => a - b;
// esm/index.js
import { add, sub } from "./math.js";
console.log(add(3, 5)); // 8
console.log(sub(10, 4)); // 6
node esm/index.js
# 8
# 6
注意 ESM 里相对路径的 ./math.js 后缀不能省,写 ./math 会报模块找不到。
常见坑
exports = xxx不生效,只能module.exports = xxx,前者只是改了别名指向。- 本地文件 require 漏写
./,require("math")会被当成包名去 node_modules 找,找不到就报错。 - 一个项目里 CJS 和 ESM 混用。
.cjs文件里不能import,.mjs文件里不能require,后缀决定解析规则。 - ESM 里直接用
__dirname会报未定义,需要import.meta.url换算。 - 改了被缓存的模块后重启程序没生效,require 有缓存,重跑进程才会重新加载。
模块系统是 Node.js 的基石,下一步学怎么用 npm 安装别人写好的模块,见 Node.js npm 入门。