从早期的脚本标签加载,到现代的 ES Module,JavaScript 模块化走过了一条漫长而精彩的道路。理解这段历史,不仅能帮我们写出更好的代码,还能深入理解现代构建工具的工作原理。
前言:为什么需要模块化?
在模块化出现之前,我们通常会这样写代码:
var globalData = '我是全局变量';
function utility1() {
// 可能修改全局变量
globalData = '被修改了';
}
function utility2() {
// 依赖utility1
utility1();
console.log(globalData);
}
这种写法在小型项目中尚可,但在大型项目中会带来严重的问题:
- 全局命名空间污染
- 依赖关系不明确
- 难以维护和测试
- 无法按需加载
模块化的演进历程
原始时期:Script 标签与全局命名空间
<!-- 1995-2009:简单的脚本加载 -->
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="main.js"></script>
在这种脚本加载中,存在以下问题:
- 所有脚本共享全局作用域
- 脚本中的变量可能被其他脚本覆盖
- 依赖关系必须手动管理
- 如果utils.js依赖jquery.js,必须确保加载顺序
命名空间模式(Namespace Pattern)
// 2005年左右:使用对象作为命名空间
var MYAPP = MYAPP || {}; // 防止重复定义
MYAPP.utils = {
trim: function(str) {
return str.replace(/^\s+|\s+$/g, '');
},
formatDate: function(date) {
// ...
}
};
MYAPP.models = {
User: function(name) {
this.name = name;
}
};
命名空间的优点
- 减少了全局变量数量
- 有一定的组织结构
命名空间的缺点
- 所有数据仍然是公开的
- 无法实现私有成员
- 依赖关系依然不明确
IIFE 模式(立即执行函数)
// 2008年左右:使用闭包实现模块化
var Module = (function() {
// 私有变量
var privateVar = '我是私有的';
// 私有函数
function privateMethod() {
console.log(privateVar);
}
// 公有API
return {
publicMethod: function() {
privateMethod();
return '公共方法';
},
publicVar: '公共变量'
};
})();
// 使用模块
Module.publicMethod(); // 可以访问
// Module.privateVar; // 报错:undefined
// Module.privateMethod(); // 报错:不是函数
IIFE 模式的优点
- 实现了真正的私有成员
- 避免了全局污染
- 代码更加安全
IIFE 模式的缺点
- 依赖管理仍需手动处理
- 无法实现按需加载
- 模块定义分散
IIFE 依赖注入
var Module = (function($, _) {
// 使用依赖
function init() {
$('#app').hide();
_.each([1, 2, 3], console.log);
}
return {
init: init
};
})(jQuery, _); // 依赖作为参数传入
IIFE 依赖注入的优点
- 依赖关系明确
- 可以替换依赖的实现
IIFE 依赖注入的缺点
- 依赖需要提前加载
- 依赖顺序必须正确
模块加载器的出现
随着 Web 应用越来越复杂,社区开始探索更先进的模块系统:
2009年:CommonJS(服务器端)
var fs = require('fs');
var _ = require('lodash');
exports.myFunction = function() {
// ...
};
2011年:AMD(浏览器端,异步加载)
define(['jquery', 'lodash'], function($, _) {
return {
init: function() {
// 使用$和_
}
};
});
2014年:UMD(通用模块定义:兼容CommonJS和AMD)
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// CommonJS
module.exports = factory(require('jquery'));
} else {
// 浏览器全局变量
root.myModule = factory(root.jQuery);
}
}(this, function($) {
// 模块代码
return {
// ...
};
}));
2015年:ES Module(现代标准)
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
四大模块系统对比
| 特性 | CommonJS | AMD | UMD | ES Module |
|---|---|---|---|---|
| 环境 | 服务器端(Node.js) | 浏览器端 | 通用 | 现代浏览器/Node.js |
| 加载方式 | 同步加载 | 异步加载 | 根据环境决定 | 静态/动态加载 |
| 语法 | require() / exports | define() / require() | 条件判断多种语法 | import / export |
| 时机 | 运行时 | 运行时 | 运行时 | 编译时(静态) |
| Tree Shaking | 不支持 | 不支持 | 不支持 | 支持 |
| 静态分析 | 困难 | 困难 | 困难 | 容易 |
Tree Shaking 深度解析
什么是 Tree Shaking?
Tree Shaking 是一种通过静态分析从代码中移除未使用代码(死代码)的技术。这个名字源于摇动树木,让枯叶(未使用的代码)掉落。
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export function unusedFunction() {
console.log('这个函数从未被使用');
return '无用';
}
// app.js
import { add } from './math.js';
console.log(add(2, 3)); // 只用到了add
// 打包后,multiply和unusedFunction可以被移除
Tree Shaking 的实现条件
1. 使用 ES Module 语法
// ✅ 使用 ES Module 语法可以被Tree Shaking
export function used() { return '使用'; }
export function unused() { return '未使用'; }
// ❌ CommonJS 难以Tree Shaking
module.exports = {
used: function() { return '使用'; },
unused: function() { return '未使用'; }
};
2. 无副作用/纯函数(Pure Function)
// ✅ 纯函数,可以安全移除
export const PI = 3.14159;
export function square(x) { return x * x; }
// ⚠️ 有副作用,需要小心处理
export function logMessage(msg) {
console.log(msg); // 副作用:控制台输出
return msg;
}
3. 静态导入
// ✅ 静态导入可以被分析
import { add } from './math.js';
// ❌ 动态导入难以分析
const moduleName = './math.js';
import(moduleName).then(module => {
// 运行时才知道使用什么
});
4. 模块级别的分析
// ✅ 整个模块可以被分析
export { add, multiply } from './math.js';
5. 使用工具标记
// package.json 中的 sideEffects 字段
{
"name": "my-package",
"sideEffects": false, // 整个包都无副作用
"sideEffects": [ // 或指定有副作用的文件
"*.css",
"src/polyfills.js"
]
}
Tree Shaking 原理详解
// math-complex.js
export function add(a, b) { // 被使用
return a + b;
}
export function multiply(a, b) { // 被使用
return a * b;
}
export function divide(a, b) { // 未被使用
return a / b;
}
export const PI = 3.14159; // 被使用
export const UNUSED_CONST = '未使用'; // 未被使用
// 副作用代码
export function init() { // 有副作用,但未被调用
console.log('初始化');
window.MATH = { version: '1.0' };
}
// 默认导出(可能有副作用)
export default function() {
console.log('默认导出');
}
// app.js
import { add, PI } from './math-complex.js';
import mathDefault from './math-complex.js';
console.log(add(2, 3), PI);
mathDefault();
以上述代码为例,Tree Shaking 步骤流程如下:
-
源代码分析
-
构建依赖图
app.js ├── 导入: add (来自math-complex.js) ├── 导入: PI (来自math-complex.js) └── 导入: default (来自math-complex.js) math-complex.js ├── 导出: add ✓ (被使用) ├── 导出: multiply ✗ (未使用) ├── 导出: divide ✗ (未使用) ├── 导出: PI ✓ (被使用) ├── 导出: UNUSED_CONST ✗ (未使用) ├── 导出: init ✗ (未使用,但有副作用) └── 导出: default ✓ (被使用) -
标记活跃代码:从入口开始,标记所有可达的代码
-
消除死代码:移除未被标记的代码
-
最终打包结果包含:
- add 函数
- PI 常量
- 默认导出函数
- 不包含:multiply, divide, UNUSED_CONST, init
注:默认导出即使内部有console.log,但因为被调用了,所以需要保留。 init函数有副作用但未被调用,理论上可以移除,但需要小心。
模块化的未来
模块联邦(Module Federation)
Webpack 5 引入的模块联邦,允许在多个独立构建的应用间共享模块:
// app1/webpack.config.js - 提供者(Host)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'app1',
filename: 'remoteEntry.js', // 远程入口文件
exposes: {
'./Button': './src/components/Button.jsx',
'./utils': './src/utils/index.js'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
// app2/webpack.config.js - 消费者(Remote)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'app2',
remotes: {
app1: 'app1@http://localhost:3001/remoteEntry.js'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
// app2/src/App.js - 使用远程模块
// 从app1动态导入模块
const RemoteButton = () => import('app1/Button');
导入映射(Import Maps)
<!-- 导入映射:控制模块的解析 -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js",
"lodash": "/node_modules/lodash-es/lodash.js",
"components/": "/src/components/"
},
"scopes": {
"/src/": {
"utils": "/src/utils/index.js"
}
}
}
</script>
<script type="module">
// 现在可以这样导入
import { createApp } from 'vue';
import { debounce } from 'lodash';
import Button from 'components/Button.js';
// 不需要写完整路径
import { formatDate } from 'utils';
</script>
结语
JavaScript 模块化的演进历程是一部精彩的技术发展史。从最初的全局变量污染,到现在的 ES Module 原生支持,我们见证了前端工程化从无到有、从简单到复杂的过程。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!