模块化
模块化发展史
JavaScript 诞生之初并没有模块系统,所有代码共享全局作用域。随着应用复杂度增长,模块化经历了以下演进阶段:
IIFE 时代
最早的模块化方案是利用立即执行函数表达式(IIFE)创建独立作用域,通过闭包实现私有变量:
const MyModule = (function () {
let _private = 0
function increment() {
_private++
}
function getCount() {
return _private
}
return { increment, getCount }
})()
MyModule.increment()
MyModule.getCount()IIFE 解决了全局污染问题,但模块之间的依赖关系需要手动管理,加载顺序必须人工保证。jQuery 插件体系就是典型的 IIFE 模块化实践。
CommonJS
2009 年 Node.js 采用 CommonJS 规范,将模块化带入服务端。核心思想是:每个文件就是一个模块,通过 require 加载、module.exports 导出。
const utils = require('./utils')
function handler(req, res) {
const result = utils.parse(req.body)
res.json(result)
}
module.exports = { handler }CommonJS 是同步加载的,适用于服务端磁盘 I/O 场景,但不适合浏览器环境的网络异步加载需求。
AMD(Asynchronous Module Definition)
为解决浏览器端异步加载问题,RequireJS 推出了 AMD 规范:
define(['jquery', 'lodash'], function ($, _) {
function render(data) {
const sorted = _.sortBy(data, 'name')
$('#app').html(sorted.map(item => `<li>${item.name}</li>`).join(''))
}
return { render }
})AMD 使用 define 声明模块,依赖前置声明、异步加载。但语法冗长,开发体验不佳。
UMD(Universal Module Definition)
UMD 是一种兼容方案,让模块同时支持 CommonJS、AMD 和全局变量:
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define(['dependency'], factory)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('dependency'))
} else {
root.MyModule = factory(root.Dependency)
}
})(typeof self !== 'undefined' ? self : this, function (Dependency) {
return { }
})UMD 本质上是运行时的环境检测分支,常见于需要同时发布到多种环境的类库(如 Lodash、Axios 的旧版本)。
ESM(ES Modules)
ES2015 正式引入语言层面的模块系统。import/export 是静态声明,必须出现在模块顶层:
import { parse } from './utils.js'
import defaultExport from './config.js'
export function handler(req) {
return parse(req.body)
}
export default handlerESM 是目前的标准方案,浏览器和 Node.js 均已原生支持。
时间线总览
1995 全局变量 / 命名空间
2003 IIFE 模块模式
2009 CommonJS (Node.js)
2011 AMD (RequireJS)
2013 UMD
2015 ESM (ES2015 标准)
2017 Node.js 开始实验性支持 ESM
2020 Node.js 正式稳定支持 ESMCommonJS 原理
require 缓存机制
Node.js 中每个模块在首次 require 时会被编译执行,执行结果缓存在 require.cache 中(以文件绝对路径为 key)。后续再次 require 同一模块时直接返回缓存结果,不会重新执行模块代码。
const mod1 = require('./counter')
const mod2 = require('./counter')
mod1 === mod2缓存对象的结构是 Module 实例:
{
id: '/absolute/path/to/counter.js',
filename: '/absolute/path/to/counter.js',
loaded: true,
exports: { ... },
children: [...],
paths: [...]
}可以通过删除缓存强制重新加载(常用于热更新场景):
delete require.cache[require.resolve('./counter')]
const fresh = require('./counter')值拷贝特性
CommonJS 导出的是值的拷贝(对于原始类型)。模块内部变量变化后,外部已经引入的值不会同步更新:
let count = 0
function increment() {
count++
}
function getCount() {
return count
}
module.exports = { count, increment, getCount }const counter = require('./counter')
counter.count
counter.increment()
counter.count
counter.getCount()counter.count 仍然是 0,因为导出时 count 被拷贝到 exports 对象上。而 getCount() 返回 1,因为函数闭包引用的是模块内部的 count 变量。
如果导出的是引用类型(对象/数组),则外部可以通过引用修改内部状态——这是因为 JavaScript 中引用类型赋值的是指针拷贝。
运行时加载
CommonJS 的 require 是一个运行时函数调用,可以出现在任何位置,支持动态路径和条件加载:
const moduleName = condition ? './moduleA' : './moduleB'
const mod = require(moduleName)
if (process.env.NODE_ENV === 'development') {
const devTools = require('./devTools')
devTools.init()
}这种灵活性使得静态分析工具无法在编译时确定模块依赖关系,也是 CommonJS 不利于 Tree Shaking 的根本原因。
循环引用处理
CommonJS 对循环引用采用部分加载策略。当模块 A 加载模块 B,而模块 B 又反过来加载模块 A 时,B 获取到的是 A 当前已执行部分的导出,而非完整导出:
exports.loaded = false
const b = require('./b')
exports.loaded = true
exports.b = bconst a = require('./a')
console.log(a.loaded)
exports.fromB = true执行 node a.js 时的过程:
- 开始加载 A,设置
a.exports.loaded = false - 遇到
require('./b'),暂停 A 的执行,开始加载 B - B 中
require('./a')获取到 A 当前的 exports:{ loaded: false } - B 执行完毕,返回 B 的 exports
- 回到 A 继续执行,设置
a.exports.loaded = true
这种机制避免了无限循环,但可能导致获取到不完整的模块导出——这是一个需要警惕的陷阱。
ESM 原理
静态分析
ESM 的 import/export 声明必须出现在模块顶层,不能嵌套在 if、for、函数等块级作用域中。这使得引擎可以在编译阶段(代码执行前)就确定所有模块的依赖关系。
import { foo } from './foo.js'
if (condition) {
import { bar } from './bar.js'
}静态结构带来的核心优势:
- 编译时即可构建完整的模块依赖图
- 支持 Tree Shaking 消除未使用代码
- 支持循环引用的正确处理(基于绑定而非拷贝)
- 可以进行静态类型检查
值引用(Live Binding)
ESM 导出的是值的实时绑定(Live Binding),而非拷贝。外部模块引入的变量始终指向源模块中的原始绑定:
export let count = 0
export function increment() {
count++
}import { count, increment } from './counter.js'
console.log(count)
increment()
console.log(count)第二次 console.log(count) 输出 1,因为 count 是对源模块变量的实时引用。
这背后的机制是:ESM 导出的不是值本身,而是一个间接引用(Indirect Export)。规范中使用 Module Record 的 [[Bindings]] 来维护导入模块到导出模块之间变量的映射关系。
需要注意:导入的绑定是只读的,不能在导入方重新赋值:
import { count } from './counter.js'
count = 10编译时确定依赖
ESM 的模块解析分为三个阶段:
1. 构建(Construction)
从入口模块开始,解析所有 import 声明,递归获取依赖模块的源代码,建立模块记录(Module Record)。此阶段不执行任何代码。
2. 实例化(Instantiation)
为所有模块创建模块环境记录(Module Environment Record),将导出的变量名分配内存空间(但不赋值),建立导入与导出之间的绑定关系。
3. 求值(Evaluation)
按照深度优先、后序的顺序执行模块代码,填充变量的实际值。
入口模块
├── import A
│ ├── import C ← C 最先求值
│ └── import D ← D 其次
└── import B ← B 再次
← 入口模块最后求值这种三阶段设计使得 ESM 能够在代码执行前就完成所有绑定的连接,从而正确处理循环引用——即使模块尚未执行完毕,绑定本身已经建立。
import() 动态导入
ES2020 正式引入 import() 动态导入语法,返回一个 Promise,可以在任何位置使用:
const loadModule = async (modulePath) => {
const module = await import(modulePath)
return module.default
}
button.addEventListener('click', async () => {
const { Chart } = await import('./chart.js')
const chart = new Chart('#container')
chart.render(data)
})
const routes = {
'/home': () => import('./pages/Home.js'),
'/about': () => import('./pages/About.js'),
'/dashboard': () => import('./pages/Dashboard.js'),
}import() 的关键特性:
- 运行时执行,可以使用动态路径
- 返回 Promise,加载失败可通过
.catch()捕获 - Webpack/Vite 等打包工具会将
import()作为代码分割点,生成独立 chunk - 可与
React.lazy、Vue 异步组件等框架 API 无缝结合
CommonJS vs ESM 核心差异
| 维度 | CommonJS | ESM |
|---|---|---|
| 加载时机 | 运行时加载 | 编译时静态分析 |
| 导出机制 | 值拷贝(原始类型) | 值引用(Live Binding) |
| 语法位置 | require 可出现在任意位置 | import 必须在模块顶层 |
| 动态导入 | require(variable) 原生支持 | import() 返回 Promise |
| this 指向 | this 指向 module.exports | this 是 undefined |
| 循环引用 | 返回已执行部分的导出(可能不完整) | 通过 Live Binding 正确处理 |
| 文件扩展名 | .js(默认) / .cjs | .mjs 或 package.json 中 "type": "module" |
| Tree Shaking | 不支持(动态特性阻碍静态分析) | 原生支持 |
| 顶层 await | 不支持 | 支持(ES2022) |
__filename / __dirname | 原生可用 | 不可用,需通过 import.meta.url 替代 |
Tree Shaking 原理
Tree Shaking 是一种基于 ESM 静态结构的死代码消除(Dead Code Elimination, DCE) 技术。名称来源于"摇晃树木让枯叶掉落"的比喻——将未被引用的导出从最终产物中移除。
DCE 基础
传统的 DCE 由压缩工具(如 Terser/UglifyJS)执行,它能消除以下代码:
- 不可达代码(
return后的语句) - 执行结果不被使用的代码
- 只写不读的变量
function unused() {
return 'never called'
}
function used() {
return 'called'
}
const result = used()
if (false) {
console.log('dead code')
}Tree Shaking 在 DCE 的基础上更进一步:它利用 ESM 的静态 import/export 结构,在模块级别判断哪些导出从未被其他模块引用,从而将整个未使用的导出及其相关代码移除。
副作用标记
Tree Shaking 的核心难点在于副作用(Side Effects)。如果一个模块在导入时会执行副作用代码(修改全局变量、注册事件、执行 polyfill 等),即使没有使用其导出,也不能安全地移除。
export function pure() {
return 42
}
window.__INITIALIZED__ = true
Array.prototype.customMethod = function () {}上面的模块即使 pure 未被使用,也不能被 Tree Shaking 移除,因为导入该模块会产生副作用。
sideEffects 字段
package.json 中的 sideEffects 字段允许库作者向打包工具声明模块的副作用情况:
{
"name": "my-library",
"sideEffects": false
}"sideEffects": false 表示包内所有模块都是纯的,未使用的导出可以安全移除。
也可以指定哪些文件有副作用:
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js",
"./src/register-global.js"
]
}CSS 文件通常被标记为有副作用,因为它们的导入本身就是有意义的(注入样式)。
实践建议
确保代码能被有效 Tree Shaking 的关键原则:
- 使用 ESM 语法发布(提供
module或exports字段指向 ESM 产物) - 避免模块顶层副作用
- 优先使用具名导出而非默认导出(便于打包工具分析引用关系)
- 正确配置
sideEffects字段 - 避免重导出整个模块(
export * from './xxx'),尽量使用具名重导出
Node.js 中 CJS 与 ESM 互操作
Node.js 同时支持两种模块系统,但它们的互操作存在一些限制。
模块类型判定
Node.js 通过以下规则确定文件的模块类型:
.cjs→ CommonJS.mjs→ ESM.js→ 取决于最近的package.json中的"type"字段"type": "module"→ ESM"type": "commonjs"或缺省 → CommonJS
ESM 中加载 CJS
ESM 可以通过 import 加载 CJS 模块,但有限制:
import cjsModule from './lib.cjs'
import { namedExport } from './lib.cjs'Node.js 会将 CJS 模块的 module.exports 作为 ESM 的默认导出。从 Node.js v16.x 开始,Node.js 会尝试对 CJS 模块进行静态分析以提取具名导出,但这不总是可靠的。
CJS 中加载 ESM
CJS 不能同步 require ESM 模块(因为 ESM 支持顶层 await,而 require 是同步的):
const esmModule = require('./lib.mjs')替代方案是使用动态 import():
async function loadESM() {
const { default: esmModule } = await import('./lib.mjs')
return esmModule
}从 Node.js v22.x 开始,require() 在特定条件下可以加载同步的 ESM 模块(即不包含顶层 await 的 ESM),这是通过 --experimental-require-module 标志启用的。
双模式发布(Dual Package)
为了让包同时支持 CJS 和 ESM 消费者,可以使用 package.json 的 exports 字段实现条件导出:
{
"name": "my-package",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts"
},
"./utils": {
"import": "./dist/esm/utils.js",
"require": "./dist/cjs/utils.cjs"
}
}
}双包危险(Dual Package Hazard):如果一个包同时被 CJS 和 ESM 方式引入,可能会创建两个独立的模块实例,导致 instanceof 检查失败、单例被破坏等问题。解决方案之一是使用 CJS 作为核心实现,ESM 入口作为薄封装层重新导出:
import cjsModule from '../cjs/index.cjs'
export const { featureA, featureB } = cjsModule
export default cjsModule打包工具如何处理模块
Rollup
Rollup 从设计之初就以 ESM 为核心,其处理方式:
模块合并(Scope Hoisting):Rollup 将所有模块的代码提升到同一个作用域中,消除模块边界。这意味着最终产物中没有 require/define 等模块加载器代码,体积更小、运行更快。
export const add = (a, b) => a + b
export const subtract = (a, b) => a - bimport { add } from './math.js'
console.log(add(1, 2))Rollup 打包结果:
const add = (a, b) => a + b
console.log(add(1, 2))subtract 被 Tree Shaking 移除,模块边界完全消失。
对 CJS 的处理:Rollup 原生不支持 CommonJS,需要通过 @rollup/plugin-commonjs 插件将 CJS 转换为 ESM 后再处理。
Webpack
Webpack 的设计哲学是兼容一切模块格式,其处理方式:
模块包装(Module Wrapping):Webpack 将每个模块包装在一个函数中,通过自实现的 __webpack_require__ 模块加载器管理依赖:
var __webpack_modules__ = {
'./src/math.js': function (module, __webpack_exports__, __webpack_require__) {
__webpack_require__.d(__webpack_exports__, {
add: function () { return add }
})
const add = (a, b) => a + b
const subtract = (a, b) => a - b
},
'./src/index.js': function (module, __webpack_exports__, __webpack_require__) {
var _math = __webpack_require__('./src/math.js')
console.log((0, _math.add)(1, 2))
}
}从 Webpack 3 开始引入 ModuleConcatenationPlugin(即 Scope Hoisting),对于 ESM 模块可以实现类似 Rollup 的模块合并优化。
对比
| 维度 | Rollup | Webpack |
|---|---|---|
| 设计理念 | ESM 优先 | 通用兼容 |
| 默认行为 | Scope Hoisting | Module Wrapping |
| Tree Shaking | 原生精确 | 需要 ESM + 配置 |
| CJS 支持 | 需要插件 | 原生支持 |
| 代码分割 | 基于 import() + output.manualChunks | 基于 import() + SplitChunksPlugin |
| 产物体积 | 更小(无运行时开销) | 稍大(包含模块加载器) |
| 适用场景 | 类库打包 | 应用打包 |
| HMR | 需要插件 | 原生支持 |
| 生态 | 精简 | 庞大(loader/plugin 生态) |
现代趋势
Vite 在开发环境利用浏览器原生 ESM 实现零打包启动,生产环境使用 Rollup 打包。esbuild 以 Go 语言实现,提供极快的打包速度。Rspack 以 Rust 实现,兼容 Webpack API 的同时大幅提升构建性能。
这些工具的共同趋势是:以 ESM 为基石,通过编译型语言提升性能。理解模块化原理,是掌握这些工具行为的基础。