require 与 import 混用报错:ESM、CJS 判定规则与 5 条报错逐条修

扩展名和最近的 package.json 决定文件是 ESM 还是 CJS,混用报错九成源于此。五条报错原文对应根因与最小修复,附 __dirname 与 require 的替代写法。

约 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 definedESM 没有 CJS 的目录变量用 import.meta.dirname,或 fileURLToPath(import.meta.url)
The requested module does not provide an export named 'x'从 CJS 包做具名导入,静态分析没识别出来先默认导入再解构:import pkg from 'x'

双格式发包的 package.json

exports 同时提供 ESM 与 CJS 入口
{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

ESM 加载 CJS 时 default 是整个 module.exports

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 和打包器里拿到不同的东西,多半就是这个原因。

两种模块的行为差异

  • 顶层 await 只在 ESM 可用,它会阻塞所有依赖它的模块;带顶层 await 的模块无法被 require() 同步加载,调用方会拿到 ERR_REQUIRE_ASYNC_MODULE,这也是双格式发布时最容易漏测的一条路径。
  • 浏览器里 script type=module 默认 defer、自动严格模式、顶层 this 是 undefined,并且按 CORS 规则拉取,跨域脚本必须带正确的 CORS 响应头。CJS 的 require 与 module.exports 在浏览器根本不存在,必须先打包。
  • 循环依赖:CJS 拿到的是尚未填充完的 exports 对象,缺的字段是 undefined;ESM 是实时绑定,访问未初始化的绑定直接抛 ReferenceError,两个方向的报错完全不同。
  • ESM 是静态结构,import 会被提升,必须写在顶层,不能塞进 if 或函数里;条件加载只能用 await import(),它会返回一个命名空间对象。
  • import 的路径必须带扩展名,且相对路径要以 ./ 开头,写成 file.js 会被当成裸包名去 node_modules 里找。Node 也不会像 require 那样补 .js、补 .json 或找目录下的 index;改成 type: module 之后,目录导入也会一起失效。
  • CJS 里的 exports 只是 module.exports 的初始引用,给 exports 整体赋值会断开这条引用,模块导出的还是空对象,这是另一种常见的静默失败。

在 ESM 里补回 __dirname 与 require

Node 20.11+ 的写法
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);

从 CJS 迁到 ESM 的顺序

  1. 先确认 Node 版本不低于 20.11,import.meta.dirname 才有;低于这个版本只能用 fileURLToPath 手算。
  2. 把包入口的 type 改成 module,同时在同一次提交里把该子树所有 .js 的 require 换成 import,中间状态最难调。
  3. 统一处理 __dirname、__filename、require.resolve 三类调用,全部走 createRequire 与 import.meta.url,避免逐处打补丁。
  4. 检查依赖里有没有纯 ESM 包,有就改成动态 import,或者升级到 Node 22.12 以上让 require 直接支持。
  5. 跑一遍构建与测试,重点看循环依赖的模块、动态 require 的分支,以及只有 CI 才会走到的脚本。

不要一次性全改

新项目直接从 type: module 起步,双格式发布用 exports 分别指向 .mjs 与 .cjs。老项目不要一次性全改:先把构建脚本和测试配置改成 .cjs 固定住,再逐个目录迁移业务代码,每迁一个跑一次测试。混用报错九成来自判定规则而不是语法本身,先看扩展名和最近的 package.json,比读十篇迁移指南更快。迁移完成后把 Node 版本写进 engines 字段,否则 CI 上跑的还是旧版本,本地正常、流水线报错的情况会反复出现。子包可以单独设 type,迁移就能分期进行,不必等整个仓库一起改完。验收标准是 node 直接跑测试文件能过,而不是构建成功。还有一点常被忽略:ESM 的加载是异步的,require 是同步的,这个差异决定了你能不能在一个构造函数里同步读出配置。

浏览器里跑 ESM 的三条硬约束

浏览器只认 script type=module,加载路径必须是完整 URL 或相对路径,裸包名在没有 import map 的情况下直接报错,这也是为什么前端项目仍然需要打包器。模块脚本按 CORS 规则拉取,服务端要给出正确的 Content-Type,否则脚本被拒绝执行。同一个模块在页面里只会求值一次,多个 script 标签共享同一张模块图,求值顺序由依赖关系决定而不是标签顺序。开发时用 Vite 或 webpack 不容易碰到这些约束,它们会替你补路径、补扩展名,只有直接部署原生模块时才会暴露,路径写错时报的是 ERR_MODULE_NOT_FOUND,而不是常见的找不到文件。

常见问题

import 和 require 能同时用吗

同一个文件不行,取决于该文件被判为 ESM 还是 CJS;跨文件可以互操作,ESM 能加载 CJS。

怎么快速判断一个文件是 ESM 还是 CJS

看扩展名和最近的 package.json。.mjs 是 ESM,.cjs 是 CJS,.js 看 type 字段。

Node 22 里 require 加载 ESM 稳定吗

22.12 起默认可用,但模块图里有顶层 await 就不能同步加载,会抛错。

浏览器能直接跑 CommonJS 吗

不能。require 与 module.exports 是 Node 的机制,浏览器只认 script type=module,CJS 必须先打包。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件