Node系列 · Node基础模块化CommonJSCommonJS 不是 Node 发明的但 Node 让它真正落地。理解模块如何被找到和模块缓存了什么就能解释循环引用为什么不死、为什么改了文件不生效、为什么exports ...总是丢赋值。一、模块化的两个核心问题任何一个模块系统都要回答两个问题怎么找——给定一个标识符require(./foo)、require(express)对应的文件在哪怎么隔离——每个模块要有自己的作用域不能直接污染全局CommonJS 的回答怎么找require解析算法路径 / 内置 / node_modules / 后缀补全 / 文件名补全怎么隔离每个模块在执行前被包进一个函数参数是exports / require / module / __filename / __dirname执行环境与外部隔离::: info本章只覆盖 CommonJS——Node 默认行为。Node 也支持 ECMAScript ModuleESM两者在加载方式、关键字、tree-shaking 上有明显差异文末有对比表。:::二、require解析算法require(X)时X 会被依次按以下规则解析直到命中为止2.1 优先级一览是否是否是否require XX 是核心模块fs / http / path ...直接返回内置模块X 以 ./ 或 ../ 或 / 开头按文件路径解析补全后缀 / package.json按模块名查找从当前目录向上找 node_modules/直到根目录找到 node_modules/里的包?读 package.json 的 main默认 index.js抛 MODULE_NOT_FOUND返回模块导出2.2 规则详解规则 1核心模块built-inconst fs require(fs); const path require(path); const crypto require(crypto);require直接返回内置模块实现不走文件系统。即使你npm install fs也不会生效——核心模块永远优先。规则 2路径形式require(./foo); // 相对当前文件 require(../foo); // 相对父目录 require(/abs/foo); // 绝对路径不推荐跨平台会出问题按以下顺序尝试精确匹配X依次补全后缀X.js、X.json、X.node、X.mjs若 X 是目录尝试X/package.json的main字段找不到则尝试X/index.js等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 项目里到处是.jsESM 项目里到处是显式后缀。:::规则 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命中后读包目录的package.json取main字段未指定则默认index.js同样按后缀补全规则解析main{ name: express, main: lib/express.js // 入口 }2.3 一段代码演示整个解析过程项目结构/app/ ├── index.js ├── lib/util.js └── node_modules/lodash/index.jsindex.jsrequire(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。项目结构demo/ ├── a.js └── b.jsa.jsconsole.log(a.js 执行); module.exports { name: A };b.js 两次requireconst a1 require(./a); const a2 require(./a); console.log(a1 a2); // true$nodeb.js a.js 执行true实际意义模块代码只执行一次——副作用如console.log、全局状态修改只发生一次同一进程内多次require拿到的是同一个对象引用想重新加载删require.cache中对应键很少见但热加载工具会用到::: warning缓存按绝对路径作 key。require(./foo)和require(/abs/path/foo)解析到同一文件时命中同一个缓存条目——不会出现两份 foo 实例。:::三、require函数require本质是一个函数常见用法// 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.parentobject谁require了当前模块已弃用module.pathsarrayNode 搜索 node_modules 的候选路径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 会把它置为truepaths——Node 向上查找node_modules的候选路径列表从当前目录一直列到根/node_modules五、module.exports/this/exports三者到底是什么关系XMind 里把它们并列写了但它们的真实关系是// 每个模块在执行前会被包成这样的函数伪代码 (function (exports, require, module, __filename, __dirname) { // 你的模块代码 });module是当前模块对象由 Node 创建module.exports是模块导出的最终对象默认是{}exports是module.exports的初始引用exports module.exportsconsole.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 }推荐写法行为最可预测::: warningexports ...始终是错的。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 实际验证function add(a, b) { return a b; } function sub(a, b) { return a - b; } exports.add add; module.exports { sub };const math require(./math); console.log(math); // { sub: [Function: sub] } console.log(math.add); // undefined ← 第二次 module.exports 整体替换抹掉了前面挂的属性5.3this在模块里的指向模块顶层this指向module.exportsconsole.log(this module.exports); // true console.log(this exports); // true所以this.foo 1等价于exports.foo 1。但箭头函数不行——箭头函数没有自己的this会沿用外层作用域的this此时this不再是module.exports。::: warning严格模式下顶层this是undefined不是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 引入 BB 又引入 A——不会无限递归因为模块缓存机制。project/ ├── a.js └── b.jsconsole.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;console.log(b.js start); exports.done false; const a require(./a); // 此时 a.js 还没执行完缓存里是部分值 console.log(b.js end, a.done , a.done); // falsea 还没执行到最后 exports.done true;$cdprojectnodea.js a.js start b.js start b.js end, a.donefalsea.js end, b.donetrue执行轨迹b.jsa.jsnode a.jsb.jsa.jsnode a.js缓存里 exports.done falserequire(./a)exports.done falserequire(./b)require(./a) (命中缓存)拿到部分 ab.js 执行完exports.done truea.js 完成关键观察循环引用时先被加载的那个模块会拿到一个半成品导出对象解决办法把require写在函数体内懒加载而不是模块顶层// ✅ 安全的循环引用写法 function getB() { return require(./b); // 真正使用时才加载 }七、模块隔离与作用域每个 JS 文件都可能在项目里裸跑——如果不做隔离文件 A 里的const user ...会和文件 B 里的同名const user互相覆盖文件 A 顶部var foo 1会泄漏成全局变量污染浏览器window。CommonJS 的隔离手段是把每个模块包成一个函数再执行函数有自己的作用域// 你的 a.js const x 1;Node 实际执行的是(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才会污染全局不要这样做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 不会重新读取。完整可复制的修复代码// 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/exportTree-shaking困难运行时才知道导出什么天然支持顶层await不支持支持Node 14.8适用老项目、Node CLI、配置文件现代前端、库发布、tree-shaking 场景Node 14 已经在原生 ESM 上做了大量优化新项目默认用 ESM。但 CommonJS 仍是大量老库的发布格式且 Node 自身内置模块全用 CJS。十、小结require解析优先级核心模块 → 路径形式带后缀/文件名补全→ node_modules 向上查找模块执行结果缓存到require.cache同一进程只加载一次module.exports是最终导出对象exports是它的初始引用不能整体赋值循环引用安全靠缓存半成品模块是常见坑模块代码被包在函数中执行顶层作用域天然隔离