ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Node.js自定义模块从入门到实践:require、CommonJS与ESM全面解析

Node.js自定义模块从入门到实践:require、CommonJS与ESM全面解析 如果你刚开始写 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.exportsexports只是它的小名你不能把小名“剥夺”后指望大名跟着变。想整体替换导出对象必须直接操作module.exports想挂多个属性和方法用exports.xxx ...最简洁。注意在模块的最后千万要检查一下有没有出现exports ...这样的赋值这是最隐蔽的空对象来源。2.3 实践项目做一个用户资料校验与格式化模块单个计算器太寡淡我带你做一个有点业务感的模块组合用户资料校验与格式化工具。项目结构如下project/ ├── index.js └── lib/ ├── validator.js ├── formatter.js └── index.jslib/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.resolveconst path require(path); console.log(require.resolve(lodash)); // /home/user/project/node_modules/lodash/lodash.jsrequire.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(testexample.com)); // true console.log(maskPhone(13812345678)); // 138****5678 }这个习惯花不了多少时间但能让你在调试一个又一个自定义模块时少走很多弯路。模块化编程说到底就是一种把复杂问题拆小、再把小零件标准化组合的思维方式。把 CommonJS 和 ESM 两套机制都理顺以后看任何 Node.js 项目的源码都会轻松很多。
返回列表