☰
Node.js自定义模块从入门到实践:require、CommonJS与ESM全面解析
2026/9/26 20:57:01 网站建设 项目流程

如果你刚开始写 Node.js,大概率会在“模块化编程”这里卡一下,尤其是自定义模块——怎么导出、怎么引入、为什么有的写法能用有的不能,网上教程多数只丢给你一句module.exports = {},从来不解释背后那套模块系统到底在干什么。这篇文章我会把 Node.js 的模块机制从头到尾捋一遍:从一个最简单的自定义模块开始,逐步拆到require的查找规则、模块缓存、循环依赖,再到工程目录怎么组织、模块怎么拆分;最后聊一下现在越来越主流的 ES Module 写法,以及它和旧的 CommonJS 怎么和平共处。内容面向从零开始的新手,也适合写过一阵子但没系统理过模块机制的同学。

说白了,读完之后你会真正搞明白:为什么别人的代码能拆成一个个小文件随便组合,为什么require('./xxx')有时候灵有时候不灵,为什么改了模块文件必须重启进程才生效,以及你的功能模块在生产环境里该以什么结构存在。我踩过不少坑,下面都会提到。

1. 模块化编程到底在解决什么问题

1.1 没有模块化的世界有多乱

先设想一个没有模块化的 Node.js 程序:所有代码堆在一个文件里,或者更糟,像早期浏览器一样通过多个<script>标签引入多个 JS 文件,大家共享同一个全局作用域。

第一个问题是变量命名冲突。你在a.js里定义了let data,结果b.js里也有一个data,后者就把前者覆盖了,程序跑起来全是玄学。第二个问题是依赖顺序不可控,a.js里的函数要用b.js定义的变量,你就必须手动保证b.js先被加载,时间一长,文件名顺序本身成了一种隐形的“配置”,谁都不敢乱动。第三个问题是复用,你想把某个工具函数分享给另一个项目,只能复制粘贴,改一处 bug,所有副本都要跟着改。

Node.js 在设计之初就吸收了 CommonJS 规范的思路,用“文件即模块”的方式从根上解决这些问题:每个文件默认都是独立的模块,自带独立作用域,外部要拿到里面的东西,必须通过显式的导出语句。这个概念特别像你把工具收进抽屉里,别人要用必须先跟你说,而不能直接伸手去摸。

1.2 CommonJS 规范与 Node 的实现

CommonJS 是一个社区驱动的模块规范提案,它的核心约定很朴素:每个文件是模块,模块内部可以用require()引入其他模块,用module.exports对外导出内容。Node.js 从早期版本开始就内置了这套机制,所以你不需要装任何第三方库,直接写就能用。

在 Node 的模块体系里,每个文件执行时都会包一层函数,内部自动提供几个关键变量:module、exports、require、__dirname、__filename。module代表当前模块对象,exports是module.exports的一个引用别名,require是加载函数,__dirname是当前文件所在目录的绝对路径,__filename是当前文件的绝对路径。很多人一开始不理解为什么文件里平白无故有这些“魔法变量”,其实就是这套包装函数的参数。

这也是理解自定义模块的第一把钥匙:你写的不是一段顶层脚本,而是被 Node“包起来”再执行的模块代码。文件之间天然的隔离,加上显式导出,让大型项目有了可维护的骨架。

1.3 为什么要自己定义模块,而不是只用内置模块

Node.js 自带了fs、path、http等一批内置模块,这解决了很多底层能力问题。但业务代码永远不可能只靠内置模块拼出来,任何项目里都有自己的配置、自己的工具函数、自己的业务流程。把这些东西放进自定义模块,收益是立刻能感受到的:

  • 责任边界清晰:一个文件只干一类事,出了问题先查对应模块。
  • 可复用:多个入口文件可以共享同一个自定义模块,改动一处,全项目生效。
  • 可测试:独立模块可以单独写测试,不用把整个应用启动起来。
  • 避免全局污染:模块内定义的变量不会泄漏到其他文件,也就不用为命名冲突提心吊胆。

这个道理听起来简单,但很多新手第一周写代码还是习惯把什么都塞进index.js。等到文件长到两三千行,维护成本陡增,才会意识到模块化不是“规范要求”,而是真正为了解放自己。

2. 从零实现第一个自定义模块

2.1 最小样例:一个计算器模块

先来一个最直观的例子。新建math.js:

// math.js function add(x, y) { return x + y; } function multiply(x, y) { return x * y; } module.exports = { add, multiply, };

再新建index.js作为入口:

// index.js const math = require('./math'); console.log(math.add(2, 3)); // 5 console.log(math.multiply(2, 3)); // 6

这是自定义模块最基础也最常见的形态:定义函数,收集到module.exports里,然后被require。require('./math')返回的就是module.exports指向的那个对象。注意路径里的./不能省,它告诉 Node“去当前目录找文件”,如果只写require('math'),Node 会把它当成内置模块或第三方包去找,结果大概率是MODULE_NOT_FOUND。

这里有一个新手常犯的错误:在math.js里写module.exports.add = add;然后又在下面写module.exports = { add }。混用没问题,但如果某次不小心写了module.exports = somethingElse之后又给exports.add赋值,原有导出就会消失。这块机制下面单独展开。

2.2 exports 和 module.exports 到底有什么区别

先看两段能正常工作的代码:

// 写法 A exports.add = function (x, y) { return x + y; }; // 写法 B module.exports = { add: function (x, y) { return x + y; }, };

两种写法结果看起来一样,但内部机制不同。模块加载时,Node 会初始化exports = module.exports = {},也就是exports一开始指向module.exports同一个对象。你写的exports.add = ...,本质是在那个对象上挂属性,require拿到的是module.exports,自然能看到add。

但如果像下面这样写:

exports = { add: function (x, y) { return x + y; }, };

问题就来了:你让exports重新指向了一个新对象,但module.exports仍然指向原来的空对象,require最后返回的是module.exports,所以拿到的是一个空对象。这大概是我见过最多的自定义模块翻车现场。

一句话记忆:require只认module.exports,exports只是它的小名,你不能把小名“剥夺”后指望大名跟着变。想整体替换导出对象,必须直接操作module.exports;想挂多个属性和方法,用exports.xxx = ...最简洁。

注意:在模块的最后,千万要检查一下有没有出现exports = ...这样的赋值,这是最隐蔽的空对象来源。

2.3 实践项目:做一个用户资料校验与格式化模块

单个计算器太寡淡,我带你做一个有点业务感的模块组合:用户资料校验与格式化工具。项目结构如下:

project/ ├── index.js └── lib/ ├── validator.js ├── formatter.js └── index.js

lib/validator.js负责校验:

// lib/validator.js function isNotEmpty(str) { return typeof str === 'string' && str.trim().length > 0; } function isValidEmail(email) { if (typeof email !== 'string') return false; return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email); } function isPhoneNumber(phone) { if (typeof phone !== 'string') return false; return /^1[3-9]\d{9}$/.test(phone); } module.exports = { isNotEmpty, isValidEmail, isPhoneNumber, };

lib/formatter.js负责格式化:

// lib/formatter.js function capitalizeName(name) { if (typeof name !== 'string') return ''; return name.trim().replace(/\b\w/g, (ch) => ch.toUpperCase()); } function maskPhone(phone) { if (typeof phone !== 'string') return ''; return phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); } module.exports = { capitalizeName, maskPhone, };

lib/index.js汇总导出,让外部只面对一个入口:

// lib/index.js const validator = require('./validator'); const formatter = require('./formatter'); module.exports = { ...validator, ...formatter, };

index.js使用:

// index.js const utils = require('./lib'); const rawName = ' alice '; const rawPhone = '13812345678'; if (utils.isNotEmpty(rawName) && utils.isPhoneNumber(rawPhone)) { console.log(utils.capitalizeName(rawName)); console.log(utils.maskPhone(rawPhone)); } else { console.log('资料不合法'); }

这个例子的关键是require('./lib')能直接加载目录:Node 会先找lib/package.json的main字段或者直接找lib/index.js。我们把对外接口集中在index.js,外部代码不用关心内部到底有几个文件。以后加一个id-validator.js,只需要改lib/index.js,所有调用方都不用动。

实操心得:汇总导出时用展开运算符...validator很方便,但如果你担心属性名冲突,更稳妥的做法是分组导出,比如module.exports = { validator, formatter },调用方变成utils.validator.isNotEmpty(...)。分组更清晰,展开更省事,自己权衡。

3. require 是怎么找到你的模块的

很多人在自定义模块上摔倒,不是不会写导出,而是搞不清require的路径解析。你写了require('./lib')它会走目录查找,你写了require('axios')它会去node_modules翻箱子,这套规则值得系统性理解一遍。

3.1 模块查找顺序

require拿到一个参数后,按下面的顺序判断:

写法查找方式示例
绝对路径直接找指定路径require('/usr/lib/my-module')
相对路径基于当前文件目录解析require('./lib/validator')
名字前无路径先查内置模块,再查node_modulesrequire('fs')、require('lodash')
目录形式找目录下的package.jsonmain 或index.jsrequire('./lib')

具体说,当你写require('my-lib')时,Node 先在核心模块里找有没有叫my-lib的,没有的话,会从当前文件的node_modules目录开始,逐级往上级目录找。举个例子,/home/user/project/src/app.js里require('x'),Node 会依次寻找:

/home/user/project/src/node_modules/x /home/user/project/node_modules/x /home/user/node_modules/x /home/node_modules/x /node_modules/x

找到第一个存在的就停,找不到就抛Cannot find module 'x'。这就是为什么即便你的代码写在任意目录层级,npm 包通常也只要装一次,因为整个目录往上都能被检索到。

注意:node_modules查找规则也会带来坑——项目里如果有多层嵌套依赖,同一个包可能出现多个副本,而两个副本如果是不同类型,比如一个是类一个是函数,可能出现a instanceof b为false的诡异问题。遇到这种问题先怀疑双副本。

3.2 文件路径与扩展名自动补全

写require('./math')时,Node 会尝试自动补扩展名,顺序是.js、.json、.node。如果三个都不存在,才报错。所以你可以省略扩展名,但如果你有一个math.js和一个math.json,省略扩展名时永远是.js先被加载。

这里有个性能细节:自动补全需要做文件系统判断,虽然现代机器上很快,但在大量模块启动时还是会有一点开销。有人会刻意写上完整路径带扩展名,省掉一次探测,不过这种优化属于锦上添花,可读性更重要。

.json模块值得一提:require('./config.json')会自动把 JSON 文件解析成 JavaScript 对象,不需要自己用fs.readFileSync再JSON.parse。这是 Node 内置的便利能力,常用于放不参与业务逻辑的静态配置。

3.3 module.paths 与目录解析

你可以自己把模块的完整查找路径打印出来。在一个项目里加一句:

console.log(module.paths);

得到的是类似下面这样的数组:

[ '/home/user/project/src/node_modules', '/home/user/project/node_modules', '/home/user/node_modules', '/home/node_modules', '/node_modules' ]

这就是上一节提到的逐级向上查找路径。如果你想快速确认某个模块到底加载了哪个物理文件,可以用require.resolve:

const path = require('path'); console.log(require.resolve('lodash')); // /home/user/project/node_modules/lodash/lodash.js

require.resolve不会真正执行模块,只返回解析后的绝对路径,调试“为什么加载的不是我想的那个版本”时特别管用。

3.4 require 的缓存机制

这里必须说一个高频问题:我已经改了自己的自定义模块文件,为什么require到的还是旧代码?

原因是 Node 模块系统有缓存。每个模块在第一次被require时会被执行,然后缓存在require.cache里,键是解析后的绝对路径。之后无论你require多少次,Node 都直接从缓存里取,不会重新执行文件。这是为了性能设计的,但对开发来说很烦,因为你改完lib/validator.js,如果不重启进程,改动根本不会生效。

如果确实需要强制重新加载,可以这样操作:

const modulePath = require.resolve('./lib/validator'); delete require.cache[modulePath]; const freshModule = require('./lib/validator');

我自己在写 CLI 工具和试验性脚本时经常这样强制刷新,但生产环境千万不要这么干,因为缓存清掉以后,那些已经持有旧对象引用的地方可能会出现状态不一致,比不清理更麻烦。

4. 突破难点的三个必修课:循环依赖、模块设计、工程化组织

4.1 循环依赖是怎么发生的,怎么规避

循环依赖就是 A 模块引用 B 模块,B 模块又(直接或间接)引用 A 模块。听起来很容易避开,但在中大型项目里模块一多,不经意间就会出现。先看一个简化例子。

a.js:

// a.js const b = require('./b'); exports.name = 'a'; exports.say = function () { return b.name; };

b.js:

// b.js const a = require('./a'); exports.name = 'b'; exports.say = function () { return a.name; };

运行入口index.js:

// index.js const a = require('./a'); const b = require('./b'); console.log(a.say()); // b console.log(b.say()); // 报错:Cannot read properties of undefined (reading 'name')

b.say()出错,是因为b.js在加载时执行了const a = require('./a'),而那时候a.js只走到exports.name = 'a'这一步,exports.say还没有被赋值。于是b.js拿到的a是一个“半成品”,只有name没有say。等到a.js完整加载完,b.js里的const a已经持有旧引用了。

规避办法很简单,不要在任何模块的顶层「立刻使用」所依赖模块的导出。延迟到函数体里再取:

b.js改成:

// b.js exports.name = 'b'; exports.say = function () { return require('./a').name; };

把require('./a')写进函数内部,执行b.say()时a.js肯定已经完全加载了,循环依赖就不成问题。这也是我在设计模块时的一条铁律:顶层代码尽量只做定义和导出,不执行依赖逻辑。

实操心得:如果你发现两个业务模块互相引用,第一反应不应该是“用延迟 require 绕过去”,而是先怀疑设计是否有问题。循环依赖往往是模块边界没划清楚,抽出一个公共底层模块,往往能让依赖关系重新变成单向的。

4.2 模块拆分的粒度怎么把握

拆模块太粗,等于没拆;拆太细,文件多到把自己绊倒。我自己判断是否该拆出一个新模块,主要看三条:

  • 是否被多个地方复用:一个函数如果只在一个文件的内部被使用,就先留在原文件,不要为“规范”而拆。
  • 是否有一类独立职责:校验是一类,格式化是一类,网络请求是一类,按职责区分边界最自然。
  • 是否独立变化:如果一部分代码经常单独改动,那拆出来能降低误伤概率,也更好针对性地写测试。

有一种拆分是很没必要的:把每个函数都塞进单独文件,然后index.js里module.exports引用全家桶。文件数量上来了,可读性反而下去了。模块拆分的目的是控制复杂度,不是制造工作量。

4.3 目录组织与 index.js 汇总导出

一个多人协作的项目里,自定义模块目录往往是这样组织的:

src/ ├── modules/ │ ├── user/ │ │ ├── validator.js │ │ ├── formatter.js │ │ ├── service.js │ │ └── index.js │ └── order/ │ ├── calculator.js │ ├── validator.js │ └── index.js └── utils/ ├── logger.js ├── http.js └── index.js

每层目录都放一个index.js做聚合导出,外部只跟目录入口打交道。这样做的实际好处是:底层文件可以大胆重命名、拆分、合并,只要index.js对外暴露的接口不变,上层业务代码一行都不用改。

4.4 package.json 的 main 与 exports 字段

如果你的自定义模块要发布成 npm 包,光有文件还不够,package.json里的main字段会告诉 Node 这个包的默认入口是哪个文件:

{ "name": "my-utils", "version": "1.0.0", "main": "lib/index.js" }

这样别人require('my-utils')时,Node 会直接加载lib/index.js。更高阶的是exports字段,它不仅能指定入口,还能精准控制包对外的子路径:

{ "name": "my-utils", "exports": { ".": "./lib/index.js", "./validator": "./lib/validator.js", "./package.json": "./package.json" } }

用了exports字段之后,require('my-utils/validator')可以加载到指定文件,而require('my-utils/internal')如果没在exports里声明,就会被明确拒绝。这是比main更严格的“对外边界控制”,值得在现代 npm 包里推广。

5. 常见问题与排查技巧实录

自定义模块看着简单,但真跑起来问题不少。我把这些年遇到的高频问题整理成一张速查表,每次排查先对号入座。

5.1 问题速查表

现象原因解决办法
require('./xxx')拿到空对象在文件里写了exports = ...改用exports.xxx挂属性,或直接module.exports = ...
报错Cannot find module './xxx'相对路径写错,或文件名拼错、扩展名不存在用node -e "console.log(require.resolve('./xxx'))"查看实际解析路径
改了自己的模块代码,运行还是旧结果模块缓存未失效开发时重启进程,或临时delete require.cache[require.resolve(...)]
循环依赖时拿到undefined加载顺序导致导出不完整把依赖放到函数体内延迟 require,或重构模块边界
同一个对象a instanceof b返回false两个不同路径下装了同一份库的两个副本用npm dedupe合并依赖,检查package-lock.json
模块加载时报错但堆栈看不懂模块内部异常没被分类在可疑模块里加try/catch,或直接断点调试

5.2 排查思路与调试技巧

第一板斧永远是console.log,但要看对象内容时记得用console.dir(obj, { depth: null }),否则嵌套多层的对象会被折叠成[Object],什么都看不到。

第二板斧是确认解析路径。模块相关的问题里,“到底加载了哪个文件”是最重要的信息之一。require.resolve能在不执行模块的前提下告诉你在哪儿,如果这个命令输出的路径和你想象的不一致,那问题多半在查找路径上。

第三板斧是断点。现在 Node 对调试的支持已经很成熟:

node --inspect-brk index.js

然后在浏览器里打开chrome://inspect,或者更简单,直接在 IDE 里启动调试模式。你在代码里打断点,单步进入require('./lib'),看module.exports在每一步到底发生了什么,比靠猜快得多。

5.3 开发期热加载小脚本

开发时不想老手动重启?可以用一个十几行的小脚本来监听自定义模块目录,发现变化就清缓存:

const fs = require('fs'); const path = require('path'); function clearModuleCache(dir) { const absoluteDir = path.resolve(dir); for (const key of Object.keys(require.cache)) { if (key.startsWith(absoluteDir)) { delete require.cache[key]; } } } fs.watch(path.resolve(__dirname, 'lib'), { recursive: true }, () => { clearModuleCache(path.resolve(__dirname, 'lib')); });

这个脚本适合本地调试,不适合生产。真的要在生产环境做热更新,应该用成熟的进程管理器方案,让新代码以新进程加载,而不是在现进程里乱删缓存。

注意:fs.watch在不同平台上的表现略有差异,macOS 上有时一个文件保存会触发多次事件,所以真实项目里最好配合防抖逻辑。这里只是为了展示思路,别直接扔到生产项目里。

6. 走向现代:ES Module 与自定义模块的另一种写法

从 Node.js 的 12 版本开始,ES Module(以下简称 ESM)逐渐进入稳定阶段。到今天,用import/export写自定义模块已经非常常见。换了一副语法,但模块化的核心目标没变:隔离、复用、组织。

6.1 ESM 语法基础

先看最直观的对比。CommonJS 的写法是:

// cjs-utils.js const double = (x) => x * 2; module.exports = { double };

ESM 的写法是:

// esm-utils.mjs export function double(x) { return x * 2; } export const triple = (x) => x * 3;

使用方:

import { double, triple } from './esm-utils.mjs'; console.log(double(4));

ESM 还支持默认导出:

// esm-utils.mjs export default function double(x) { return x * 2; }
import double from './esm-utils.mjs';

默认导出就是模块最核心的那一个东西,适合一个模块只做一件事的场景。

6.2 type: module 与文件后缀

在package.json里设置"type": "module",会让这个包里的.js文件全部按 ESM 解析;反过来如果你想在 ESM 包中保留一个 CommonJS 模块,就把那个文件命名成.cjs。同样地,在一个默认 CommonJS 的包里,想让某个.js文件按 ESM 跑,就命名成.mjs。规则可以记成一句话:后缀名.cjs和.mjs是最高优先级,type字段决定.js默认归属。

实际项目里,我建议不要依赖“靠感觉”,而是明确统一:要么整个包都用 ESM,要么整个包都用 CJS。混着写虽然 Node 支持,但团队心智负担会变大,尤其是新手容易混淆。

6.3 CJS 与 ESM 互相调用

ESM 可以很方便地加载 CommonJS 模块:

import { double } from './cjs-utils.js';

Node 会把module.exports整体作为默认导出,所以上面的具名导入也能解析。但 CommonJS 反过来require一个 ESM 模块就麻烦一些,因为 ESM 模块必须等顶层异步执行完才能拿到完整模块记录。Node 的官方建议是:如果确实需要在 CommonJS 里加载 ESM,可以使用动态import():

// cjs 文件里 async function init() { const mod = await import('./esm-utils.mjs'); console.log(mod.double(4)); } init();

这段代码是异步的,所以会牵动调用方的结构。如果你维护一个被广泛使用的包,最好避免让 CommonJS 调用者被迫接受异步。

6.4 双格式包实战

现在很多成熟的 npm 包同时支持 CommonJS 和 ESM,核心思路是用package.json的exports字段给不同加载方式分配不同入口:

{ "name": "my-dual-utils", "type": "module", "exports": { ".": { "import": "./src/index.mjs", "require": "./src/index.cjs" } } }

当外部代码用import时,Node 走import对应的入口;用require时,走require对应的入口。两个入口文件各自组装自己的导出,底层业务逻辑可以通过共享模块复用一份,这样既照顾了老用户的 CommonJS 习惯,也给新项目留了 ESM 入口。

不过双格式包维护成本是翻倍的,内部必须注意不能依赖某些仅有 ESM 才有的顶层特性。我自己写内部包时不会一上来就双格式,只有当包真的被很多不同技术栈的同事依赖了,才考虑这种布局。

6.5 我如何选择

如果是全新的纯 Node 项目,我倾向直接用 ESM,语法更现代,浏览器端也能复用同样的模块思维。如果是给现有 CommonJS 项目加新模块,那就继续用 CJS,别为了“新”而强行改造。至于学习顺序,建议先把 CommonJS 的机制彻底搞懂,因为大量存量代码和依赖库还在用它,遇到问题你又要回来补课。

过渡期里最常见的报错是:在"type": "module"的包里写了一段module.exports,结果直接报module is not defined in ES module scope。看到这句英文别慌,无非是解析方式和你写的内容不匹配,要么去掉"type": "module",要么改文件后缀为.cjs。

最后再分享一个小建议:每次写完一个自定义模块,我都会顺手写三行自测代码,直接在模块文件底部做if (require.main === module)判断,这样既能快速验证逻辑,又不会在被人正式引用时污染产线输出。比如:

if (require.main === module) { console.log(isValidEmail('test@example.com')); // true console.log(maskPhone('13812345678')); // 138****5678 }

这个习惯花不了多少时间,但能让你在调试一个又一个自定义模块时少走很多弯路。模块化编程说到底就是一种把复杂问题拆小、再把小零件标准化组合的思维方式。把 CommonJS 和 ESM 两套机制都理顺,以后看任何 Node.js 项目的源码都会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询