工程
JavaScript 模块系统
用一篇文章看清 CommonJS、ESM、浏览器模块、Node.js packages exports/imports 的当前用法。
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 同时支持 CommonJS 和 ESM 两套模块系统。
最实用的判断规则就三条:
.mjs按 ESM 处理.cjs按 CommonJS 处理.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可以导入 CommonJSdefault指向 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
await,require()这条路就不稳了
所以老 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(),通常比全仓瞬时改造更稳