ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Node.js模块化全解析:从require到import的底层机制与工程实践

Node.js模块化全解析:从require到import的底层机制与工程实践 刚接触Node.js的时候我被 require 和 import 搞懵过很久。同一个项目里有人写const xx require(xx)有人写import xx from xx混着用也能跑但一报错就没有头绪。后来把 CommonJS 和 ESM 这套模块机制从头捋了一遍很多所谓“玄学”问题才算真正通了。这篇东西就把我梳理过的 Node.js 模块化脉络整理出来——从底层加载机制到互操作边界再到工程项目里的拆包设计适合那些已经会 npm install、但还没真正搞懂模块系统为什么这么设计的开发者。1. 从require到import两套模块系统如何在一个运行时里共存要理解 Node.js 的模块化第一件事就是把“为什么会有两套模块系统”这个问题想清楚。这关系到你每次新建项目时面对的第一个选择package.json 里的type: module到底要不要写.js 文件应该用 require 还是 import。1.1 出身决定命运CommonJS 与服务端同步加载Node.js 诞生的时候JavaScript 还没有官方的模块标准。浏览器端的script标签加载脚本全局变量互相污染没有作用域隔离服务端要组织一个稍微大点的项目很快就寸步难行。于是 Node.js 选择了当时社区里的 CommonJS 规范每个文件是一个模块模块内部独立作用域通过require同步加载其他文件用module.exports导出内容。这是 Node.js 在 2009 年左右做出的决策背景是服务端代码本来就来自本地磁盘同步读文件在启动阶段不是什么大问题。于是require就带着同步、阻塞、缓存这几个特点被固定下来。哪怕后来磁盘换成 SSD网络请求成了一等公民require 的底层行为也没变过。直到今天你在.cjs文件里看到的require依然是同样的加载逻辑同步解析路径、同步读取文件、同步执行。1.2 浏览器倒逼出来的 ESM异步与静态分析到了 2015 年ECMAScript 官方终于发布了import / export模块语法也就是 ES Module。它的设计目标主要是为了浏览器网络环境不能同步加载必须异步模块之间不能有副作用顺序依赖要有静态结构方便打包器做 tree shaking。所以 ESM 从骨子里就和 CommonJS 不同加载是异步的依赖关系是静态的模块顶层可以await。Node.js 从 8.5 开始实验性支持 ESM到 12.17 之后逐渐稳定再到 Node 18、20、22 里逐步把 ESM 变成了一等公民。但 CommonJS 生态太大了几十万个 npm 包还在用 require 写两套机制必须共存。于是你就看到了今天 Node.js 的处理方式.mjs强制 ESM.cjs强制 CommonJS.js取决于最近的 package.json 里type字段没有type字段时默认 CommonJS。很多新手在这里就开始晕了。我建议你记住一个最直接的判断入口文件用什么语法取决于 Node.js 如何解释这个文件而不是文件里写了什么关键字。当你看到“require is not defined in ES module”这类报错时先检查启动命令、文件后缀和 package.json 的 type 字段十有八九是解释方式错了。2. require(./foo)背后Node.js 模块加载器的完整工作链如果你写过几年的 Node.js对 require 的用法肯定不陌生。但“能用”和“懂它为什么这么工作”是两回事。这一章我把 require 的完整过程拆开从Module._load到module.exports赋值一条线讲到底。2.1 模块查找从路径解析到 node_modules 逐层上溯当你在代码里写require(lodash)或require(./config)Node.js 的Module._resolveFilename会按一套固定顺序处理如果第一个字符是./或../视为相对路径从当前文件所在目录开始解析。如果直接是一个包名比如lodashNode.js 从当前目录的 node_modules 开始找逐层向上到/node_modules为止。这就是著名的“逐级向上查找”。如果找不到 node_modules 里的包检查是否为核心模块如 fs、path、http 等这些内置模块在编译期就注册好了优先级最高。都可以通过NODE_PATH环境变量添加额外的查找路径不过现在很少用了如果你在代码里看到有人依赖它建议尽早改掉。找到目录之后还有扩展名解析Node.js 会依次尝试.js、.json、.node如果路径指向一个文件夹则查找文件夹下的 package.json 的main字段没有 main 再找index.js。ESM 里 import 的解析规则更严格必须写全扩展名或者依赖 package.json 的exports字段做映射这是后面第五章的事。2.2 module 对象的诞生与 wrapper 函数解析到具体文件后Node.js 会创建新的Module实例把filename作为缓存的 key。真正执行代码前文件内容会被包在一个 wrapper 函数里(function (exports, require, module, __filename, __dirname) { // 你的代码在这里 });这就是为什么 CommonJS 文件里不需要声明require、module、exports就能直接用。也不是什么黑魔法就是 Node.js 在执行前给代码套了一层壳把这五个变量注入进去。所有你写的顶层代码其实都发生在函数内部所以“模块内部的 this 在最外层等于 module.exports”这个现象本质上是 wrapper 函数调用方式的副作用。模块之间是隔离的每个文件都有自己独立的__dirname和__filename这部分被包装后天然形成作用域不存在全局污染的问题——除非你主动挂到 global 对象上这种操作在工程上基本属于“慎用中的慎用”。2.3 缓存机制第二次 require 为什么不是重新执行CommonJS 的Module._cache是一个全局对象key 是绝对路径。第一次 require 完成后模块的执行结果包括赋值完的 module.exports会被缓存。后续再 require 同一个文件直接返回缓存里的 exports不会再重新执行模块代码。这个机制有两个非常实用的推论单例模式天然成立。你多次 require 同一个模块拿到的其实是同一个对象改它的属性所有地方都会感知到。你可以在模块执行完之前把缓存“短路”。实际写代码时有些人利用这个特性做 mock比如require.cache[filename] { exports: fake }替换某个依赖。这不是常规做法但排查问题时你能看到很多库是这么做的。注意缓存 key 是绝对路径。如果你通过符号链接、不同的相对路径引用同一个真实文件缓存依然只有一个因为最终都会解析到同一个绝对路径。但如果你复制了整个项目到另一个目录那就相当于两套缓存互不干扰。3. exports与module.exports导出这件小事究竟有什么门道CJS 模块里导出内容有两种写法exports.foo 1和module.exports { foo: 1 }。很多文章会告诉你“exports 是 module.exports 的引用”但这句正确的废话不结合实际报错场景几乎等于没说。这一章我把所有导出形态的坑填平。3.1 赋值陷阱exports module.exports {} 的真相模块初始化时Node.js 创建module.exports为对象同时让exports指向同一个对象。所以exports.name Tom; // 等价于 module.exports.name Tom module.exports.age 18; // 等价于 exports.age 18但如果你直接exports { name: Tom }事情就不一样了exports 指向了新的对象和 module.exports 的引用关系断开了。最终 Node.js 返回的是 module.exports你这一行赋值什么都没导出去。// bad.js exports { name: Tom }; // require(./bad) 得到 {}所以常见的修正习惯是这么写的// good.js module.exports { name: Tom };需要同时使用导出和引用时最保险的做法是统一只在 module.exports 上操作。exports这个变量更多的是早期代码风格遗留现在写新模块我基本不用它。3.2 导出函数、类、实例三种常见形态的注意点实际写业务代码时我们最常导出三类东西普通函数、类、已创建的实例。对应写法如下// 导出函数 module.exports function (a, b) { return a b; }; // 导出类 class User { constructor(name) { this.name name; } } module.exports User; // 导出实例单例 const config { env: prod }; module.exports config;这里有个容易忽略的点当你导出的是普通对象或函数时是引用传递。也就是说其他模块拿到这个对象后改了属性原始模块里看到的值也会变。这在配置类模块里可能正是你要的效果但在一些本该是纯函数的模块里会成为隐性问题。提示纯逻辑模块建议导出函数而不是对象。比如module.exports { add, subtract }和module.exports.add add的区别不大但你在另一个模块里const { add } require(./math)解构时如果导出对象被改动了可能导致解构失败。函数的导出形态更稳定测试也好写。严格模式下还有一个细节module.exports不能直接和exports混用。比如先exports.a 1再module.exports { b: 2 }最终导出的只有 b。你之前的 a 白写了。这种问题在代码 review 时常看到建议新模块从一开始就决定好导出风格别在文件里一会儿赋值属性一会儿整体替换。4. 循环依赖不一定会崩但你要知道它什么时候会崩循环依赖是面试常客实际项目中也确实会出现。A 模块 require B、B 模块 require A如果没处理对轻则拿到一个空对象重则直接 TypeError。理解 Node.js 对循环依赖的处理方式你才能预判哪些场景安全、哪些场景危险。4.1 一次循环依赖的完整执行轨迹假设你有 a.js 和 b.js// a.js const b require(./b); console.log(a.js done, b , b); module.exports A;// b.js const a require(./a); console.log(b.js done, a , a); module.exports B;执行node a.js你看到的结果是b.js done, a {} a.js done, b B为什么顺序是这样关键在于 Node.js 的缓存机制模块在开始执行前就会把自己加入Module._cache此时 module.exports 还是空对象{}。当 b.js 回过来 require(./a) 时缓存命中但 a.js 还没执行完毕返回的只是那个尚未被赋值的不完整 exports。所以 b.js 里拿到的 a 是{}。等 a.js 继续执行完成后它的 module.exports 才被赋值为 A。换句话说循环依赖遇到“在模块顶层直接取值”的情形拿到的必然是残缺对象。4.2 安全循环与危险循环的分界线再看一个不那么容易崩的例子// api.js const utils require(./utils); function call() { return utils.format(hello); } module.exports { call };// utils.js const api require(./api); function format(msg) { return api.prefix msg; } module.exports { format };如果入口是 api.jsutils.js 里const api require(./api)时api.js 连module.exports都还没赋值因为第一行就 require utils所以 utils.js 里的 api 同样是空对象。但好在 utils.js 没有在模块顶层立即用 api而是把它留到了format函数里等到真正调用时api.js 已经执行完成api.prefix就能正常访问。这就是安全循环的核心别在模块顶层读取循环依赖的导出内容把实际取值推迟到函数调用阶段。反过来说如果你在顶层解构依赖const { helper } require(./someModule);而这个模块和你互相引用且在顶层就使用那结果大概率是undefined甚至抛错。破循环的根本办法其实不复杂拆公共模块。把 api.js 和 utils.js 都依赖的部分抽出来单独放一个模块从源头消灭循环。延迟加载。在函数体内 require而不是顶层 require。依赖注入。通过构造函数、方法参数传入依赖比模块级引用灵活得多也更好测试。我自己的经验是循环依赖一旦出现千万不要用“先这么写等报错再说”的心态拖着。刚开始可能不崩等模块越来越大只要有人把顶层取值早于赋值时机的断言触发这个雷就炸了。尽早把共享逻辑抽出去一劳永逸。5. ESM与CJS互操作的边界import的坑都有哪些现代 Node.js 项目里CJS 和 ESM 混用几乎不可避免。你刚把新项目改成 ESM但 npm 里一堆老包还是 CJS。这一章把两条方向的互操作边界讲清楚。5.1 从 ESM 导入 CJS哪些写法会翻车在.mjs文件里 import 一个 CommonJS 模块大部分情况下是正常工作的。Node.js 对 CJS 做了兼容处理导入的 default 值就是 CJS 的 module.exports。比如// user.cjs module.exports { name: Tom, getAge() { return 18; } };// index.mjs import user from ./user.cjs; console.log(user.name); // Tom但如果你想像 ESM 一样按命名导入Node.js 用的是cjs-module-lexer做静态分析。它能识别出module.exports { name, getAge }这种“对象字面量直接赋值”的形式然后给你导出name和getAge这两个命名导出。所以import { name, getAge } from ./user.cjs也能跑。翻车的情况出现在动态赋值或整体覆盖时// dynamic.cjs const result {}; result.name Tom; module.exports result;// index.mjs import { name } from ./dynamic.cjs; // SyntaxError因为 lexer 无法静态分析出 result 有哪些属性。这时只能用 default 导入再从对象上取值。这是一个很实用的排查思路在 ESM 里import一个 CJS 报“module does not provide an export named xxx”时先去看目标模块是不是动态生成的 exports。5.2 从 CJS 导入 ESMrequire(esm) 的同步限制反过来在.cjs文件里require一个.mjs文件很长时间里都是不行的。直到 Node.js 20.17 和 22.12 开始require(esm)才默认启用不再需要--experimental-require-module。但有一个很硬的限制被加载的 ESM 模块图里不能有顶层await。// async-util.mjs const data await fetchData(); export default data;// main.cjs const util require(./async-util.mjs); // TypeError [ERR_REQUIRE_ASYNC_MODULE]这是因为 require 是同步的而顶层 await 意味着模块加载必然异步。报错信息会明确告诉你这个模块必须用import()异步加载。所以从 CJS 里拉取 ESM 模块最稳妥的方式就是动态 import// main.cjs const util await import(./async-util.mjs);动态import()是异步的与 ESM 的加载方式完全匹配在 CJS 里同样可用。这也是老项目往 ESM 迁移时的过渡方案入口保持 CJS新模块用 ESM跨模块引用时统一走await import()代码能跑迁移风险也小。5.3 ESM 里没有 __dirname一个让你原地宕机的差异刚切换到 ESM 的人第一件头疼的事就是__dirname is not defined。CommonJS 的 wrapper 函数注入了__dirnameESM 模块没有。解决办法是借助import.meta.urlimport { fileURLToPath } from node:url; import { dirname } from node:path; const __dirname dirname(fileURLToPath(import.meta.url));如果你需要在 ESM 里使用 require也可以手动创建一个import { createRequire } from node:module; const require createRequire(import.meta.url); const oldPackage require(./old-package.cjs);这两个技巧基本是每个 ESM 项目的标配。别想着用什么 hack 去全局注入__dirname老老实实用官方 API 就行。6. 工程视角的模块化从语法到拆包设计模块化的语法只是表面真正决定一个项目能不能长期维护下去的是依赖关系的组织方式。这一章不讲语法讲我这些年做项目总结出来的拆包思路。6.1 项目内模块目录按业务域拆而不是按技术层拆很多新手项目喜欢建三个大目录controllers、services、models。刚开始还好项目一膨胀每个目录下都有几十个文件而且经常跨层依赖controller 直接 require 另一个 controllerservice 里也混着业务逻辑和数据库操作剪不断理还乱。我更推荐按业务域feature组织。以一个小型服务为例src/ features/ user/ user.routes.js user.service.js user.model.js user.validator.js order/ order.routes.js order.service.js order.model.js order.validator.js shared/ lib/ utils/ app.js每个业务域内部自成一体跨域的共享逻辑下沉到 shared。这样做的核心收益是当你需要改用户相关功能时只需打开features/user这个目录依赖范围一目了然不会因为全局按技术分层导致“改 user 的 controller 却牵动 order 的 service”。这里有个设计原则我一直在用依赖走向保持单向。上层业务模块可以依赖底层共享模块但共享模块绝不能反向依赖业务模块。一旦在 shared 里出现require(../features/user)就说明某些逻辑放错了位置该下沉的没下沉该上提的没上提。6.2 package.json 的 exports 字段给模块包装一道门如果你写过 npm 包或者负责公司内部公共库package.json 的exports字段是必学的。它可以控制包的哪些子路径能被外部引用相当于给包的入口装了一道门。{ name: my-lib, type: module, main: ./src/index.js, exports: { .: { import: ./src/index.js, require: ./src/index.cjs, default: ./src/index.js }, ./utils: { import: ./src/utils.js, require: ./src/utils.cjs } } }配置exports后外部只能引用你显式暴露的路径。比如想require(my-lib/src/internal)如果没有在 exports 中声明Node.js 会直接报错。这有效防止了用户依赖你未公开的内部实现。对于同时支持 CJS 和 ESM 的包双入口是标准做法import条件指向 ESM 版本require条件指向 CJS 版本打包器也能正确识别。写exports时我有两个经验一是default条件永远放在最后兜底二是开发时一定要跑一下外部消费方的测试因为 exports 配置错误不像 main 字段那样容易兜底一旦写错外部消费者会非常困惑。6.3 什么时候该从单文件拆成独立包很多人一上来就把所有工具函数放在一个大utils.js里等文件超过一千行再拆。我的判断标准是看“依赖密度”和“变更频率”当多个业务域都依赖同一块逻辑且这部分逻辑的变更会影响所有调用方时就该从 utils.js 里抽出来放到 shared 或者独立 npm 包。当你发现一个模块的改动经常导致另一个业务模块的回归时说明它们之间的边界画错了需要重新审视依赖关系。当共享代码需要加版本号管理时就把它提成独立包通过 semver 控制升级节奏而不是让所有调用方被动升级。拆包本身不是目的让依赖关系清晰可查才是。我见过一些团队把每段公共代码都拆成一个 npm 包最后依赖树变得极其恐怖改一个函数要发十几个包。模块化的最优粒度应该以“团队能记住依赖关系”为准别为了组件化而组件化。拿我自己的项目举例多年前我把服务端按 controller/service/model 三层堆了半年后来重构改成按业务域拆分改动反而小得多因为每次需求变更影响面都被局限在单个 feature 内部。模块化这件事语法只是工具真正值钱的是对依赖关系的控制能力。当你有一天写新模块时第一反应不是“这个文件放哪个目录”而是“这个模块应该依赖谁、谁应该依赖它”的时候Node.js 的模块化就算真正入门了。
返回列表