JS模块化
模块化演进历程:IIFE、CommonJS、AMD/CMD、UMD、ES Modules 对比,Tree-shaking 原理
模块化是大型 JS 工程的基石。本笔记按演进历程梳理:从无模块时代到 ESM,每种方案的原理、用法、优缺点全覆盖。
目录
| 章节 | 内容 |
|---|---|
| 一 | 为什么需要模块化 |
| 二 | 无模块时代的"伪方案" |
| 三 | CommonJS(CJS) |
| 四 | AMD / CMD |
| 五 | UMD(通用模块) |
| 六 | ES Modules(ESM) |
| 七 | CJS vs ESM 深度对比 |
| 八 | 工程化与构建工具 |
| 九 | Module Federation(微前端) |
| 十 | 面试高频考点 |
一、为什么需要模块化
1. 没有模块化的世界
早期 JS 全靠 <script> 标签顺序加载,所有变量都在全局作用域下。
<!-- 远古写法:每个 script 之间没有任何隔离 -->
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="business.js"></script>
<script src="main.js"></script>
2. 暴露出的核心问题
| 问题 | 说明 |
|---|---|
| 全局污染 | 所有变量挂在 window 上,容易命名冲突 |
| 依赖混乱 | script 顺序错了就报错,人工维护成本高 |
| 复用困难 | 想复用一段代码必须复制粘贴 |
| 无法按需加载 | 一次性全部下载,首屏性能差 |
// 问题示例:两个文件都定义了 user,后者覆盖前者
// utils.js
var user = { name: 'Alice' }
// business.js
var user = { name: 'Bob' } // 静悄悄覆盖了 utils.js 的 user
console.log(user.name) // 'Bob' —— 出现 bug 都找不到原因
3. 模块化要解决的核心诉求
- 作用域隔离: 每个模块有自己的作用域,不污染全局
- 依赖管理: 模块自己声明依赖,引擎自动加载
- 可复用: 一处编写,处处导入
- 按需加载: 需要时再加载,优化性能
二、无模块时代的"伪方案"
在 CommonJS 出现之前,开发者用一些约定俗成的写法模拟模块化。
1. 命名空间模式
把所有东西塞进一个全局对象,减少冲突。
// 把所有方法挂到 MyApp 命名空间下
var MyApp = MyApp || {}
MyApp.utils = {
formatDate: function(d) { /* ... */ },
parseUrl: function(u) { /* ... */ }
}
MyApp.business = {
login: function() { /* ... */ }
}
// 使用
MyApp.utils.formatDate(new Date())
⚠️ 缺陷: 变量依然在 MyApp 下,容易被外部直接修改;无法隐藏私有变量。
2. IIFE(立即执行函数表达式)
利用函数作用域实现私有变量。
// 经典 IIFE 模块模式
var Counter = (function() {
// 私有变量,外部无法访问
var count = 0
// 返回的对象就是模块的"公开 API"
return {
increment: function() { return ++count },
decrement: function() { return --count },
getCount: function() { return count }
}
})()
Counter.increment() // 1
Counter.increment() // 2
console.log(Counter.count) // undefined ✅ 私有
console.log(Counter.getCount()) // 2
3. IIFE + 依赖注入
把依赖作为参数传入,显式声明依赖关系。
// jQuery 插件常用的写法
(function($, window) {
// 在这里使用 $ 和 window,与外部隔离
$.fn.myPlugin = function() {
return this.each(function() { /* ... */ })
}
})(jQuery, window)
💡 总结: IIFE 是模块化的"思想雏形",所有现代模块方案本质都在解决同样的问题。
三、CommonJS(CJS)
1. 背景
CommonJS 是 Node.js 采用的模块规范,目标是让 JS 也能像 Python、Ruby 一样写后端。
- 文件即模块,一个
.js文件就是一个独立作用域 - 用
require()导入,用module.exports导出 - 同步加载(因为后端文件在本地磁盘,加载快)
2. 基本用法
// math.js —— 定义模块
function add(a, b) {
return a + b
}
function sub(a, b) {
return a - b
}
// 方式 1:挂到 exports 上(逐个导出)
exports.add = add
exports.sub = sub
// 方式 2:整体替换 module.exports(单一导出)
module.exports = { add, sub }
// main.js —— 使用模块
const math = require('./math.js')
console.log(math.add(1, 2)) // 3
// 也可以解构导入
const { add, sub } = require('./math.js')
console.log(sub(5, 2)) // 3
3. exports vs module.exports
🚨 重点:
exports本质是module.exports的一个引用,不能直接给 exports 赋值。
// 初始状态:exports === module.exports === {}
// ✅ 正确:在原对象上添加属性
exports.foo = 'bar' // module.exports.foo = 'bar'
// ❌ 错误:断开了引用关系,外部拿到的还是空对象
exports = { foo: 'bar' } // 仅修改了局部变量
// ✅ 正确:直接给 module.exports 赋值
module.exports = { foo: 'bar' }
4. CJS 模块加载机制
每个 CJS 模块加载时,Node 实际上把代码包了一层函数:
// Node 内部实际执行的样子(简化版)
(function(exports, require, module, __filename, __dirname) {
// 你的模块代码在这里
const x = 1
module.exports = { x }
})
这就是为什么模块里可以直接用 __filename、__dirname 这些"全局变量"。
5. CJS 的核心特性
| 特性 | 说明 |
|---|---|
| 同步加载 | require 是同步阻塞的 |
| 运行时加载 | 在代码执行时才解析依赖 |
| 值的拷贝 | 导出的是值的副本,不是引用 |
| 有缓存 | 同一模块多次 require 只执行一次,后续从缓存读 |
// 值的拷贝示例:counter.js
let count = 0
function increment() { count++ }
module.exports = { count, increment }
// main.js
const { count, increment } = require('./counter.js')
console.log(count) // 0
increment()
console.log(count) // 0 ❗ 还是 0,因为是值拷贝
// 想拿到最新值,要导出 getter
// counter.js
let count = 0
module.exports = {
get count() { return count }, // 用 getter 实时返回
increment: () => count++
}
6. CJS 缓存机制
// a.js 只会被执行一次
console.log('a.js loaded')
module.exports = { time: Date.now() }
// main.js
const a1 = require('./a.js') // 打印 'a.js loaded'
const a2 = require('./a.js') // 不打印,从缓存读
console.log(a1 === a2) // true,完全同一个对象
// 清除缓存
delete require.cache[require.resolve('./a.js')]
四、AMD / CMD
背景: 浏览器环境下不能用同步加载(网络请求太慢),需要异步方案。
1. AMD(Asynchronous Module Definition)
AMD 由 RequireJS 推广,是浏览器端最早的异步模块方案。
核心思想: 依赖前置 + 提前执行
// 定义模块
define('myModule', ['jquery', 'lodash'], function($, _) {
// 所有依赖加载完后才执行
return {
init: function() {
_.each([1, 2, 3], function(n) { console.log(n) })
}
}
})
// 使用模块
require(['myModule'], function(myModule) {
myModule.init()
})
2. CMD(Common Module Definition)
CMD 由国内的 SeaJS(玉伯)提出,更接近 CJS 的写法。
核心思想: 依赖就近 + 延迟执行
define(function(require, exports, module) {
// 用到时才 require,跟 CJS 风格一致
const $ = require('jquery')
$('#btn').click()
// 异步 require
require.async('./other', function(other) {
other.doSomething()
})
exports.foo = 'bar'
})
3. AMD vs CMD 对比
| 维度 | AMD(RequireJS) | CMD(SeaJS) |
|---|---|---|
| 依赖处理 | 依赖前置 | 依赖就近 |
| 执行时机 | 提前执行(全加载完就执行) | 延迟执行(用到才执行) |
| 写法风格 | 接近函数式 | 接近 CommonJS |
| 流行程度 | 国际主流 | 国内一度流行 |
⚠️ 现状: 随着 ESM 普及和 Webpack 等打包工具的兴起,AMD/CMD 基本退出历史舞台,只在维护老项目时会接触。
五、UMD(Universal Module Definition)
UMD 不是一种新规范,而是兼容方案:让一份代码同时支持 CJS、AMD、浏览器全局变量。
// 经典 UMD 模板
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// ① AMD 环境
define(['jquery'], factory)
} else if (typeof module === 'object' && module.exports) {
// ② CommonJS 环境
module.exports = factory(require('jquery'))
} else {
// ③ 浏览器全局变量
root.MyLib = factory(root.jQuery)
}
}(typeof self !== 'undefined' ? self : this, function($) {
// 真正的模块代码
return {
name: 'MyLib',
init: function() { /* ... */ }
}
}))
💡 适用场景: 编写 npm 库时常用 UMD 打包,保证库能在各种环境下使用(如 jQuery、Lodash 早期版本)。
六、ES Modules(ESM)
1. 背景
ES Modules 是 ES6 (2015) 正式引入的官方模块规范,现已是浏览器和 Node 双端支持的标准。
2. 基本用法
导出(export)
// math.js
// 方式 1:命名导出(可以多个)
export const PI = 3.14
export function add(a, b) { return a + b }
export class Calculator { /* ... */ }
// 方式 2:统一导出
const sub = (a, b) => a - b
const mul = (a, b) => a * b
export { sub, mul }
// 方式 3:导出时重命名
export { sub as subtract, mul as multiply }
// 方式 4:默认导出(每个模块只能有一个)
export default function divide(a, b) { return a / b }
导入(import)
// main.js
// 1. 命名导入(必须用花括号,名字要对应)
import { PI, add } from './math.js'
// 2. 重命名
import { add as plus } from './math.js'
// 3. 默认导入(可以随便起名)
import divide from './math.js'
// 4. 默认 + 命名混合
import divide, { PI, add } from './math.js'
// 5. 全部导入为命名空间
import * as math from './math.js'
math.add(1, 2)
math.default(10, 2) // 默认导出在 .default 上
// 6. 副作用导入(只执行不获取值)
import './polyfill.js'
3. 在浏览器中使用 ESM
<!-- 必须加 type="module" -->
<script type="module" src="./main.js"></script>
<!-- 或者内联 -->
<script type="module">
import { add } from './math.js'
console.log(add(1, 2))
</script>
⚠️ 注意: 浏览器中导入路径必须是完整的 URL 或相对路径(以
./或/开头),不能省略.js后缀。
4. ESM 的核心特性
| 特性 | 说明 |
|---|---|
| 静态分析 | import/export 必须在顶层,编译时就能确定依赖 |
| 异步加载 | 浏览器自动并行下载依赖 |
| 值的引用(绑定) | 导出的是实时绑定,不是拷贝 |
| 严格模式 | 模块默认运行在严格模式下 |
| 单例 | 同一模块只会执行一次 |
5. 实时绑定 vs 值拷贝
🚨 关键差异: 这是 ESM 和 CJS 最大的区别。
// counter.js (ESM)
export let count = 0
export function increment() { count++ }
// main.js
import { count, increment } from './counter.js'
console.log(count) // 0
increment()
console.log(count) // 1 ✅ 实时获取最新值
// 但是不能在导入方修改它(只读绑定)
import { count } from './counter.js'
count++ // ❌ TypeError: Assignment to constant variable
6. 动态导入 import()
ES2020 引入 import() 函数式语法,返回 Promise,实现按需加载。
// 静态 import 必须在顶层,且路径是字符串字面量
// import math from './math.js' // 一上来就加载
// 动态 import:可以放在任何地方,路径可以是变量
button.addEventListener('click', async () => {
// 真正点击时才下载这个模块
const { default: heavyLib } = await import('./heavy-lib.js')
heavyLib.run()
})
// 条件加载
if (userLang === 'zh') {
import('./locales/zh.js').then(m => use(m))
} else {
import('./locales/en.js').then(m => use(m))
}
💡 实战: 路由懒加载、组件懒加载、Webpack code splitting 都靠 import() 实现。
// React 路由懒加载经典写法
const Home = React.lazy(() => import('./pages/Home'))
const About = React.lazy(() => import('./pages/About'))
7. 在 Node.js 中使用 ESM
Node 从 12 开始支持 ESM,有两种启用方式:
// 方式 1:package.json 设置 type
{
"type": "module"
}
// 方式 2:文件后缀用 .mjs
// foo.mjs —— Node 当作 ESM 处理
// foo.cjs —— Node 当作 CommonJS 处理
⚠️ 注意: ESM 中没有
__dirname、__filename、require,需要用import.meta.url替代。
// ESM 中获取当前文件路径的写法
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
8. import.meta 详解
import.meta 是 ESM 内置的元数据对象,提供当前模块的上下文信息:
// ① import.meta.url:当前模块文件的完整 URL
console.log(import.meta.url)
// 浏览器:'https://example.com/src/utils.js'
// Node.js:'file:///Users/shea/project/src/utils.js'
// ② Vite 项目中的 import.meta.env(构建时注入环境变量)
console.log(import.meta.env.MODE) // 'development' / 'production'
console.log(import.meta.env.VITE_API_URL) // .env 中定义的变量(必须 VITE_ 前缀)
console.log(import.meta.env.DEV) // boolean,是否开发模式
console.log(import.meta.env.PROD) // boolean,是否生产模式
// ③ import.meta.glob(Vite 专有):批量匹配文件,返回懒加载函数
const pages = import.meta.glob('./pages/**/*.vue')
// 结果:{ './pages/Home.vue': () => import('./pages/Home.vue'), ... }
// 立即加载版(eager: true)
const icons = import.meta.glob('./assets/icons/*.svg', { eager: true })
// 实战:自动注册所有页面路由
const routes = Object.entries(pages).map(([path, loader]) => ({
path: path.replace('./pages', '').replace('.vue', ''),
component: loader, // 懒加载路由
}))
| 属性 | 适用环境 | 作用 |
|---|---|---|
import.meta.url |
所有 ESM 环境 | 当前模块 URL,用于计算相对路径 |
import.meta.env |
Vite 项目 | 环境变量(VITE_ 前缀才暴露给客户端) |
import.meta.glob |
Vite 项目 | 批量导入匹配文件,支持懒加载 |
import.meta.resolve |
现代浏览器 | 解析模块的绝对 URL |
七、CJS vs ESM 深度对比
🚨 面试高频: 这个表必背。
| 对比维度 | CommonJS | ES Modules |
|---|---|---|
| 语法 | require / module.exports |
import / export |
| 加载时机 | 运行时加载 | 编译时(静态)分析 |
| 加载方式 | 同步 | 异步 |
| 导出值 | 值的拷贝 | 值的实时绑定 |
| this 指向 | 当前模块 | undefined |
| 能否动态路径 | ✅ require 路径可以是变量 | ❌ import 路径必须字面量(用 import() 才能动态) |
| Tree-shaking | ❌ 不支持(动态特性) | ✅ 支持(静态分析) |
| 循环依赖 | 返回未完成的导出对象 | 通过绑定机制更优雅 |
| 主要环境 | Node.js(传统) | 浏览器 + Node.js(现代) |
静态 vs 动态的本质
// CJS:require 是函数,可以在任何位置、用任何字符串
if (condition) {
const mod = require(`./modules/${name}`)
}
// ESM:import 是关键字,只能在顶层,路径必须字面量
import mod from './modules/foo' // ✅
// import mod from `./modules/${name}` // ❌ 语法错误
// if (x) import mod from './a' // ❌ 语法错误
// 想动态就用 import()
const mod = await import(`./modules/${name}`) // ✅
💡 为什么 ESM 静态分析重要? 因为编译时就知道所有依赖,打包工具可以做 Tree-shaking(摇掉没用到的代码),减小包体积。
循环依赖处理
// a.js
import { b } from './b.js'
export const a = 'a value'
console.log('in a, b =', b)
// b.js
import { a } from './a.js'
export const b = 'b value'
console.log('in b, a =', a)
// 入口 main.js
import './a.js'
// CJS 中会得到部分导出(undefined),容易踩坑
// ESM 通过实时绑定,只要不在初始化时立即用,就能正确解析
八、工程化与构建工具
1. 构建工具如何处理模块
现代开发我们写 ESM,但浏览器兼容性、依赖管理、按需加载都靠构建工具搞定。
| 工具 | 定位 | 模块处理 |
|---|---|---|
| Webpack | 老牌打包器 | 支持 CJS/ESM/AMD 互转,产出 bundle |
| Rollup | 库打包优选 | ESM 优先,Tree-shaking 强 |
| Vite | 现代开发利器 | 开发用浏览器原生 ESM,生产用 Rollup |
| esbuild / SWC | 极速编译器 | Go/Rust 写的,速度数十倍于 Babel |
| Parcel | 零配置 | 自动处理依赖 |
2. Vite 的模块加载哲学
Vite 在开发模式下完全利用浏览器原生 ESM:
浏览器请求 main.js
↓
Vite 拦截,转换 .vue/.tsx 为浏览器能识别的 JS
↓
浏览器解析 import,自动请求依赖
↓
按需加载,无需打包,启动飞快
// 开发模式下浏览器实际看到的代码
import { createApp } from '/@modules/vue.js' // Vite 重写裸模块路径
import App from '/src/App.vue' // .vue 被实时编译为 JS
createApp(App).mount('#app')
3. package.json 模块字段
{
"name": "my-lib",
"type": "module", // 默认按 ESM 解析 .js
"main": "./dist/index.cjs", // CJS 入口(老消费者)
"module": "./dist/index.mjs", // ESM 入口(打包工具优先用)
"types": "./dist/index.d.ts", // TypeScript 类型
"exports": { // 现代化精确出口配置
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": "./dist/utils.mjs"
}
}
💡 提示: 现代库通常同时提供 CJS 和 ESM 两种构建,通过
exports字段精确控制不同环境的入口。
4. Tree-shaking 实战
// utils.js —— 导出多个函数
export function used() { return 'I am used' }
export function unused() { return 'I am NOT used' }
export function alsoUnused() { return 'me neither' }
// main.js —— 只用了 used
import { used } from './utils.js'
console.log(used())
// 打包后(Tree-shaking 生效):
function used() { return 'I am used' }
console.log(used())
// unused 和 alsoUnused 被完全摇掉 ✅
⚠️ 注意: Tree-shaking 只对 ESM 有效;有副作用的代码(如修改全局变量)不会被摇掉,可在 package.json 中标记
"sideEffects": false帮助优化。
九、面试高频考点
Q1. CommonJS 和 ES Modules 的区别?
参见上面第七章对比表,回答时按以下四个核心维度:
- 加载时机: CJS 运行时 / ESM 编译时
- 同步异步: CJS 同步 / ESM 异步
- 值的处理: CJS 值拷贝 / ESM 实时绑定
- 是否支持 Tree-shaking: CJS 不支持 / ESM 支持
Q2. 为什么 import 必须放在顶层?
为了静态分析。编译器在不执行代码的情况下就要确定所有依赖关系,这样才能:
- 提前并行下载依赖
- 进行 Tree-shaking
- 检测循环依赖
// ❌ 编译报错
if (cond) {
import x from './x.js'
}
// ✅ 需要动态就用 import()
if (cond) {
const x = await import('./x.js')
}
Q3. exports 和 module.exports 区别?
// 一句话:exports 是 module.exports 的引用,直接给 exports 赋值会断开引用
exports.foo = 'bar' // ✅ 等价于 module.exports.foo = 'bar'
exports = { foo: 'bar' } // ❌ 只改局部变量
module.exports = { foo } // ✅ 重新赋值要用 module.exports
Q4. ESM 中的 this 指向什么?
// 浏览器/Node ESM 中
console.log(this) // undefined
// CJS 中
console.log(this) // {} (即 module.exports)
// 普通 script 中
console.log(this) // window
Q5. 动态 import() 的应用场景?
| 场景 | 例子 |
|---|---|
| 路由懒加载 | Vue Router / React Router 的异步组件 |
| 条件加载 | 不同语言、不同浏览器加载不同 polyfill |
| 按需加载大型库 | 用户点击后才加载图表库、富文本编辑器 |
| 代码分割 | Webpack 自动 splitChunks |
Q6. Tree-shaking 的原理是什么?
基于 ESM 的静态结构分析:
- 打包工具(Webpack/Rollup)扫描所有 import/export
- 标记出"被引用"的导出
- 删除"未被引用"的导出和相关代码
- 输出精简后的 bundle
// 前提条件
// ① 必须用 ESM 语法(import/export)
// ② 代码无副作用(或正确标记 sideEffects)
// ③ 生产模式打包(开发模式通常不开)
Q7. CJS 模块循环依赖会怎样?
// a.js
console.log('a starts')
exports.done = false
const b = require('./b.js')
console.log('in a, b.done =', b.done)
exports.done = true
// b.js
console.log('b starts')
exports.done = false
const a = require('./a.js')
// 此时 a.js 还没执行完,只能拿到 exports = { done: false }
console.log('in b, a.done =', a.done)
exports.done = true
输出:
a starts
b starts
in b, a.done = false ← 拿到的是不完整的导出
in a, b.done = true
💡 ESM 更优雅: 由于是引用绑定,等到真正使用时,值已经是更新后的最新值,大多数情况不会出问题。
九、Module Federation(模块联邦)
Module Federation 是 Webpack 5 引入的微前端核心特性,允许不同独立构建的应用在运行时动态共享模块,无需重新打包。
核心概念
Host(主应用/消费方)
↓ 运行时动态加载
Remote(子应用/提供方)
→ 暴露模块供外部使用
→ 共享依赖(如 React、Vue),避免重复加载
配置示例(Webpack 5)
// ===== 子应用 shop(提供方)=====
// webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'shop', // 子应用名称
filename: 'remoteEntry.js', // 入口文件名(主应用通过这个加载)
exposes: {
'./ProductList': './src/components/ProductList', // 暴露组件
'./Cart': './src/components/Cart',
},
shared: {
react: { singleton: true }, // React 共享单例,避免版本冲突
'react-dom': { singleton: true },
},
})
]
}
// ===== 主应用 shell(消费方)=====
// webpack.config.js
new ModuleFederationPlugin({
name: 'shell',
remotes: {
// 声明远程应用:格式 "远程名称@URL/remoteEntry.js"
shop: 'shop@http://localhost:3001/remoteEntry.js',
cart: 'cart@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
})
// 主应用中像使用本地模块一样引用远程组件
import React, { lazy, Suspense } from 'react'
// 动态加载远程组件(自动从 http://localhost:3001 下载)
const ProductList = lazy(() => import('shop/ProductList'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProductList />
</Suspense>
)
}
与传统方案对比
| 方案 | 代码共享 | 运行时动态 | 独立部署 | 版本隔离 |
|---|---|---|---|---|
| iframe | ❌ 完全隔离 | ✅ | ✅ | ✅ |
| npm 包 | ✅ | ❌ 需重新构建 | ❌ | ❌ |
| Module Federation | ✅ | ✅ 运行时按需加载 | ✅ | ✅ 可配置 |
💡 适用场景: 大型企业级应用拆分、多团队独立开发和部署、跨项目复用组件库(共享 UI 组件而无需发 npm 包)。
📌 模块化演进总览
[远古]
全局变量 + script 标签
│
▼ 解决全局污染
[过渡]
命名空间 / IIFE
│
▼ 走向规范
┌──────┴──────┐
│ │
▼ ▼
CommonJS AMD/CMD
(Node) (浏览器)
│ │
└─────┬───────┘
▼ 兼容方案
UMD
│
▼ 官方统一
[现代]
ES Modules
│
▼ 工程化
Vite / Webpack / Rollup
+ Tree-shaking
+ Code Splitting
+ 动态 import()
🎯 总结: 模块化的演进就是 JS 工程化的发展史 —— 从"能用"到"好用",从"运行时"到"编译时",从"全量"到"按需"。理解了模块化,就理解了现代前端工具链的底层逻辑。