约 7 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证
Node 只认扩展名和最近的那个 package.json。.mjs 一定是 ESM,.cjs 一定是 CJS,.js 则向上查找最近的 package.json:type 写 module 就是 ESM,没写或写 commonjs 就是 CJS。注意是最近的,子目录里再放一个 package.json 就会改变整个子树的判定。同一个包里两种模块可以共存,但一个文件只能属于一种,运行时不会根据你写了 import 还是 require 去猜。报错信息里所谓 outside a module,说的就是这个判定结果。还有两个容易被忽略的前提:tsconfig 的 module 选项只影响编译产物,不改变 Node 运行时的判定;打包器有自己一套解析规则,同一份代码在 Vite 里能跑、直接用 node 执行就报错,原因通常就在这里。package.json 的 exports 字段还能给 import 与 require 各指一个入口,条件顺序写反时,Node 会挑到不符合预期的那个文件。monorepo 里最常见:根 package.json 与子包的 type 不一致,同一段代码在两个目录里表现完全不同,报错却长得一样。
| 报错原文 | 根因 | 最小修复 |
|---|---|---|
| Cannot use import statement outside a module | 文件按 CJS 解析,里面写了 import | 改成 .mjs,或在 package.json 加 "type": "module" |
| require is not defined in ES module scope | 文件按 ESM 解析,里面用了 require | 改用 import,或用 createRequire(import.meta.url) |
| ERR_REQUIRE_ESM | 用 require() 加载纯 ESM 包 | 改成 await import(),或升级到 Node 22.12 以上 |
| __dirname is not defined | ESM 没有 CJS 的目录变量 | 用 import.meta.dirname,或 fileURLToPath(import.meta.url) |
| The requested module does not provide an export named 'x' | 从 CJS 包做具名导入,静态分析没识别出来 | 先默认导入再解构:import pkg from 'x' |
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}ESM 加载 CJS 时,default 拿到的是整个 module.exports 对象。具名导出靠 cjs-module-lexer 做静态分析,只有 exports.foo = 这类能直接看出来的写法才会被识别;动态拼接属性名、或者在循环里赋值,分析不出来,运行时就会报 does not provide an export named。遇到这种包,最稳的写法是默认导入再解构,代价是打包器无法做 tree-shaking。反过来,Babel 或 tsc 编出来的 CJS 往往带 __esModule 标记,Node 见到这个标记就把 default 当成真正的默认导出,而不再当成整个命名空间。两种包混在同一棵依赖树里,行为并不一致,用默认导入再解构是唯一同时兼容的写法。另外,ESM 的命名空间对象是不可扩展的,往上加属性会静默失败,这和 CJS 里随手改 exports 完全是两回事。动态 import() 返回的也是这种命名空间对象,所以解构 CJS 包时经常要多加一层 default。同一行 import 在 Node 和打包器里拿到不同的东西,多半就是这个原因。
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
const here = import.meta.dirname; // Node 20.11+
const file = join(here, 'data.json');
console.log(pkg.name, readFileSync(file, 'utf8').length);新项目直接从 type: module 起步,双格式发布用 exports 分别指向 .mjs 与 .cjs。老项目不要一次性全改:先把构建脚本和测试配置改成 .cjs 固定住,再逐个目录迁移业务代码,每迁一个跑一次测试。混用报错九成来自判定规则而不是语法本身,先看扩展名和最近的 package.json,比读十篇迁移指南更快。迁移完成后把 Node 版本写进 engines 字段,否则 CI 上跑的还是旧版本,本地正常、流水线报错的情况会反复出现。子包可以单独设 type,迁移就能分期进行,不必等整个仓库一起改完。验收标准是 node 直接跑测试文件能过,而不是构建成功。还有一点常被忽略:ESM 的加载是异步的,require 是同步的,这个差异决定了你能不能在一个构造函数里同步读出配置。
浏览器只认 script type=module,加载路径必须是完整 URL 或相对路径,裸包名在没有 import map 的情况下直接报错,这也是为什么前端项目仍然需要打包器。模块脚本按 CORS 规则拉取,服务端要给出正确的 Content-Type,否则脚本被拒绝执行。同一个模块在页面里只会求值一次,多个 script 标签共享同一张模块图,求值顺序由依赖关系决定而不是标签顺序。开发时用 Vite 或 webpack 不容易碰到这些约束,它们会替你补路径、补扩展名,只有直接部署原生模块时才会暴露,路径写错时报的是 ERR_MODULE_NOT_FOUND,而不是常见的找不到文件。
同一个文件不行,取决于该文件被判为 ESM 还是 CJS;跨文件可以互操作,ESM 能加载 CJS。
看扩展名和最近的 package.json。.mjs 是 ESM,.cjs 是 CJS,.js 看 type 字段。
22.12 起默认可用,但模块图里有顶层 await 就不能同步加载,会抛错。
不能。require 与 module.exports 是 Node 的机制,浏览器只认 script type=module,CJS 必须先打包。