← Frontend

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 的区别?

参见上面第七章对比表,回答时按以下四个核心维度:

  1. 加载时机: CJS 运行时 / ESM 编译时
  2. 同步异步: CJS 同步 / ESM 异步
  3. 值的处理: CJS 值拷贝 / ESM 实时绑定
  4. 是否支持 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 的静态结构分析:

  1. 打包工具(Webpack/Rollup)扫描所有 import/export
  2. 标记出"被引用"的导出
  3. 删除"未被引用"的导出和相关代码
  4. 输出精简后的 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 工程化的发展史 —— 从"能用"到"好用",从"运行时"到"编译时",从"全量"到"按需"。理解了模块化,就理解了现代前端工具链的底层逻辑。