Node.js 模块系统
浏览器里分文件靠 <script src> 标签;Node 在电脑上跑,需要更严谨的"模块"机制来组织代码、复用逻辑、隔离作用域。Node 历史上有两套模块系统:传统的 CommonJS 和现代的 ESM。这一章把它们讲透。
1. 为什么需要模块?
如果把所有代码都写在一个文件里,项目大了根本没法维护。模块化的好处:
- 分文件组织:把功能拆到不同文件,各管各的。
- 作用域隔离:每个模块有自己的私有作用域,变量不会污染全局。
- 复用与分享:写一次,到处引用;还能打包发到 npm 给全世界用。
- 依赖管理:明确声明依赖谁,谁依赖我,清晰可追溯。
2. CommonJS:Node 的传统默认
Node 最初(2009 年)用的就是 CommonJS——那时 ESM 标准还没出来。它通过 require 引入、module.exports 导出:
// CommonJS 是 Node 的传统默认模块系统
// 文件名通常用 .js 或 .cjs
// ---- math.js ----(导出)
function add(a, b) {
return a + b;
}
const PI = 3.14;
// 方式 1:整体导出(对象)
module.exports = { add, PI };
// 方式 2:逐个挂载(效果一样)
// exports.add = add;
// exports.PI = PI;
// ---- app.js ----(引入)
const { add, PI } = require("./math");
console.log(add(2, 3)); // 5
console.log(PI); // 3.14关键点:
require("./math")会执行math.js文件,拿到它的module.exports对象。后缀.js可省略。module.exports和exports是同一个引用——刚开始时两者相等。但一旦给module.exports整体赋值,exports就失效了。新手高发 bug:又用exports.xxx又整体赋值,最后导出为空。require是同步的——它会立即执行目标文件并拿到结果。这是 CommonJS 的本质特征,也决定了它不能直接用在浏览器(浏览器只能异步加载)。
3. ESM:现代推荐
ES6(2015)制定了官方的 ESM 标准,用 import / export。它设计上静态化——依赖关系在编译期就能确定,利于 tree-shaking(打包时删掉没用到的代码)。Node 从 13.2 开始正式支持 ESM,今天已是推荐写法:
// ESM(ECMAScript Modules)是现代推荐写法
// 文件名用 .mjs,或在 package.json 设 "type": "module"
// ---- math.mjs ----(导出)
export function add(a, b) {
return a + b;
}
export const PI = 3.14;
// 默认导出(一个文件只能有一个 default)
export default function multiply(a, b) {
return a * b;
}
// ---- app.mjs ----(引入)
import { add, PI } from "./math.mjs";
import multiply from "./math.mjs"; // 默认导出,名字自定义
import * as math from "./math.mjs"; // 全部导入
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20ESM 与 CommonJS 的主要区别:
- 导入路径:ESM 必须写完整路径,
import自定义模块必须带后缀(如./math.mjs);CommonJS 的require可省后缀。 - 顶层 await:ESM 支持在模块顶层直接
await(Node 14.8+);CommonJS 不行。 - this 指向:CommonJS 顶层
this指向module.exports;ESM 顶层this是undefined。 - 严格模式:ESM 默认严格模式;CommonJS 默认不是(可以给未声明变量赋值,但极不推荐)。
4. 三种模块来源
// 引入内置模块:直接写名字,不加路径
const fs = require("fs"); // 文件系统
const path = require("path"); // 路径处理
const http = require("http"); // HTTP 服务
const os = require("os"); // 操作系统信息
const crypto = require("crypto"); // 加密
const url = require("url"); // URL 解析
// 第三方模块:先 npm install,再 require 包名
const express = require("express"); // 需要 npm install express
const lodash = require("lodash"); // 需要 npm install lodash
// 自己的模块:必须加 ./ 或 ../ 前缀
const myTool = require("./utils/tool"); // 同目录
const config = require("../config"); // 上一级目录记住这个区分:内置模块(fs、path、http...)直接写名字;第三方模块要先 npm install 再写名字;自己的模块必须带 ./ 或 ../ 前缀。漏掉前缀是新手常见错误——Node 会去 node_modules 里找一个叫 utils 的包,找不到就报错。
5. package.json 的 type 字段
Node 怎么知道一个 .js 文件该用 CommonJS 还是 ESM?看 package.json 的 type 字段:
// package.json 的 type 字段决定 .js 文件按哪种模块处理
{
"name": "my-app",
"version": "1.0.0",
"type": "module", // 设为 module 则 .js 都按 ESM 处理
"main": "index.js"
}
// 不设 type 或设 "commonjs" 则 .js 按 CommonJS 处理(默认)
// 强制某文件用某系统:改后缀
// .cjs → 强制 CommonJS
// .mjs → 强制 ESM
// .js → 看 package.json 的 type简单结论:
- 新项目用 ESM:在
package.json加"type": "module"。 - 读老代码要懂 CommonJS:大量 npm 包仍是 CJS 写的。
- 两种混用:把 ESM 代码写成
.mjs,把 CJS 代码写成.cjs,Node 按后缀强制识别。
6. 两种系统互相引用
ESM 里可以引入 CommonJS 模块(默认导出当默认导入),但不能用命名导入解构 CJS 的属性:
// 旧的 CJS 包(old-lib.cjs)
module.exports = { foo: 1, bar: 2 };
// ESM 里引入它(app.mjs)
import oldLib from "./old-lib.cjs"; // 默认导入整个对象,正确
console.log(oldLib.foo); // 1
// 但命名导入会报错(CJS 是运行时对象,静态分析不出来)
import { foo } from "./old-lib.cjs"; // 报错反过来,CommonJS 里引入 ESM 要用动态 import()(因为 ESM 是异步加载的):
// ESM 模块(new-lib.mjs)
export const hello = "hi";
// CommonJS 里引入它(app.cjs)——只能用动态 import
async function main() {
const mod = await import("./new-lib.mjs");
console.log(mod.hello); // hi
}
main();7. 循环依赖
当 A 引用了 B,B 又引用了 A,就形成循环依赖。Node 不会报错,但行为很微妙:
// a.js
const b = require("./b");
console.log("a 里看到 b:", b.x); // 此时 b.x 可能是 undefined
module.exports = { x: "from-a" };
// b.js
const a = require("./a");
console.log("b 里看到 a:", a.x); // undefined(a 还没执行完)
module.exports = { x: "from-b" };
// 运行 node a.js 的输出:
// b 里看到 a: undefined
// a 里看到 b: from-b原因:require 是同步执行的,Node 检测到循环时会返回"当前已导出的部分"(可能是个空对象)。最佳实践是从设计上避免循环依赖——把公共逻辑抽到第三个模块,让 A 和 B 都依赖 C,而不是互相依赖。
8. 加载目录
// 加载目录时,Node 会自动找该目录下的 index.js
// 假设有这样的结构:
// utils/
// index.js ← 默认入口
// format.js
// validate.js
// 三种 require 都能命中 utils/index.js:
const utils = require("./utils");
const utils = require("./utils/");
const utils = require("./utils/index");
// 但建议显式写 ./utils/index,清晰明确这个机制让"一个目录对外是一个模块"成为可能,大型项目常用它来组织子系统。也可以在目录下放 package.json 指定 main 字段,效果一样。
小结
Node 的模块系统是组织代码的地基。记住三点:CommonJS 用 require/module.exports,ESM 用 import/export;新项目选 ESM,读老代码懂 CommonJS;内置模块直接写名字,自定义模块带前缀。下一篇我们用内置的 http 模块,几行代码起一个 Web 服务器。
← 上一篇 Node.js 环境安装
下一篇 Node.js HTTP 服务 →