一、为什么需要模块化
JavaScript 早期没有官方模块系统。代码用 <script> 标签一个个堆上去,所有变量都挂在 window 上,问题很具体:
- 全局变量互相污染,不同库的
utils、helper随时命名冲突 - 文件加载顺序完全靠手动排列,
jQuery必须在app.js之前,漏了就挂 - 没有依赖声明,团队协作靠"口口相传"
随着 Web 应用变复杂,这套方式撑不住了,社区开始自己造模块系统。
二、社区模块方案:CJS、AMD、UMD
2.1 CommonJS:服务端的第一个方案
2009 年 Node.js 出现,它需要在服务端管理文件级别的模块,于是实现了 CommonJS 规范。
核心就三个东西:
js
// 导出
module.exports = { add, sub }
// exports 是 module.exports 的快捷方式
exports.add = function(a, b) { return a + b }
// 导入
const math = require('./math')
一个常见的坑:
js
// ❌ 断开了 exports 和 module.exports 的关联
exports = { add: function() {} }
// ✅ 整体替换要用 module.exports
module.exports = { add: function() {} }
原因是 Node.js 初始化模块时做了这件事:
js
var module = { exports: {} }
var exports = module.exports // 两个变量指向同一个对象
exports = {...} 让 exports 指向了新对象,和 module.exports 断开。require() 只认 module.exports,所以拿到的是空对象。
Node.js 把每个文件包装成函数执行:
js
(function(module, exports, require, __filename, __dirname) {
// 你的代码
})
这就是为什么任何 CJS 文件里都能直接用 module、exports、require------它们是包装函数传进来的参数,不是全局变量。
CommonJS 的问题:require() 是同步的。 服务端读磁盘是毫秒级,没问题。浏览器要走网络,同步等待 = 页面卡死,所以 CJS 不能直接用在浏览器。
2.2 AMD:专为浏览器设计的异步方案
为了解决浏览器异步加载的问题,社区提出了 AMD 规范,代表实现是 RequireJS。
js
// 先声明依赖,加载完再回调执行
define(['jquery', 'underscore'], function($, _) {
return {
render: function() { ... }
}
})
核心思路:先声明"我需要哪些依赖",加载器异步并行拉取,全部加载完再执行回调。解决了浏览器异步问题,但语法繁琐,嵌套深了不好维护。
2.3 UMD:库作者的万能适配器
如果你是开源库作者,你不知道用户在哪个环境------可能是 Node.js(CJS),可能是 RequireJS(AMD),可能是直接 <script> 标签引入(全局变量)。
UMD 的本质是一段运行时 if-else 检测代码:
js
(function(global, factory) {
if (typeof define === 'function' && define.amd) {
// AMD 环境(RequireJS 暴露了 define 函数)
define(factory)
} else if (typeof module !== 'undefined' && module.exports) {
// CJS 环境(Node.js 有 module.exports)
module.exports = factory()
} else {
// 浏览器裸跑,挂到全局变量
global.MyLib = factory()
}
}(this, function() {
// 库的实际代码
return { ... }
}))
这里的 factory() 在手写 UMD 里通常就是库代码本身。如果这份 UMD 是 Webpack 这类打包器生成的,factory() 里面还会再进入打包器自己的模块运行时;这属于"打包产物的两层结构",后面再展开。
运行时判断当前是哪个环境,用对应的方式注册自己:
| 环境 | typeof define |
typeof module |
走哪条分支 |
|---|---|---|---|
| RequireJS | 'function' |
无所谓 | AMD |
| Node.js | 'undefined' |
'object' |
CJS |
| 浏览器裸跑 | 'undefined' |
'undefined' |
全局变量 |
UMD 不是一种新格式,是把三种注册方式包在一起的胶水代码。
三、ES Modules:语言层面的标准
3.1 基本语法
2015 年 ES6 正式引入原生模块系统:
js
// 导出
export default function add(a, b) { return a + b }
export function sub(a, b) { return a - b }
// 导入
import add from './math'
import { sub } from './math'
优势:
- 静态分析,
import/export必须在顶层,打包工具可以做 tree-shaking - 浏览器原生支持(
<script type="module">) - 语言标准,不依赖运行时实现
但历史遗留库大量是 CJS/UMD,ESM 和 CJS 的互操作问题延续至今。
3.2 浏览器原生 ESM
现代浏览器已经原生支持 ESM:
html
<script type="module" src="/src/index.js"></script>
模块脚本和普通脚本有几个明显区别:
- 默认是严格模式
- 顶层变量只属于当前模块,不会自动挂到
window - 默认延迟执行,行为接近
defer - 可以用静态
import/export声明依赖 - 也可以用动态
import()按需加载模块
js
// src/index.js
import { add } from './math.js'
console.log(add(1, 2))
js
// src/math.js
export function add(a, b) {
return a + b
}
浏览器会从入口文件开始,顺着 import 递归加载依赖,形成一张模块依赖图。
3.3 原生 ESM 的现实限制
原生 ESM 解决了语言层面的模块标准,但生产环境还有现实限制:
- 每个模块都是一个网络请求,文件多了会有大量请求
- 浏览器不认识 npm 包名解析,比如
import react from 'react' - 老旧浏览器不支持或支持不完整
- 生产环境通常还需要压缩、拆包、缓存指纹、资源处理
所以浏览器支持 ESM 不等于前端项目就不需要打包工具。
四、为什么还需要打包器
Webpack、Rollup、Vite 这类工具解决的核心问题不是"发明模块规范",而是把开发阶段的模块依赖图加工成适合生产环境加载的产物。
4.1 生产环境需要解决的问题
它们通常负责:
- 从入口文件开始分析
import、require、动态import(),构建完整依赖图 - 解析 npm 包入口和路径别名
- 兼容 ESM、CJS、UMD 等不同模块格式
- 删除没有用到的导出,也就是 tree-shaking
- 把代码拆成多个 chunk,实现按需加载
- 给输出文件加 hash,方便长期缓存
- 把 CSS、图片、字体等资源接入同一套依赖图
4.2 模块规范和模块打包不是一回事
所以现代前端模块化可以理解成两层:
| 层级 | 解决什么问题 | 代表 |
|---|---|---|
| 模块规范 | 代码如何声明导入和导出 | ESM、CJS、AMD、UMD |
| 模块打包 | 如何把模块图变成浏览器适合加载的文件 | Webpack、Rollup、Vite |
五、打包器内部如何运行模块
5.1 模块表、缓存和 __webpack_require__
浏览器不认识 CJS 的 require,也不认识构建工具内部的模块 ID。打包器会在输出文件里生成一小段模块运行时代码,用来注册模块、缓存模块、执行入口。
js
// bundle.js 的核心结构
(function() {
// 打包器生成的模块加载函数
function __bundle_require__(moduleId) {
if (moduleCache[moduleId]) return moduleCache[moduleId].exports
var module = { exports: {} }
moduleCache[moduleId] = module
modules[moduleId](module, module.exports, __bundle_require__)
return module.exports
}
var moduleCache = {}
// 所有源文件都被打包进这个对象
var modules = {
'./math.js': function(module, exports) {
exports.add = function add(a, b) { return a + b }
},
'./index.js': function(module, exports, require) {
var math = require('./math.js')
console.log(math.add(1, 2))
}
}
__bundle_require__('./index.js')
})()
5.2 和 Node.js CommonJS 的对比
对比起来:
| Node.js | 打包器 | |
|---|---|---|
require |
内置,读磁盘 | 自己实现,读 modules 对象 |
| 缓存 | 有 | 有 |
module.exports |
有 | 有 |
| 作用域隔离 | 函数包装 | 函数包装 |
这个例子只是说明"打包器运行时"的基本形态。真实的 Webpack/Rollup/Vite 输出会更复杂,因为它们还要表达 ESM 的 live binding、default export、chunk 异步加载、循环依赖等语义。
六、不同格式的依赖被打进 bundle 后会怎样
6.1 先接入统一模块图
打包器会把不同来源的模块统一接入自己的模块图:
javascript
ESM 代码 → 静态分析 import/export → 接入模块图
CJS 库 → 识别 require/exports → 接入模块图
AMD 库 → 识别 define → 接入模块图
UMD 库 → 根据环境分支识别导出方式 → 接入模块图
最终输出不一定都等价于 module.exports。更准确地说,打包器会把不同模块格式统一编排到自己的模块运行时里,再按目标环境输出成一个或多个 JS 文件。
接入模块图以后,还有一个更容易混淆的问题:一个 npm 包被打进 bundle 以后,它执行时看到的 module、exports、require、import/export,到底是谁提供的?
以 Webpack 为例,源码模块和 npm 包都会被放进 Webpack 的模块表,再由 Webpack runtime 调用:
js
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)
所以,被打进 bundle 的依赖不是直接运行在原始宿主环境里,而是运行在打包器构造出来的模块环境里。
6.2 CJS npm 包:module 由 Webpack 注入
CJS 包源码里经常会写:
js
if (typeof module !== 'undefined' && module.exports) {
module.exports = factory()
}
如果这个包被 Webpack 打进浏览器 bundle,这里的 module 不是 Node.js 的真实 module,而是 Webpack 执行内部模块函数时传入的对象:
js
var module = { exports: {} }
__webpack_modules__[id](module, module.exports, __webpack_require__)
所以这个判断会命中 CJS 分支,但 module.exports 最终被 Webpack 的内部模块系统接住。其他模块再通过 __webpack_require__(id) 拿到这个包的导出。
6.3 ESM npm 包:import/export 被编排成内部导出关系
ESM 包源码通常是:
js
export function add(a, b) {
return a + b
}
export default function multiply(a, b) {
return a * b
}
ESM 的重点不是运行时判断 module 是否存在,而是构建时静态分析。Webpack 会读取 import/export,把它们接入模块图,并在内部 runtime 里维护导出关系。
概念上可以理解成:
js
// Webpack 内部记录:这个模块有哪些导出
__webpack_require__.d(__webpack_exports__, {
add: function() { return add },
default: function() { return multiply }
})
这样做是为了保留 ESM 的语义,比如命名导出、默认导出、live binding,以及 tree-shaking 所需要的静态结构。也就是说,ESM 包被打进 bundle 后,不是浏览器原生 import 逐个文件加载,而是由 Webpack 统一编排进自己的模块运行时。
6.4 UMD npm 包:环境判断也可能命中 Webpack 提供的环境
有些 npm 包发布的是 UMD 产物,里面可能有这种判断:
js
if (typeof define === 'function' && define.amd) {
define(factory)
} else if (typeof module !== 'undefined' && module.exports) {
module.exports = factory()
} else {
root.Lib = factory()
}
如果这段代码作为依赖被打进 Webpack bundle,它执行时也在 Webpack 的内部模块函数里。此时 module 可能是 Webpack 注入的内部模块对象,所以它会走 CJS 分支,而不是走浏览器全局变量分支。
这就是容易误解的地方:代码里写的是 UMD 环境判断,但它判断到的"环境"不一定是最终浏览器页面,而可能是打包器为这个依赖模块提供的内部执行环境。
6.5 不要和 bundle 的外层输出格式混淆
被打进 bundle 的依赖有自己的模块执行环境;整个 bundle 也有自己的外层输出格式。这是两层不同的问题:
| 层级 | 解决的问题 | 例子 |
|---|---|---|
| 依赖模块内部 | 某个 npm 包如何执行、缓存、导出 | CJS 包写入 Webpack 内部的 module.exports |
| bundle 外层 | 整个产物如何交给宿主环境 | library.type: 'umd' 输出到 module.exports、AMD 或全局变量 |
所以,一个 bundle 可以外层输出成 UMD;同时它内部某个 npm 包仍然按 CJS 方式被 Webpack 执行。前者决定"整个 bundle 暴露给谁",后者决定"bundle 里面的模块如何互相工作"。
七、现代前端项目的模块链路
markdown
你写的代码(通常是 ESM)
↓
打包器解析入口文件
↓
构建完整模块依赖图
↓
处理 ESM / CJS / UMD 等格式差异
↓
tree-shaking、拆包、资源接入、产物优化
↓
输出 bundle / chunks
↓
浏览器按最终产物加载执行
分工总结:
- ESM:现代前端代码的主流模块写法
- CJS:Node.js 生态和历史 npm 包里的重要格式
- UMD:主要服务于 CDN 直接引入
- 打包器:把各种模块格式统一成浏览器适合加载的产物
八、发布 npm 包时的格式选择
现代库一般同时输出多个格式:
js
// rollup.config.js
export default [
{ output: { file: 'dist/index.cjs.js', format: 'cjs' } }, // Node.js / 旧 Webpack
{ output: { file: 'dist/index.esm.js', format: 'esm' } }, // 现代打包工具,支持 tree-shaking
{ output: { file: 'dist/index.umd.js', format: 'umd', name: 'MyLib' } } // CDN script 标签
]
json
// package.json 告诉不同工具读哪个文件
{
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"exports": {
".": {
"import": "dist/index.esm.js",
"require": "dist/index.cjs.js"
}
},
"unpkg": "dist/index.umd.js"
}
UMD 现在主要保留给"不用打包工具直接 CDN 引入"这个场景,其余场景被 ESM + CJS 双入口替代。