模块化
模块化是前端工程化的基石
“前端模块化经历了从早期的 IIFE 闭包,到 Node.js 主导的 CommonJS(同步、值拷贝、运行时),再到现在浏览器和工程化标准支持的 ES Module(异步解析、动态引用、编译时静态分析)的演进。”
一、 早期模块化方案
1. IIFE (立即调用的函数表达式)
早期 JavaScript 没有模块系统,为了避免全局变量污染,开发者通过 IIFE 利用函数作用域/闭包来封装私有变量,只暴露公共接口给全局对象(如 window)。
2. AMD 与 CMD
在 ESM 出现前,浏览器端没有像 Node.js 那样的同步文件读取机制,因此诞生了异步加载模块的规范:
- AMD (RequireJS):依赖前置,提前执行。
- CMD (SeaJS):就近依赖,延迟执行。 (这俩已被淘汰,面试被问到直接说“了解过但现在都是用 Webpack/Vite 打包 ESM”,不要过多纠缠)
二、 CommonJS (CJS)
Node.js 默认的模块规范。每个文件就是一个模块,拥有独立的作用域。
- 核心变量:
module、exports、require。 - 特点:
- 同步加载:
require是同步执行的。因为 Node.js 在服务端,文件都在本地磁盘,读取极快。 - 值拷贝:导出的值是被缓存的浅拷贝。
- 注意:对于基本数据类型,一旦输出,模块内部再发生变化,不会影响外部拿到的值。但如果是引用类型(对象/数组),由于导出的是内存地址的浅拷贝,外部修改该对象的属性会影响模块内部,反之亦然。这与 ESM 真正的“动态只读引用”有本质区别。ESM 的“只读”是指你不能重新给导入的变量赋值(如
import a from './a'; a = 1会报错),但如果导入的是个对象,你依然可以修改对象的属性。两者在处理对象的属性修改时表现是一致的,区别在于对“变量本身的重新赋值”和“基本数据类型的动态同步”。
- 注意:对于基本数据类型,一旦输出,模块内部再发生变化,不会影响外部拿到的值。但如果是引用类型(对象/数组),由于导出的是内存地址的浅拷贝,外部修改该对象的属性会影响模块内部,反之亦然。这与 ESM 真正的“动态只读引用”有本质区别。ESM 的“只读”是指你不能重新给导入的变量赋值(如
- 运行时加载:
require可以在代码的任何地方(比如if语句中)动态调用。这也导致了打包工具无法在编译阶段确定模块的依赖关系。
- 同步加载:
三、 ES Module (ESM)
ECMAScript 官方提供的标准化模块方案,如今 Vite、Rollup 等现代打包工具的核心基石。
- 核心关键字:
import、export。 - 特点:
- 静态分析:
import必须放在模块顶层,不能放在条件语句中。引擎在编译阶段(代码执行前)就能确定模块的依赖关系。 - 只读引用 (Live Binding):导出的不是值的拷贝,而是内存地址的引用。当导出模块内部变量发生变化时,导入模块拿到的值也会实时更新。同时,导入的变量是只读的(不能重新赋值)。
- 异步解析:支持在浏览器中通过
<script type="module">异步加载。
- 静态分析:
面试高频考点对比
1. CJS 与 ESM 的核心区别
| 特性 | CommonJS (CJS) | ES Module (ESM) |
|---|---|---|
| 加载阶段 | 运行时加载(动态) | 编译时静态分析(提前构建依赖图) |
| 导出形式 | 值拷贝(导出后原模块改变不影响当前模块) | 值引用 (Live Binding)(动态且只读的内存映射) |
this 指向 | 顶层 this 指向 module.exports 对象 | 顶层 this 严格绑定为 undefined |
| 动态引入 | 原生支持,可放在代码任何位置:require(path) | 需使用专门的 import() 函数实现动态导入 |
2. 如何解决循环引用问题?
- CommonJS 的循环引用:
因为是运行时同步执行,如果 A 引入了 B,B 又引入了 A。B 执行到引入 A 的那一行时,拿到的是 A 尚未执行完毕时,缓存下来的不完整
exports对象。这极易导致拿到的属性为undefined而报错。 - ES Module 的循环引用:
因为是只读引用,引擎在编译阶段就会构建出完整的“模块依赖图”。在真正执行代码获取变量时,只要保证该变量已经被初始化(比如利用
function的声明提升),就可以正常访问,完美处理循环引用。
3. Tree-Shaking 为什么依赖 ESM?
面试题:什么是 Tree-Shaking?为什么 CommonJS 很难做 Tree-Shaking?
- Tree-Shaking 原理:在打包过程中,通过分析代码,找出那些定义了但从未被使用过的死代码(Dead Code),并在最终的 Bundle 中将它们剔除,从而减小文件体积。
- 底层实现:打包工具(如 Webpack/Rollup)会将源码解析为 AST(抽象语法树)。基于 AST,工具可以轻易地遍历出所有的
import和export节点,构建出一个全局的模块依赖图,从而标记出哪些导出变量在整个依赖图中“毫无访问痕迹”,最后在生成代码时将其丢弃。
- 底层实现:打包工具(如 Webpack/Rollup)会将源码解析为 AST(抽象语法树)。基于 AST,工具可以轻易地遍历出所有的
- 为什么必须是 ESM:
因为 ESM 的
import和export是静态的(必须写在顶层,不能动态拼接模块名)。打包工具在生成 AST 编译阶段就能100%确定哪些模块和变量被引入了,哪些没有。 而 CommonJS 的require是动态的(比如require('./utils/' + type)),打包工具在进行 AST 分析时,无法在运行前猜到type变量的值到底是什么,为了安全起见,只能把所有可能用到的代码都打包进去,无法进行可靠的 Tree-Shaking。
4. export vs export default
export(命名导出):一个模块可以有多个,导入时必须使用相同的名字(或者通过as重命名),需要加花括号{ }。export default(默认导出):一个模块只能有一个,导入时不需要加花括号,可以随意起名。- 注意:
export default导出的往往是一个具体的值表达式,它在一定程度上会丢失部分“动态绑定”的特性,因此在追求极致 Tree-Shaking 的 UI 组件库中,更推荐使用命名导出。