Node.js 模块系统:CJS 与 ESM 详解
文章目录
- 前言
- 一、CommonJS (CJS)
- 二、ESM(ES Module)
- 三、跨模块互相导入(重要坑点)
- 四、有关路径的要求
- 总结
前言
Node.js 两套模块系统:CommonJS(CJS)、ESM,由package.json和文件后缀共同决定。JavaScript 最初没有模块系统,Node.js 诞生后自行实现 CommonJS(require/module.exports),用于服务端,并非语言官方标准。随后,ES6 推出官方标准 ESM(import/export),支持浏览器与 Node,支持静态分析,但 Node 为兼容海量旧生态没有直接替换 CommonJS。
于是 Node 两套模块共存,依靠package.json type和文件后缀.mjs/.cjs区分,由此产生各类导入语法差异。
一、CommonJS (CJS)
要求如下:
- package.json: “type”: “commonjs” 默认值,不写type也是CJS
- 文件后缀:.js -> CJS; .cjs -> 强制 CJS,无视package.json
- 语法:require() / module.exports,不能用import/export
导出(helpers.js):
// 方式1:整体导出对象(最常用)module.exports={func1,func2};// 方式2:逐个挂载exports.func1=func1;exports.func2=func2;导入(main.js):
// ✅ 本地文件,必须 ./ 开头;后缀 .js 可以省略,建议写上const{func1,func2}=require("./helpers.js");// ❌ 错误:不带 ./,node会去node_modules查找npm包const{func1}=require("helpers");require("./helpers")省略后缀也可以,Node 自动补.js/.json。- 只能用
.js后缀,不能命名为.mjs。 - 运行命令:
node main.js
二、ESM(ES Module)
package.json:"type":"module"- 文件后缀:
.mjs→ 强制 ESM,完全忽略 package.json 的 type 配置 - 语法:
import / export,原生不能直接用require()
- 文件后缀:
重点:
.mjs永远 ESM;.cjs永远 CJS;普通.js看package.json的type字段。
导出(helpers.js):
// 方式1:声明时直接导出exportasyncfunctionloadPackage(){}// 方式2:末尾集中导出export{loadPackage};// 默认导出exportdefault{loadPackage};导入(main.mjs):
// ✅本地文件:必须 ./ 开头,**后缀 .js 不能省略!ESM不会自动补后缀**import{loadPackage}from"./helpers.js";// 如果导入的是CJS模块(helpers.js是module.exports),也可以default导入importhelpersfrom"./helpers.js";const{loadPackage}=helpers;// ❌错误1:无 ./,当作npm包import{loadPackage}from"helpers";// ❌错误2:省略后缀,ESM直接报模块找不到import{loadPackage}from"./helpers";当文件是.mjs,就算 package.json 是commonjs,依然执行 ESM 语法。
运行:node generateTestcase.mjs
三、跨模块互相导入(重要坑点)
- ESM (.mjs) 导入 CJS (.js):
允许;CJS 的module.exports对象被 ESM 识别,支持解构导入。
import{loadPackage}from"./helpers.js";// 结构导入// 或者使用CJS的原生require导入import{createRequire}from"module";constrequire=createRequire(import.meta.url);// Enable require in ESMconstpkg=require(pkgPath);- CJS 导入 ESM
CJS 的require()不能直接 require ESM 文件,会报错;只能用动态await import()。
// CJS里面加载ESM模块只能动态importconstesmModule=awaitimport("./some-esm.js");四、有关路径的要求
- ESM 动态导入,await import其路径必须是
file://开头的 URL 字符串指向本地磁盘文件。不接受windows系统的反斜杠。
// ✅ 本地磁盘文件,必须转成file:// URLconsturl=pathToFileURL(absDiskPath).href;constmod=awaitimport(url);constabsFsPath="C:\\Users\\LIly\\file.js";// path.resolve得到,带反斜杠 const url = pathToFileURL(absFsPath).href; // ✅API内部自动处理反斜杠 → file:///C:/... const mod = await import(url);awaitimport("./dir/test.js");// ✅ 只能正斜杠 await import("./dir\\test.js");// ❌ 反斜杠不行,同静态import// Windows反斜杠转换functionnormalizeSlash(p){if(isWindows()){returnp.replace(/\\/g,'/');}returnp;}- ESM 静态导入 import xxx from “xxx”
静态导入不接受 file:// URL,也不接受操作系统磁盘绝对路径(C:\xxx /home/xxx)。不接受Windows的反斜杠。
静态导入只有两类合法输入:
裸模块名(npm 包、node 内置模块):lodash、fs/promises
importxfrom"lodash"相对说明符:./xxx.js、…/xxx.js
importxfrom"./dir\\test.js";// ❌错误!\是字符串转义符号,路径直接错乱importxfrom"./dir/test.js";// ✅只能正斜杠 /- require的动态导入,操作系统原生磁盘路径,完全接纳 Windows 反斜杠
\,唯一坑:给 require 的相对本地文件路径,必须带上./或者../,否则会被识别成npm包。
letpath="./helper.js";constm=require(path);// ✅完全合法if(flag){require("./other.js")}总结
Node.js 中 CJS 与 ESM 双模块系统共存,核心区分逻辑是package.json的type字段 + 文件后缀(.mjs/.cjs):
- CJS:
require/module.exports,type: commonjs(或默认),require对本地文件路径带./,后缀.js可省略。 - ESM:
import/export,type: module(或.mjs后缀强制),import必须写./且后缀不可省略。 - 跨模块导入:ESM 导入 CJS 一般没问题,支持解构导入;反过来 CJS 用
require加载 ESM 会直接报错,必须用动态await import()。
日常开发先确认项目根目录package.json的type,再决定用哪个文件后缀和导入语法,可以避免绝大多数模块解析错误。
| 导入方式 | 类型 | 执行时机 | 静态 / 动态 | 路径完整要求 | Windows 反斜杠支持 | 相对路径要求 |
|---|---|---|---|---|---|---|
CommonJSrequire() | 运行时函数调用 | 执行到该行才加载 | ✅动态导入 | 1. npm 包:直接写包名 2. 本地文件:操作系统原生磁盘路径;不需要 file:// 协议 | ✅兼容\与/ | 本地相对文件必须带.//../;不带则识别为 npm 包;后缀可省略 |
ESM 静态导入import xxx from "xxx" | JS 语法 | 解析阶段(代码运行前) | ✅静态导入 | 1. npm 包:直接写包名 2. 本地文件:仅允许 .//../相对说明符;禁止 file://、禁止磁盘绝对路径;只能字符串字面量,不能变量 | ❌禁止\,只能正斜杠/ | 必须.//../前缀;必须写完整.js后缀,不可省略 |
ESM 动态导入await import(xxx) | Promise 函数调用 | 执行到该行才加载 | ✅动态导入 | 1. npm 包:直接写包名 2. 本地磁盘文件:必须传入 file://URL;不能直接传操作系统磁盘路径;支持变量传参 | ❌不要手动处理\;原始磁盘路径交给pathToFileURL()自动转 URL | 字面量写./xxx.js规则同静态导入;变量加载本地文件必须转为file:// |
