先记住这个答案
Node 从 ESM 导入 CommonJS 时,会提供对应 module.exports 的默认导出,并尝试根据常见源码模式静态识别额外命名导出。这种兼容检测不是执行任意 JavaScript 后枚举所有属性,动态拼接导出名等写法可能无法识别,因此命名导入会失败,而默认导入后读对象属性仍可工作。合成的命名导出也不跟踪后续属性更新,不能等同于原生 ESM 的实时绑定。消费旧包时应遵循其发布契约,并在实际 Node 运行方式下验证。
- 对象属性存在与静态命名导出可识别是两回事
- 默认导入接收 CommonJS 的 module.exports 值
- 合成命名导出不会持续同步对象属性变化
动态计算属性能暴露检测与运行的差异
下面的 CommonJS 用字符串片段计算属性名,最终对象确实有 label。ESM 默认导入后读取 label 输出业务值,这只需要访问导出对象,不依赖 Node 在执行前静态猜到这个名称。
将入口改成 import { label } from 同一文件可能在链接阶段报命名导出不存在。示例选用动态计算是为了说明边界,不建议为测试工具故意把公开导出写得难以分析;包作者应提供清晰稳定的入口。
// file: dynamic.cjs
const key = ['la', 'bel'].join('');
module.exports = { [key]: '动态导出' };
// file: main.mjs
import data from './dynamic.cjs';
console.log(data.label);运行 node main.mjs 输出动态导出。另建入口使用 import { label } 时可以对照静态检测结果;默认导入成功并不能推导所有对象键都可以作为 ESM 命名导入。
合成命名值与对象属性更新不是同一机制
对于 exports.value = 1 这样的常见形式,Node 可能提供 value 命名导出。但 CommonJS 后续把导出对象上的 value 改为 2,已经合成的命名值不会像原生 ESM 绑定一样自动跟随。
若默认导入拿到的是同一个对象,之后读取 data.value 则可以看到对象属性更新。提前解构 const { value } = data 又会复制当时的值;分析时需要追踪究竟保存的是数值、对象引用还是模块绑定。
对发布产物测试而不是依赖开发环境的宽容
打包器和转译器可能生成自己的互操作包装,开发环境里的命名导入成功不代表原生 Node 也能接受。升级依赖后若构建产物改变导出写法,即使最终对象看起来相似,静态识别结果也可能变化。
应用侧可按包文档选择默认导入或明确的 ESM 入口,库作者则应测试支持范围内的真实加载方式。若暴露可变状态,优先提供有清晰语义的读取函数,不要把合成命名值当作跨模块状态同步协议。
容易答错的地方
- 把对象解构称为 ESM 实时绑定
- 默认导入之后的解构属于普通 JavaScript 求值,与 import 的绑定机制不同。数值属性后续修改不会回写到已经赋值的局部变量;需要当前状态时,应在使用时读取对象或调用公开读取接口。
- 看到命名导入失败就断言整个包不支持 ESM 使用
- 失败可能仅来自静态导出检测,而默认导入仍然符合互操作契约。应先看实际发布入口和导出形状,再选择受支持的访问方式,避免为了一个导入语法错误引入不必要的整库替换。
面试官还会怎么问?
import 星号能绕过命名识别限制吗?
命名空间导入不会让 Node 凭空识别新的 CommonJS 名字。通常可以经由 namespace.default 访问实际导出值,但仍应依据当前版本与包契约检查形状,不能假定每个动态属性都出现在命名空间顶层。
CommonJS 替换 module.exports 后默认导入会换对象吗?
默认导入取得的值不会成为每次访问都重新读取 module.exports 的代理。若模块之后换了另一对象,已有消费者可能仍持有旧值;应避免把延迟替换导出对象当作可靠的更新通知机制。
库作者怎样减少消费者的互操作意外?
提供明确文档和稳定发布入口,测试原生 Node 的导入与加载用例,并避免依赖工具特有包装才能工作。对于需要动态变化的数据,使用显式函数接口表达读取时机,通常比暴露可变合成命名值更清楚。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。