Skip to content

Node系列 · Node基础:模块化(CommonJS)

CommonJS 不是 Node 发明的,但 Node 让它真正落地。理解"模块如何被找到"和"模块缓存了什么",就能解释循环引用为什么不死、为什么改了文件不生效、为什么 exports = ... 总是丢赋值。

一、模块化的两个核心问题

任何一个模块系统都要回答两个问题:

  1. 怎么找——给定一个标识符(require('./foo')require('express')),对应的文件在哪
  2. 怎么隔离——每个模块要有自己的作用域,不能直接污染全局

CommonJS 的回答:

  • 怎么找require 解析算法(路径 / 内置 / node_modules / 后缀补全 / 文件名补全)
  • 怎么隔离:每个模块在执行前被包进一个函数,参数是 exports / require / module / __filename / __dirname,执行环境与外部隔离

INFO

本章只覆盖 CommonJS——Node 默认行为。Node 也支持 ECMAScript Module(ESM),两者在加载方式、关键字、tree-shaking 上有明显差异,文末有对比表。

二、require 解析算法

require(X) 时,X 会被依次按以下规则解析,直到命中为止:

2.1 优先级一览

2.2 规则详解

规则 1:核心模块(built-in)

javascript
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

require 直接返回内置模块实现,不走文件系统。即使你 npm install fs 也不会生效——核心模块永远优先。

规则 2:路径形式

javascript
require('./foo');     // 相对当前文件
require('../foo');    // 相对父目录
require('/abs/foo');  // 绝对路径(不推荐,跨平台会出问题)

按以下顺序尝试:

  1. 精确匹配 X
  2. 依次补全后缀:X.jsX.jsonX.nodeX.mjs
  3. 若 X 是目录:尝试 X/package.jsonmain 字段;找不到则尝试 X/index.js
javascript
require('./foo');      // → 尝试 ./foo, ./foo.js, ./foo.json, ./foo.node, ./foo.mjs
require('./foo.json'); // → 直接命中
require('./bar');      // → ./bar 是目录?尝试 ./bar/package.json/main,否则 ./bar/index.js

TIP

后缀补全是 CommonJS 才有的便利。在 ESM 下 import './foo' 必须写完整路径,否则报错。这就是为什么早期 Node 项目里到处是 .js,ESM 项目里到处是显式后缀。

规则 3:模块名形式require('express')

按目录层级向上查找 node_modules

/Users/you/project/src/routes/user.js
↑ 1. /Users/you/project/src/routes/node_modules
↑ 2. /Users/you/project/src/node_modules
↑ 3. /Users/you/project/node_modules
↑ 4. /Users/you/node_modules
↑ 5. /Users/node_modules
↑ 6. /node_modules

命中后:

  1. 读包目录的 package.json
  2. main 字段;未指定则默认 index.js
  3. 同样按后缀补全规则解析 main
json
{
  "name": "express",
  "main": "lib/express.js"  // 入口
}

2.3 一段代码演示整个解析过程

项目结构:

text
/app/
├── index.js
├── lib/util.js
└── node_modules/lodash/index.js

index.js:

javascript
require('fs');              // → 核心模块
require('./lib/util');      // → /app/lib/util.js
require('lodash');          // → /app/node_modules/lodash/index.js
require('lodash/index');    // → 同上(不会重复加载,缓存命中)

2.4 模块缓存

模块一旦加载,结果会缓存到 require.cache。同一个标识符第二次 require 直接返回缓存的 module.exports。项目结构:

text
demo/
├── a.js
└── b.js

a.js:

javascript
console.log('a.js 执行');
module.exports = { name: 'A' };

b.js 两次 require

javascript
const a1 = require('./a');
const a2 = require('./a');
console.log(a1 === a2); // true
bash
$ node b.js
a.js 执行
true

实际意义:

  • 模块代码只执行一次——副作用(如 console.log、全局状态修改)只发生一次
  • 同一进程内多次 require 拿到的是同一个对象引用
  • 想重新加载?删 require.cache 中对应键(很少见,但热加载工具会用到)

WARNING

缓存按绝对路径作 key。require('./foo')require('/abs/path/foo') 解析到同一文件时,命中同一个缓存条目——不会出现"两份 foo 实例"。

三、require 函数

require 本质是一个函数,常见用法:

javascript
// 1. 加载模块
const express = require('express');

// 2. 加载并解构
const { join } = require('path');

// 3. 条件加载(避免没装某个包就崩)
let optional;
try {
  optional = require('optional-dep');
} catch (e) {
  optional = null;
}

// 4. resolve 只解析路径不执行
const configPath = require.resolve('./config.json');
console.log(configPath);

require.resolve(X) 走和 require(X) 完全一样的解析算法,但只返回路径,不执行模块——也就是说它不会触发模块顶层 console.log、副作用和缓存写入。常用于不改代码确认模块路径,或与 require.cache 配合做热加载。

四、module 对象

每个模块内部都有一个 module 变量,它的常用属性:

属性类型含义
module.idstring模块标识,默认等于文件名
module.filenamestring绝对路径
module.loadedboolean是否已加载完成
module.exportsany模块对外的导出对象
module.childrenarray当前模块 require 进来的子模块列表
module.parentobjectrequire 了当前模块(已弃用)
module.pathsarrayNode 搜索 node_modules 的候选路径
javascript
console.log(module);

输出(简化):

Module {
  id: '.',
  filename: '/app/index.js',
  loaded: false,
  children: [],
  parent: null,
  paths: [
    '/app/node_modules',
    '/node_modules'
  ]
}

逐项解释:

  • id: '.'——入口模块(被 node 命令直接执行的那个)的 id 固定是 .;被 require 引入的模块,id 是它的绝对路径
  • parent: null——同样是入口模块的特征;非入口模块会指向"谁 require 了它"
  • loaded: false——打印发生在模块执行中,所以是 false;模块代码全部跑完后,Node 会把它置为 true
  • paths——Node 向上查找 node_modules 的候选路径列表,从当前目录一直列到根 /node_modules

五、module.exports / this / exports:三者到底是什么关系

XMind 里把它们并列写了,但它们的真实关系是:

javascript
// 每个模块在执行前会被包成这样的函数(伪代码)
(function (exports, require, module, __filename, __dirname) {
  // 你的模块代码
});
  • module 是当前模块对象,由 Node 创建
  • module.exports 是模块导出的最终对象,默认是 {}
  • exportsmodule.exports初始引用exports === module.exports
javascript
console.log(exports === module.exports); // true

exports.a = 1;          // ✅ 挂属性到 module.exports
module.exports.b = 2;   // ✅ 同上

// ❌ 重新赋值会让 exports 不再指向 module.exports
exports = { c: 3 };
console.log(exports === module.exports); // false
console.log(require('./exports-demo')); // { a: 1, b: 2 }  ← c 丢了

5.1 三种导出写法对比

写法示例适用场景
挂属性exports.foo = ...多工具函数聚合导出
整体替换module.exports = function () {}导出单个东西(类、函数、对象字面量)
整体替换对象module.exports = { foo, bar }推荐写法,行为最可预测

WARNING

exports = ... 始终是错的。Node 官方文档明确写:"If you want to export a function, use module.exports, not exports"。代码里看到 exports = ...,结果一定是导出对象里没那个属性。

简而言之:

  • 想要多个具名导出exports.foo = ...module.exports = { foo }
  • 想要单个默认导出module.exports = something
  • 永远不要对 exports 整体赋值

5.2 实际验证

javascript
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }

exports.add = add;
module.exports = { sub };
javascript
const math = require('./math');
console.log(math);                // { sub: [Function: sub] }
console.log(math.add);            // undefined ← 第二次 module.exports 整体替换抹掉了前面挂的属性

5.3 this 在模块里的指向

模块顶层 this 指向 module.exports

javascript
console.log(this === module.exports); // true
console.log(this === exports);         // true

所以 this.foo = 1 等价于 exports.foo = 1。但箭头函数不行——箭头函数没有自己的 this,会沿用外层作用域的 this,此时 this 不再是 module.exports

WARNING

严格模式下顶层 thisundefined,不是 module.exports。Node CJS 模块默认不是严格模式,所以上面 console.log(this === module.exports) 输出 true;但只要在文件顶部加 "use strict"(或 .mjs / ESM 模块),顶层 this 立刻变成 undefined。在严格模式下写 this.foo = 1 会直接抛 TypeError: Cannot set properties of undefined。不要在 CJS 与 ESM 混用的项目里靠 this 做模块导出。

六、循环引用:为什么不会死

A 引入 B,B 又引入 A——不会无限递归,因为模块缓存机制

text
project/
├── a.js
└── b.js
javascript
console.log('a.js start');
exports.done = false;
const b = require('./b');   // 触发加载 b.js
console.log('a.js end, b.done =', b.done);
exports.done = true;
javascript
console.log('b.js start');
exports.done = false;
const a = require('./a');   // 此时 a.js 还没执行完,缓存里是部分值
console.log('b.js end, a.done =', a.done); // false(a 还没执行到最后)
exports.done = true;
bash
$ cd project && node a.js
a.js start
b.js start
b.js end, a.done = false
a.js end, b.done = true

执行轨迹:

关键观察:

  • 循环引用时,先被加载的那个模块会拿到一个"半成品"导出对象
  • 解决办法:把 require 写在函数体内(懒加载),而不是模块顶层
javascript
// ✅ 安全的循环引用写法
function getB() {
  return require('./b'); // 真正使用时才加载
}

七、模块隔离与作用域

每个 JS 文件都可能在项目里"裸跑"——如果不做隔离,文件 A 里的 const user = ... 会和文件 B 里的同名 const user 互相覆盖;文件 A 顶部 var foo = 1 会泄漏成全局变量污染浏览器 windowCommonJS 的隔离手段是把每个模块包成一个函数再执行,函数有自己的作用域:

javascript
// 你的 a.js
const x = 1;

Node 实际执行的是:

javascript
(function (exports, require, module, __filename, __dirname) {
  const x = 1;
});

包装函数的 5 个参数由 Node 注入:

参数用途
exportsmodule.exports 的初始引用(见 §五)
require当前模块的 require 函数
module当前模块对象(见 §四)
__filename当前文件的绝对路径(仅 CJS)
__dirname当前文件所在目录的绝对路径(仅 CJS)

实际效果:

  • 模块顶层 var / const / let 不会污染全局对象
  • 模块内显式写 global.foo = 1 才会污染全局(不要这样做
javascript
foo = 1;                  // ❌ 没有 var/let/const,隐式全局(严格模式下报错)
global.bar = 2;           // ❌ 显式全局(也不推荐)
const baz = 3;            // ✅ 模块局部

八、常见错误与排查

报错原因解决
Cannot find module 'X'包没装 / 路径错npm ls X / require.resolve(X)
X is not a function拿到的是 module.exports 对象但当成函数调用X.default 或修导出方式
Cannot find module './foo',但文件存在后缀名错 / 在 ESM 下不补全写完整 require('./foo.cjs') 或改用 import
改了文件不生效缓存重启进程;或 delete require.cache[require.resolve('./foo')]

"改了文件不生效"的典型场景是开发期调试——你修改了某个模块文件,但 Node 不会重新读取。完整可复制的修复代码:

javascript
// 1. 解析模块路径(带后缀)
const path = require.resolve('./foo.js');

// 2. 从 require 缓存中删除该条目
delete require.cache[path];

// 3. 重新 require,这次会真正执行一次模块代码
const fresh = require('./foo.js');

WARNING

热加载通常意味着代码组织有问题。生产环境不要这么用——模块被多个地方引用时,删缓存只会让"这次 require 拿到新值",之前已经持有旧引用的代码看不到变更,状态会分裂。开发期单文件调试可以临时用,但上线前务必移除。

九、CommonJS vs ESM:何时用哪个

维度CommonJSESM
加载方式同步、运行时异步、静态分析
关键字require / module.exportsimport / export
Tree-shaking困难(运行时才知道导出什么)天然支持
顶层 await不支持支持(Node 14.8+)
适用老项目、Node CLI、配置文件现代前端、库发布、tree-shaking 场景

Node 14+ 已经在原生 ESM 上做了大量优化,新项目默认用 ESM。但 CommonJS 仍是大量老库的发布格式,且 Node 自身内置模块全用 CJS。

十、小结

  • require 解析优先级:核心模块 → 路径形式(带后缀/文件名补全)→ node_modules 向上查找
  • 模块执行结果缓存到 require.cache,同一进程只加载一次
  • module.exports 是最终导出对象;exports 是它的初始引用,不能整体赋值
  • 循环引用安全靠缓存;半成品模块是常见坑
  • 模块代码被包在函数中执行,顶层作用域天然隔离