六维教程

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 别名陷阱

exportsmodule.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),这是浏览器原生模块语法 importexport 的移植。

对比项 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 入门

上一篇
Node.js 第一个程序
下一篇
Node.js npm 入门