1. 为何需要模块化?
在模块化概念诞生之前,前端代码的组织方式通常有以下几种:
- 全局函数模式:将不同的功能封装成不同的全局函数。污染全局命名空间,容易引起命名冲突。
- Namespace 模式:使用对象作为命名空间,减少全局变量。但本质仍是全局对象,外部可以直接修改内部数据,安全性低。
- IIFE 模式:立即执行函数表达式。通过函数作用域实现封装,暴露公共接口。是早期模块化的雏形,但依赖管理困难,需要手动保证加载顺序。
这些方式都存在致命缺陷:依赖关系不透明、难以管理、容易冲突、无法进行静态分析。。
2. CommonJS
CommonJS 规范的出现,首先是为了解决 JavaScript 在服务器端(Node.js)的模块化问题。它的设计哲学是同步加载,这在服务器磁盘 I/O 速度很快的场景下是非常合适的。
2.1 核心语法
- 导出模块 :使用
module.exports
或exports
- 导入模块 :使用
require('path')
javascript
// math.js - 定义一个模块
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
// 导出方式一:整体导出
module.exports = {
add,
multiply
};
// 导出方式二:逐个挂载到 exports 上
// exports.add = add;
// exports.multiply = multiply;
// main.js - 导入并使用模块
const math = require('./math.js'); // .js 后缀可省略
console.log(math.add(2, 3)); // 5
console.log(math.multiply(2, 3)); // 6
2.2 核心特性
- 同步加载 :
require()
语句是同步的,它会阻塞代码执行,直到模块被加载、执行并返回结果。 - 值拷贝(对于普通值):CommonJS 模块导出的是值的拷贝。一旦导出,模块内部的变化就不会影响已经导入的值(除非导出的是对象或数组,修改其属性会受影响,因为传递的是引用)。
- 运行时加载 :模块代码是在运行时才被加载和执行的。模块的依赖关系在代码执行到
require
语句时才能确定。 - 缓存机制 :同一个模块被多次
require
时,只会被执行一次,后续的require
会直接返回第一次执行结果的缓存,避免重复执行。
2.3 在前端浏览器中的困境
CommonJS 的同步加载特性在浏览器端遇到了巨大挑战。因为浏览器从服务器加载脚本是异步 的网络 I/O,如果使用同步 require
,会导致浏览器界面"假死",体验极差。
为了在浏览器端使用 CommonJS 规范的模块,就需要一些模块打包工具(Module Bundler) ,如 Browserify 和 webpack 。它们的工作方式是:在构建阶段,静态分析代码中的所有 require
调用,将所有依赖的模块代码"打包"合并成一个或少数几个 JavaScript 文件。这样,在浏览器运行时,所有模块都已经在同一个文件中,不再需要异步网络请求,从而"模拟"了同步加载的环境。
3. ES6 Modules
ECMAScript 2015(ES6)正式在语言层面引入了模块功能,俗称 ESM。它旨在成为浏览器和服务器通用的模块解决方案,是未来的终极标准。
3.1 核心语法
- 导出模块 :使用
export
/export default
- 导入模块 :使用
import ... from 'path'
javascript
// math.mjs 或 <script type="module"> 中的 .js 文件
// 命名导出 (Named Exports)
export function add(a, b) {
return a + b;
}
export const PI = 3.14159;
// 默认导出 (Default Export) - 一个模块只能有一个
export default function multiply(a, b) {
return a * b;
}
// main.mjs
// 导入命名导出 - 必须使用相同的名称,或者使用 as 重命名
import { add, PI } from './math.js';
// 导入默认导出 - 名称可以任意
import multiply from './math.js'; // 或者 import myMultiply from './math.js'
// 全部导入
import * as math from './math.js';
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6
console.log(math.PI); // 3.14159
3.2 核心特性
- 静态化 :这是 ESM 与 CommonJS 最根本的区别。ESM 的
import
和export
命令必须在模块的顶层,不能放在条件语句中。这种设计使得依赖关系在代码运行前(编译时)就已经确定,工具可以进行静态分析,实现 Tree Shaking(消除未引用代码)等优化。 - 异步加载:ESM 本身支持异步加载,这天然适合浏览器环境。
- 动态只读引用(Live Read-Only View) :ESM 导入的不是值的拷贝,而是值的只读引用。当导出模块中的值发生变化时,导入模块中看到的值也会随之变化。但是导入方不能直接修改这个值。
- 编译时加载:由于是静态的,模块的编译和链接发生在代码执行之前。
4. 关键差异示例:值的引用 vs 拷贝
javascript
// CommonJS
// counter.js
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };
// main.js
const { count, increment } = require('./counter');
console.log(count); // 0
increment();
console.log(count); // 0 (count 是原始值的拷贝,不会变)
// ES Modules
// counter.mjs
export let count = 0;
export function increment() {
count++;
}
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 (count 是对原变量的只读引用,原值变了这里也变)
5. 实践与应用
如今,纯粹使用任何一种方案的情况都比较少见,更多的是两者的融合与转换。
-
在 Node.js 中 :新版本的 Node.js 已经逐步支持原生 ESM。可以通过在
package.json
中设置"type": "module"
来让.js
文件被视为 ESM 模块,或者使用.mjs
扩展名。对于 CommonJS 模块(.cjs
),依然可以正常使用。 -
在浏览器中 :通过
<script type="module">
标签可以直接使用原生 ESM。现代浏览器都已支持。html<script type="module"> import { add } from './math.js'; console.log(add(1, 2)); </script>
-
在构建工具中(webpack, Vite, Rollup) :这是最常见的使用场景。开发者可以在源代码中自由地使用 ESM 语法(
import/export
)。构建工具会承担以下工作:- 模块转换:将各种模块格式(ESM, CommonJS, UMD, AMD)的第三方库统一转换和处理。
- 打包与优化:基于 ESM 的静态分析,进行极致的 Tree Shaking,删除未被导出的代码。
- 语法降级:将新的 ESM 语法转换为兼容旧浏览器的代码(通常转换为类似 IIFE 或 AMD 的模式)。
Vite 等新型构建工具更是利用浏览器原生 ESM 能力,在开发环境下不打包模块,直接按需提供源代码,实现了闪电般的冷启动和热更新。
6. 总结
-
CommonJS采用
require()
/module.exports
语法,专为服务器端设计,特点是同步加载和值拷贝 -
ES6模块则通过
import
/export
语法实现了官方标准化,支持编译时静态分析、异步加载和实时绑定。 -
ES6模块依赖关系在编译阶段确定,支持tree shaking优化;而CommonJS在运行时确定依赖。
-
ES6模块凭借静态结构和浏览器原生支持已成为现代前端开发的主流方案,同时Node.js也通过.mjs扩展名和package.json配置提供了对ES6模块的正式支持。
模块化编程的出现解决了这些痛点,它允许开发者将代码分割成独立的模块,每个模块具有明确的功能和接口。这种编程方式带来了诸多好处:
- 可维护性:模块可以独立开发、测试和更新
- 命名空间管理:避免全局变量污染,减少命名冲突
- 代码复用:模块可以在不同项目中重复使用
- 依赖管理:明确声明模块间的依赖关系