首页 / Node.js 教程 / CommonJS 模块

Node.js 教程

CommonJS 模块

本教程共 76 篇 · 第 6 篇 · 更新于 2026-07-25 · 约 6 分钟阅读

Node.jsCommonJSrequire模块module.exports

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) {
  // 你写的代码在这里
});

这意味着你在模块顶部定义的 constletvar,作用域只限于当前文件。不用担心命名冲突,放心写就好。

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 提供两个导出对象:exportsmodule.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 内部会做一套相当复杂的查找。理解它,能帮你排查「模块找不到」这种诡异错误。

简化版的流程如下:

  1. 检查缓存 — 这个模块之前加载过?直接返回缓存,跳过所有步骤。
  2. 判断类型lodash 是内置模块名吗?是就直接加载。
  3. 解析路径 — 以 / 开头按绝对路径找;以 ./../ 开头按相对路径找。
  4. 查找文件 — 如果 require('./utils'),依次尝试:
    • utils(无后缀,当成文件或目录)
    • utils.js
    • utils.json
    • utils.node(C++ 扩展)
    • utils/index.js(当成目录,找其入口)
  5. 进入 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 的推荐姿势。