Skip to main content

模块化

模块化是前端工程化的基石

“前端模块化经历了从早期的 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 默认的模块规范。每个文件就是一个模块,拥有独立的作用域。

  • 核心变量moduleexportsrequire
  • 特点
    1. 同步加载require 是同步执行的。因为 Node.js 在服务端,文件都在本地磁盘,读取极快。
    2. 值拷贝:导出的值是被缓存的浅拷贝
      • 注意:对于基本数据类型,一旦输出,模块内部再发生变化,不会影响外部拿到的值。但如果是引用类型(对象/数组),由于导出的是内存地址的浅拷贝,外部修改该对象的属性会影响模块内部,反之亦然。这与 ESM 真正的“动态只读引用”有本质区别。ESM 的“只读”是指你不能重新给导入的变量赋值(如 import a from './a'; a = 1 会报错),但如果导入的是个对象,你依然可以修改对象的属性。两者在处理对象的属性修改时表现是一致的,区别在于对“变量本身的重新赋值”和“基本数据类型的动态同步”。
    3. 运行时加载require 可以在代码的任何地方(比如 if 语句中)动态调用。这也导致了打包工具无法在编译阶段确定模块的依赖关系。

三、 ES Module (ESM)

ECMAScript 官方提供的标准化模块方案,如今 Vite、Rollup 等现代打包工具的核心基石。

  • 核心关键字importexport
  • 特点
    1. 静态分析import 必须放在模块顶层,不能放在条件语句中。引擎在编译阶段(代码执行前)就能确定模块的依赖关系。
    2. 只读引用 (Live Binding):导出的不是值的拷贝,而是内存地址的引用。当导出模块内部变量发生变化时,导入模块拿到的值也会实时更新。同时,导入的变量是只读的(不能重新赋值)。
    3. 异步解析:支持在浏览器中通过 <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,工具可以轻易地遍历出所有的 importexport 节点,构建出一个全局的模块依赖图,从而标记出哪些导出变量在整个依赖图中“毫无访问痕迹”,最后在生成代码时将其丢弃。
  • 为什么必须是 ESM: 因为 ESM 的 importexport静态的(必须写在顶层,不能动态拼接模块名)。打包工具在生成 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 组件库中,更推荐使用命名导出