在 Node.js 或现代打包工具里,只要你在 package.json 写了 "type": "module",整个项目就进入 ESM(ES Module)模式,此时沿用老教程里的 require 写法会直接报错。很多新手从旧博客抄来一段 CommonJS 代码,一跑就崩,却不知道根因是模块系统变了。引入一个非 ES 模块(典型如老旧的 CommonJS 包)时,正确姿势有三种:用默认导入承接它的 module.exports、用命名空间导入拿全部导出、或用 createRequire 在 ESM 里重新拿到 require。本文一次讲清这三种写法的取舍与适用场景,帮你不再被模块报错卡住。

先看结论
当你在 package.json 里写了 "type": "module",整个项目就被当成 ESM,require 不再可用。引入非 ES 模块时:能用默认导入就别用命名空间导入;需要兼容旧 API 或动态加载时用 createRequire;懒加载场景用动态 import()。关键判断点是“这个包到底导出什么形态”,而不是“我想怎么写”。换句话说,模块的写法由依赖决定,不由你的偏好决定;先看清来源包的形态,再去匹配导入语法,这一步想错后面全错。
一、写法一:默认导入承接 module.exports
最常见的非 ES 模块是 CommonJS 包,它用 module.exports 导出单个对象。在 ESM 里可以直接默认导入:
import pkg from 'some-cjs-pkg';
console.log(pkg.someMethod());
Node.js 会做默认互操作,把 module.exports 当作默认导出。阅读 JavaScript 教程 里模块系统的小节,能帮你建立“导出形态决定导入写法”的认知。预期结果是 pkg 拿到那个导出对象,方法可直接调用。一个容易忽略的点是:CommonJS 的 module.exports 如果是函数或原始值(比如 module.exports = 42),默认导入拿到的是这个值本身,而非包了一层对象;很多人以为会有 .default,结果取到 undefined。遇到拿到的结构对不上,先 console.log 一下 pkg 的真实类型,别急着换写法。另一个常见误区是以为默认导入永远拿得到 default,但当包用 exports 字段的现代写法导出时,Node 会优先走 exports 里声明的条件、忽略 module.exports,这时默认导入可能拿到空对象;遇到这种情况,换成命名空间导入往往能拿到完整导出。
二、写法二:命名空间导入拿全部导出
如果不确定包导出了什么,或者它用 exports.xxx = ... 挂了多个成员,用命名空间导入更稳:
import * as pkg from 'some-cjs-pkg';
console.log(pkg.methodA(), pkg.methodB());
这种写法把所有命名导出聚合到 pkg 上,适合接口较多的老包。但要注意边界:某些纯 module.exports = function 的包,命名空间导入拿到的可能不是你想要的。运行 node index.mjs 后,检查 pkg 的结构是否符合预期,若发现方法缺失,退回默认导入。这种写法在引入大型老库(如某些工具类集合)时尤其省心,因为你不需要关心它到底导出了多少成员;但反过来,命名空间导入会把这些成员全部拉进作用域,若包体很大且你只用其中一个函数,按需场景不如默认导入干净。权衡标准是“用得多不多、介不介意一次性加载”。如果你的项目只是临时验证某个老包能不能用,命名空间导入配合 console.table(Object.keys(pkg)) 能快速列出全部可用成员,比逐个试错高效得多。
三、写法三:createRequire 在 ESM 里用回 require
当包依赖 require 的某些特性(如按路径动态加载 JSON、调用只支持 CommonJS 的插件),默认导入会失败。此时用 createRequire 重建一个 require:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('some-cjs-pkg');
配置上确保文件是 .mjs 或项目已声明 ESM。运行后预期能像在 CommonJS 里一样拿到包。若仍报“require is not defined”,先检查是否漏了 import.meta.url,再确认包本身是否要求特定 Node 版本。更多命令与版本边界见 Node.js 速查手册。注意 createRequire 必须在 .mjs 文件或显式 ESM 的上下文中调用,且传入的 import.meta.url 要来自当前模块,不能硬编码成别的路径,否则解析出的相对路径会指向错误目录,找到的包自然也不对。这是动态加载场景下最隐蔽的一个坑。
四、动态 import 处理非 ES 模块的边界
第三种思路的变体是动态 import(),它返回 Promise,适合按需加载体积大的非 ES 模块:
const pkg = await import('some-cjs-pkg');
这在浏览器和 Node 都通用,且不会阻塞首屏。适用边界在于:只在真正需要时调用,避免把静态能解决的依赖变成动态加载。验证阶段用 Node.js 教程 里的运行示例对比不同写法的输出,确认拿到的导出形态一致。若动态导入拿到的是 { default: ... } 包裹结构,按 pkg.default 取用即可。从工程角度看,动态 import() 的最大价值是做代码分割:把不常用的非 ES 模块推迟到真正用到时才加载,能明显减小首包体积。但它也有代价——异步意味着你得处理 Promise 与加载态,不能像静态导入那样直接同步使用。因此“静态解决得了就别动态”仍是默认原则。实际项目里,把一次性工具类按需 import() 是性价比最高的用法,既不影响首屏,也不会让代码变复杂。
常见问题
Q:为什么我用 import 导入老包拿到的是空对象?
多半是包用 module.exports = 导出了函数或原始值,而你可能误用了命名空间导入。改用默认导入 import pkg from '...',通常就能拿到正确的值。
Q:ESM 里还能用 require 吗?
不能直接用,会报 require is not defined。需要时用 createRequire 重建,或者在少数场景改用动态 import(),二者都能在 ESM 里引入非 ES 模块。
Q:浏览器里的非 ES 模块怎么处理?
浏览器原生只认 ESM,老的非 ES 模块需要先经打包工具(如把 CommonJS 转成 ESM)再引入。原则和 Node 一致:先弄清导出形态,再选对应写法。
Q:我怎么快速判断一个包是不是 CommonJS?
最省事的办法是看它的 package.json:如果写了 "type": "commonjs" 或根本没有 type 字段、却用 require/module.exports,基本就是 CommonJS;如果写了 "type": "module" 或明确 exports 字段带 import 条件,就是 ESM。判断清楚再选写法,能省下大量试错时间。


总结
引入非 ES 模块的三条路:默认导入承接 module.exports、命名空间导入聚合成员、createRequire 重建 require。选哪条取决于包的导出形态和你的运行环境。把“导出什么”想清楚,导入写法自然就不会报错。

TRAE-AI编程



