JavaScript 模块系统:一场至今未醒的历史梦魇
一、引言:我们真的解决了"模块化"吗?
你可能以为,JavaScript 模块系统早已标准化,import/export 就是答案。 但现实却是另一番景象:构建报错、依赖冲突、加载失败几乎成了日常。 从 <script>
到 require()
到 import/export
,我们始终在为过去的架构选择埋单。
模块化理应是解决复杂项目的基础设施,却变成了开发者最常踩雷的区域。
事实上模块系统不仅没带来统一,反而成为 JavaScript 疲劳的结构性根源。这不得不谈起JavaScript的历史谈起。

二、模块混乱简史:从混沌到多头并立
没有模块的年代(2000 年代初)
JavaScript 的早期设计压根没考虑模块化,全靠全局变量堆叠逻辑。 开发者只能依赖 <script>
标签的顺序加载,代码易碎且无法维护。 每多一个依赖,就多一次"希望变量名别撞上"的祈祷。
社区自救:非官方解决方案
在官方迟迟不出手的背景下,社区自发提出了模块化"假方案":IIFE、揭示模块模式、命名空间对象。
这些方法聪明,但彼此无法兼容,无法跨项目协作,也缺乏系统级支持。
JavaScript 项目开发在很长一段时间里都像是"野路子拼图"。
Node.js 引入 CommonJS
Node.js 首次将模块概念"官方化":使用 require()
同步加载模块、通过 module.exports
暴露接口。 这让服务端开发变得清晰许多,但也制造了新的麻烦------浏览器根本不支持这一套。 为了"翻译" CommonJS 模块,我们被迫发明 Browserify、Webpack 等复杂工具链。
ES Modules 到来
ES6 标准引入了 import
和 export
,看似终于有了解药。 可惜为时已晚:CommonJS 早已根深蒂固,打包工具演化成庞然大物,模块格式分裂成混战状态。 从此之后,模块系统不再是"写法选择",而是构建工具之间的谈判协议。
三、模块系统的真实代价
你可能遇到过:"Cannot use import outside a module"、"SyntaxError: Unexpected token 'export'" 等经典报错。
这些并不是语法问题,而是模块格式错配、环境配置错误的表现。
每一次 import 报错背后,都隐藏着 JavaScript 二十年历史的裂缝。
模块系统的混乱还导致 tree shaking 常常失效、包体积变大、加载性能下降。
开发者发布一个包,不得不生成 CommonJS、ESM、UMD 等多个格式,搞懂每种写法的兼容差异。
最终,"模块"这个原本该简化协作的机制,反而成了构建过程最大的复杂源之一。

四、CommonJS vs ESM:核心差异与兼容性问题
CommonJS(CJS)和 ECMAScript Modules(ESM)在 Node.js 中长期共存,成为 JavaScript 最顽固的技术债之一。
它们语法、加载方式和运行时特性都有差异,开发者在写模块时常常小心翼翼,很多报错并非代码写错,而是模块系统错用。
语法:require() 与 import/export 的差异
CommonJS 使用 require()
同步加载,接口通过 module.exports
暴露,简单直观,成为 Node.js 服务端的事实标准。
javascript
// CommonJS 示例
const { addTwo } = require('./addTwo.js');
console.log(addTwo(2));
而 ESM 使用静态语法的 import
和 export
,支持静态分析和 tree shaking,是 ES6 标准,适用于浏览器和服务器。
javascript
// ESM 示例
import { addTwo } from './addTwo.mjs';
console.log(addTwo(2));
两者不能直接混用,需额外适配层实现互操作。
加载方式:同步 vs 异步
CommonJS 采用同步加载,适合服务端读取本地文件,但浏览器端不适用。 ESM 采用异步加载,import
语句必须顶层使用,符合现代网络环境需求,更适合性能优化。
Tree shaking 与静态分析
ESM 支持 tree shaking,构建工具可去除未使用代码,提升性能。
CommonJS 运行时动态加载,无法静态分析,导致包体积通常较大。
__dirname、__filename 与 import.meta.url
CommonJS 中可以直接用 __dirname
和 __filename
获取当前路径。 ESM 中这两个变量被移除,需使用 import.meta.url
配合 Node.js 内置模块处理路径,容易踩坑。
javascript
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
其他细节差异
特性 | CommonJS | ESM |
---|---|---|
加载方式 | 同步 require() | 异步 import |
Tree shaking | 不支持 | 支持 |
扩展名 | 可省略 .js | 必须写明 .mjs 或设置 "type":"module" |
JSON 导入 | require('./data.json') | import data from './data.json' with { type: 'json' } (Node 17+) |
顶层 await | 不支持 | 支持 |
动态导入 | 仅支持 require() | 支持 import() 动态加载 |
内建模块导入 | require('fs') | import fs from 'node:fs' (Node 12.20+) |
模块缓存 | 共享 require.cache | 独立缓存 |
互操作:CommonJS 与 ESM 混用
- ESM 中用 CommonJS :使用
createRequire()
创建加载器,或用动态import()
。
javascript
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const lodash = require('lodash');
- CommonJS 中用 ESM :必须使用动态
import()
,Node.js 23+ 支持用require()
直接加载无顶层 await 的 ESM 模块。
javascript
async function loadESM() {
const { addTwo } = await import('./addTwo.mjs');
console.log(addTwo(3));
}
loadESM();
// Node 23+ 新特性
const esm = require('./esm-file.mjs');
console.log(esm);
五、现实中的迁移方案
新项目建议直接使用 ESM,从一开始就站在"更现代、更统一"的起跑线。
但对于旧项目来说,迁移之路并不轻松。CommonJS 与 ESM 在模块加载方式、路径解析、缓存机制、动态导入等方面都存在结构性差异。
为了平稳过渡,你可以采用以下策略:
- 渐进式迁移 :保留 CommonJS 主体结构,逐步将核心模块替换为 ESM,并通过
await import()
在 CJS 中引入新模块。 - 分层测试环境:为每次模块替换设立测试边界,确保行为一致性。
- 利用 Node.js 23+ 的新特性 :该版本提供了有限条件下的
require()
加载 ESM 支持,减少早期转译依赖。 - 使用 ServBay :它提供了快速搭建支持多模块系统的 Node 项目能力,默认支持
.mjs
、"type": "module"
配置,并允许你在本地独立测试 CJS/ESM 混合代码,避免在 CI/CD 中踩雷。

六、不为旧坑背锅:写给每一位 JavaScript 开发者
JavaScript 的模块系统从来不是被"设计"出来的,而是被"补丁"堆出来的。 最早没有模块,我们拼命创造"伪模块";Node.js 引入 CommonJS,浏览器不认;ESM 到来,却又太迟,生态已四分五裂。 结果是现在的模块化不再只是技术问题,而是一种系统性的历史负担。
你不是因为不懂 import/export 才被报错折磨,而是因为这本来就不是统一的世界。
Maxime 在《Modules in JavaScript: A 20-Year Mistake》中说得很直接:
"我们没构建出模块系统,我们只是造了个兼容层,用来盖住 20 年来的混乱。"
即便如此,模块迁移依旧值得进行。 它不仅能提高构建效率、支持现代浏览器和服务端 API,更是未来生态向前演进的基石。 你无需一夜转型,可以选择"旧中有新",逐步引入标准写法、修复遗留边界。
最后别忘了:模块是用来组织代码的,不是用来折磨开发者的。 我们不该为历史重复付出代价,而应该用工具和知识构筑一条更清晰的道路。