JavaScript 现在并不缺模块系统,真正的问题是你同时会遇到三层东西:

  • 语言标准里的 ESM: import / export
  • Node.js 历史遗留的 CommonJS: require / module.exports
  • 浏览器和包管理层的解析规则: type="module"、import map、package.json"exports" / "imports"

如果只记一个结论:

  • 新项目默认优先写 ESM
  • 遇到旧 Node.js 工程、老工具链、历史包袱时才继续用 CommonJS
  • 给 npm 包设计入口时,优先把公开接口写清楚,不要再依赖“随便从包内部路径 import”

1. 先把几个名词分清

CommonJS

Node.js 最早用的是 CommonJS。典型写法如下:

const fs = require('node:fs')

function add(a, b) {
  return a + b
}

module.exports = {add}

它的特点是:

  • 运行时加载
  • 入口 API 是 require
  • 导出核心是 module.exports
  • 对旧 Node.js 生态兼容最好

如果你是这种写法,不要写 exports = xxx,那只是改了局部变量绑定,真正导出的仍然是 module.exports

ESM

ESM 是 ECMAScript 标准模块格式,现在已经是浏览器和 Node.js 的共同主线:

export function add(a, b) {
  return a + b
}
import {add} from './add.js'

console.log(add(1, 2))

它的特点是:

  • 静态 import / export
  • 更适合分析依赖图、做 tree-shaking、做跨运行时统一
  • 浏览器原生支持,Node.js 也已经稳定支持

2. 2026 年 Node.js 到底怎么判断模块类型

Node.js 官方文档现在写得很明确:Node.js 同时支持 CommonJSESM 两套模块系统。

最实用的判断规则就三条:

  1. .mjs 按 ESM 处理
  2. .cjs 按 CommonJS 处理
  3. .js 取决于最近的 package.json 里的 "type"

比如:

{
  "type": "module"
}

这时同目录下的 .js 会按 ESM 处理。

{
  "type": "commonjs"
}

这时同目录下的 .js 会按 CommonJS 处理。

如果都没写显式标记,Node.js 还会根据源码里是否出现 ESM 语法做判断,但实际工程里不建议依赖这个隐式行为。直接把 "type".mjs.cjs 写清楚,排障成本最低。

3. CommonJS 和 ESM 如何互操作

这是现在最常见的实际问题。

ESM 导入 CommonJS

Node.js 官方文档给出的语义是:

  • import 可以导入 CommonJS
  • default 指向 CommonJS 的 module.exports
  • Node.js 还会做一层静态分析,尽量补出“看起来像命名导出”的字段

例子:

// legacy.cjs
exports.name = 'demo'
// app.mjs
import legacy, {name} from './legacy.cjs'

console.log(legacy)
console.log(name)

但这里最好别过度依赖“猜出来的命名导出”。如果是 CommonJS,最稳妥的方式还是优先按默认导入理解它。

CommonJS 加载 ESM

这边反而限制更多。Node.js 文档说明:

  • require() 只能加载同步 ESM
  • 如果 ESM 使用了 top-level awaitrequire() 这条路就不稳了

所以老 CommonJS 工程想逐步接 ESM,最稳妥的是:

async function main() {
  const mod = await import('./modern.js')
  console.log(mod)
}

main()

也就是在 CommonJS 里用动态 import() 过桥。

4. 浏览器侧现在是什么状态

浏览器早就不是“只能靠打包器模拟模块”的时代了。

原生模块

浏览器可以直接用:

<script type="module" src="/app.js"></script>

模块里再继续:

import {start} from './bootstrap.js'

start()

HTML Standard 里对浏览器模块的几个关键点现在已经非常清楚:

  • import() 是标准能力,而且既能在 classic script 里用,也能在 module script 里用
  • 浏览器会用 module map 去保证同一个模块不会被重复抓取和重复求值
  • JavaScript 模块默认就是 JavaScript 类型,不需要也不能再写 with { type: "javascript" }

Import maps

浏览器和 Node.js 最大的习惯差别,是浏览器默认不认识裸 specifier 对应的包名解析规则,比如:

import {uniq} from 'lodash-es'

在浏览器里,如果你想这么写,需要 import map:

<script type="importmap">
  {
    "imports": {
      "lodash-es": "/vendor/lodash-es/lodash.js"
    }
  }
</script>

这也是为什么 Node.js 官方文档现在专门提醒包作者:既然 import maps 已经是浏览器和其他运行时里的标准能力,包的子路径导出最好保持清晰、稳定,必要时显式带扩展名,避免把 import map 搞得过于臃肿。

5. package.json 里最重要的两个字段

如果你在写 npm 包,现在最应该关注的不是“模块化概念大全”,而是包入口设计。

"exports"

Node.js 官方明确建议:新包优先使用 "exports" 字段定义公开入口。

{
  "exports": {
    ".": "./index.js",
    "./cli.js": "./cli.js"
  }
}

它解决的是两个问题:

  • 明确包的公共 API
  • 把内部文件和公开入口分开,避免别人直接 import 你的私有路径

一旦写了 "exports",没有显式导出的子路径就不该再被外部依赖。

"imports"

"imports" 是包内部私有映射,只给包自己用,键名必须以 # 开头:

{
  "imports": {
    "#internal/logger.js": "./src/logger.js"
  }
}

然后在包内部这样写:

import {log} from '#internal/logger.js'

这对重构内部目录很有用,因为你不需要在一堆相对路径里来回改 ../../..

6. 现在还需要 UMD、AMD 吗

除非你在维护特别老的前端资产,或者要兼容非常老的嵌入式加载环境,否则大多数新项目已经不需要把 UMD、AMD 当默认方案了。

这一点是基于当前 Node.js 官方文档和 HTML Standard 能力做出的工程判断:

  • 运行时层面,Node.js 已稳定支持 ESM,同时继续兼容 CommonJS
  • 浏览器层面,原生模块、动态 import()、import maps 都已经进入标准路径

所以今天真正常见的组合通常是:

  • 应用代码写 ESM
  • 少量遗留依赖继续保持 CommonJS
  • 包入口通过 "exports" / "imports" 管理

7. 实际怎么选

场景一:新建 Node.js 应用

直接选 ESM:

{
  "type": "module"
}

然后统一写:

import {readFile} from 'node:fs/promises'

场景二:在维护老 Node.js 项目

如果仓库已经大量使用:

const x = require('x')
module.exports = something

那就不要为了“追新”一次性全仓硬切。更现实的策略是:

  • 保留 CommonJS 主体
  • 新增模块尽量独立
  • 需要接 ESM 时,用动态 import() 做边界过渡

场景三:发布 npm 包

优先考虑三件事:

  • 是否写清楚 "exports"
  • 是否需要 "imports" 管理包内私有路径
  • 是否真的需要同时维护 import / require 双入口

双入口能做,但复杂度会上升,尤其是条件导出、默认导出语义、类型声明路径、测试矩阵都会变麻烦。除非你确实有 CommonJS 消费者需要兼容,否则直接提供清晰的 ESM 入口通常更省事。

8. 结论

  • 语言标准 的主线已经是 ESM,不再有争议
  • Node.js 现实世界 仍然同时存在 ESM 和 CommonJS
  • 浏览器原生模块 早已可用,import maps 解决的是包名解析问题
  • 包发布 重点是 "exports" / "imports" 设计,而不是再背一遍历史模块名词
  • 迁移策略 不要激进;在边界上用动态 import(),通常比全仓瞬时改造更稳

参考