16.1:模块化的必要性:全局污染与依赖
第16章:模块化的必要性:全局污染与依赖管理
本节位于第16章“模块化:代码的封装与复用”的开头,前面已经学习过作用域、函数、对象、数组等基础内容,后面会继续学习 IIFE、CommonJS、AMD、ES Modules 等具体模块方案。本节只解决一个问题:为什么 JavaScript 项目需要模块化。
1. 本章学习目标
学完本节后,应该能够做到:
- 理解什么是“全局污染”,知道它为什么会导致代码冲突。
- 理解多个 JavaScript 文件之间为什么会出现加载顺序问题。
- 能够识别代码中的“隐式依赖”和“依赖不清”问题。
- 能使用对象命名空间初步减少全局变量数量。
- 明白模块化要解决的核心问题:封装、复用、隔离、依赖管理。
2. 前置知识
学习本节前,最好已经理解:
- 变量、常量、函数的基本写法。
- 数组和对象的基本使用。
- 作用域的含义:变量在哪里能被访问。
- 浏览器中可以通过
<script>标签引入 JavaScript 文件。
如果还不熟,可以先记住一句话:
作用域决定了一个变量或函数在哪些地方可以被访问;模块化的目的之一,就是让代码拥有更清晰、更安全的作用域边界。
3. 为什么要学这一章
假设正在做一个网页项目:
cart.js负责购物车。user.js负责用户登录。price.js负责价格计算。main.js负责页面启动。
刚开始代码很少,所有东西都写在一个文件里,似乎没有问题。
后来项目变大了,问题开始出现:
let total = 100;
购物车文件里有一个 total,价格计算文件里也有一个 total。两个文件都被页面加载后,变量名字冲突了。
又比如:
calculatePrice();
这段代码调用了 calculatePrice,但这个函数到底在哪个文件里?必须先加载哪个文件?如果顺序错了,程序就会报错。
模块化要解决的正是这些问题:
代码变多以后,如何让每一部分代码各管各的,不随便影响别人,同时又能在需要时互相配合。
4. 正文讲解
4.1 什么是模块化
概念解释
模块化,就是把一个大程序拆成多个相对独立的小部分。
每个小部分负责一类明确的工作,例如:
- 用户模块:处理登录、退出、用户信息。
- 购物车模块:处理添加商品、删除商品、计算总价。
- 工具模块:处理日期格式化、金额格式化等通用功能。
模块化不是简单地“把代码拆成多个文件”。
真正的模块化至少要做到三点:
- 每个模块内部的变量不要随便暴露到外面。
- 模块之间的依赖关系要清楚。
- 一个模块应该方便被其他地方复用。
为什么需要它
没有模块化时,所有代码容易混在一起:
let userName = "Alice";
let cartItems = [];
let total = 0;
function login() {}
function addToCart() {}
function calculateTotal() {}
代码少的时候还能看懂。代码多了以后,就会出现几个问题:
- 变量太多,不知道哪个变量属于哪个功能。
- 函数太多,不知道哪个函数应该在哪里调用。
- 名字容易重复。
- 修改一个地方,可能影响另一个看起来无关的地方。
模块化的本质,是让代码从“杂物堆”变成“分类收纳盒”。
最小可运行代码
下面先不用真正的模块语法,只用对象模拟“把相关代码放在一起”的感觉:
const CartModule = {
items: [],
addItem(name) {
CartModule.items.push(name);
},
showItems() {
console.log(CartModule.items);
}
};
CartModule.addItem("JavaScript 教材");
CartModule.showItems();
运行结果:
["JavaScript 教材"]
代码逐行解释
第1行:
const CartModule = {
创建一个对象,名字叫 CartModule。这里把它当作“购物车相关代码的收纳盒”。
第2行:
items: [],
在对象中保存一个数组,用来存放购物车商品。
第4行:
addItem(name) {
定义一个方法,用来添加商品。
第5行:
CartModule.items.push(name);
把商品名称加入 CartModule.items 数组。
第8行:
showItems() {
定义一个方法,用来显示当前购物车商品。
第9行:
console.log(CartModule.items);
输出购物车中的商品。
第13行:
CartModule.addItem("JavaScript 教材");
调用购物车模块的添加方法。
第14行:
CartModule.showItems();
调用购物车模块的显示方法。
常见错误
错误一:以为“拆成对象”就等于真正模块化。
const CartModule = {};
这种写法只是减少了一些全局变量,并没有真正实现模块隔离。后面学习 ES Modules 时,会看到更标准的模块写法。
错误二:对象名字本身仍然可能冲突。
const CartModule = {};
const CartModule = {};
同一个作用域中不能重复声明同名常量。
错误三:把所有东西都塞进一个大对象。
const App = {
user: {},
cart: {},
price: {},
order: {},
message: {},
config: {}
};
这样虽然减少了全局名字数量,但如果对象过大,仍然会变得难维护。
4.2 全局污染:变量和函数挤在同一个空间里
概念解释
全局污染,是指太多变量或函数被放到了全局作用域中,导致它们容易互相冲突、互相覆盖。
“全局”可以理解为程序中最外层的公共空间。
在浏览器普通 <script> 脚本中,多个脚本文件会共享同一个全局环境。也就是说,a.js 中声明的全局变量,可能影响 b.js。
为什么需要理解它
如果一个网页同时引入很多脚本:
<script src="user.js"></script>
<script src="cart.js"></script>
<script src="main.js"></script>
这些文件不是天然隔离的。
如果它们都在最外层声明变量或函数,就可能互相影响。
最小可运行代码:浏览器示例
新建一个 index.html 文件:
<!DOCTYPE html>
<html>
<body>
<script>
var title = "用户中心";
</script>
<script>
var title = "购物车页面";
console.log(title);
</script>
</body>
</html>
用浏览器打开,控制台输出:
购物车页面
代码逐行解释
第4行:
var title = "用户中心";
第一个脚本声明了一个全局变量 title。
第8行:
var title = "购物车页面";
第二个脚本又声明了同名变量 title。
由于使用的是 var,后面的值覆盖了前面的值。
第9行:
console.log(title);
输出当前的 title,结果是 "购物车页面"。
这说明两个脚本之间不是完全隔离的。
浏览器和 Node.js 的区别
在浏览器普通脚本中:
var title = "购物车页面";
console.log(window.title);
通常可以通过 window.title 访问到这个变量。
但在 Node.js 中,文件最外层的 var、let、const 通常不会自动变成 global 的属性。
Node.js 示例:
var title = "购物车页面";
console.log(global.title);
console.log(title);
运行结果通常是:
undefined
购物车页面
这说明:
- 浏览器普通脚本中的全局变量更容易污染
window。 - Node.js 文件本身有一层模块包装,因此顶层变量通常不会直接挂到
global上。 - 但如果手动写
global.title = "xxx",仍然会污染 Node.js 的全局环境。
常见错误
错误一:误以为不同 <script> 标签之间互不影响。
<script>
var count = 1;
</script>
<script>
console.log(count);
</script>
第二个脚本可以访问第一个脚本中的 count。
错误二:忘记写 let、const 或 var。
count = 10;
在非严格模式下,这可能意外创建全局变量。更安全的写法是:
"use strict";
let count = 10;
错误三:全局函数重名。
function format() {
return "A";
}
function format() {
return "B";
}
console.log(format());
输出:
B
后面的函数覆盖了前面的函数。
4.3 函数命名冲突:同名函数会互相覆盖
概念解释
函数命名冲突,是指不同功能的代码使用了相同的函数名,导致后声明的函数覆盖前面的函数。
例如两个程序员都觉得 formatPrice 是个好名字,于是一个写了人民币格式化,另一个写了美元格式化。
为什么需要它
项目变大后,函数名会越来越多。
如果所有函数都放在全局作用域中,就很容易出现这种情况:
- A 文件写了
init()。 - B 文件也写了
init()。 - 最后到底调用的是哪个
init(),取决于脚本加载顺序。
这会让程序变得非常脆弱。
最小可运行代码
function formatPrice(price) {
return price + " 元";
}
function formatPrice(price) {
return "$" + price;
}
console.log(formatPrice(100));
运行结果:
$100
代码逐行解释
第1行:
function formatPrice(price) {
第一次声明 formatPrice 函数。
第2行:
return price + " 元";
这个版本把价格格式化为人民币形式。
第5行:
function formatPrice(price) {
第二次声明同名函数。
第6行:
return "$" + price;
这个版本把价格格式化为美元形式。
第9行:
console.log(formatPrice(100));
调用 formatPrice。
由于后面的函数覆盖了前面的函数,所以输出 $100。
改进写法:放入不同对象中
const CNYFormatter = {
formatPrice(price) {
return price + " 元";
}
};
const USDFormatter = {
formatPrice(price) {
return "$" + price;
}
};
console.log(CNYFormatter.formatPrice(100));
console.log(USDFormatter.formatPrice(100));
运行结果:
100 元
$100
代码逐行解释
第1行:
const CNYFormatter = {
创建人民币格式化对象。
第2行:
formatPrice(price) {
在对象内部定义 formatPrice 方法。
第3行:
return price + " 元";
返回人民币格式。
第7行:
const USDFormatter = {
创建美元格式化对象。
第8行:
formatPrice(price) {
这个对象内部也可以有一个叫 formatPrice 的方法。
第9行:
return "$" + price;
返回美元格式。
第13行和第14行:
console.log(CNYFormatter.formatPrice(100));
console.log(USDFormatter.formatPrice(100));
通过不同对象调用不同方法,不再直接冲突。
常见错误
错误一:只改函数名,不改组织方式。
function formatPrice1() {}
function formatPrice2() {}
function formatPrice3() {}
这只是暂时避免重名,代码仍然缺少清晰分类。
错误二:命名过于宽泛。
function handle() {}
function init() {}
function submit() {}
这些名字太普通,容易在大项目里重复。
错误三:以为对象里的方法名必须全局唯一。
const A = {
init() {}
};
const B = {
init() {}
};
这种写法没有问题,因为两个 init 分别属于不同对象。
4.4 依赖管理:代码之间谁依赖谁要说清楚
概念解释
依赖,就是一段代码运行时需要另一段代码提供支持。
例如:
const result = calculateTotal([10, 20, 30]);
这行代码依赖 calculateTotal 函数。
如果 calculateTotal 不存在,程序就无法运行。
依赖管理,就是要弄清楚:
- 当前代码需要哪些变量或函数?
- 这些变量或函数来自哪里?
- 它们必须在什么时候准备好?
- 如果缺少依赖,程序会在哪里报错?
为什么需要它
如果依赖关系不清楚,代码就会变成这样:
startApp();
看起来只调用了一个函数,但这个函数里面可能依赖:
- 用户信息。
- 配置对象。
- 购物车数据。
- 网络请求工具。
- 页面 DOM 元素。
如果这些东西没有提前准备好,程序就会报错。
最小可运行代码:加载顺序错误
<!DOCTYPE html>
<html>
<body>
<script>
const total = calculateTotal([10, 20, 30]);
console.log(total);
</script>
<script>
function calculateTotal(numbers) {
let sum = 0;
for (const number of numbers) {
sum += number;
}
return sum;
}
</script>
</body>
</html>
运行时会报错:
ReferenceError: calculateTotal is not defined
代码逐行解释
第4行:
const total = calculateTotal([10, 20, 30]);
代码试图调用 calculateTotal。
但此时浏览器还没有执行下面那个 <script> 中的函数声明。
第8行:
function calculateTotal(numbers) {
函数在后面的脚本中才声明。
浏览器执行普通脚本时,是按 <script> 出现顺序执行的。前面的脚本不会自动等待后面的脚本准备好。
修正写法:先提供依赖,再使用依赖
<!DOCTYPE html>
<html>
<body>
<script>
function calculateTotal(numbers) {
let sum = 0;
for (const number of numbers) {
sum += number;
}
return sum;
}
</script>
<script>
const total = calculateTotal([10, 20, 30]);
console.log(total);
</script>
</body>
</html>
运行结果:
60
代码逐行解释
第4行到第12行:
function calculateTotal(numbers) {
let sum = 0;
for (const number of numbers) {
sum += number;
}
return sum;
}
先声明计算总和的函数。
第16行:
const total = calculateTotal([10, 20, 30]);
再调用函数,此时依赖已经准备好了。
第17行:
console.log(total);
输出计算结果。
浏览器和 Node.js 的区别
浏览器普通脚本中,多个 <script> 默认按顺序执行。
Node.js 中,不是靠 <script> 标签顺序组织文件,而是通过模块系统加载文件。后面会学到:
- CommonJS 中使用
require。 - ES Modules 中使用
import。
本节暂时只需要理解:
浏览器普通脚本更容易暴露“加载顺序问题”;Node.js 更依赖模块系统表达文件关系。
常见错误
错误一:把 <script> 顺序写反。
<script src="main.js"></script>
<script src="utils.js"></script>
如果 main.js 依赖 utils.js,通常应该先加载 utils.js。
错误二:代码能跑,但依赖关系不明显。
const result = calculateTotal(data);
读这行代码时,并不知道 calculateTotal 和 data 来自哪里。
错误三:所有文件都依赖一个巨大的 global.js。
<script src="global.js"></script>
<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>
这种方式短期方便,但长期会让所有代码互相牵连。
4.5 隐式依赖:函数偷偷使用外面的变量
概念解释
隐式依赖,是指一个函数没有通过参数说明自己需要什么,却在函数内部偷偷使用外部变量。
例如:
const discount = 0.8;
function getFinalPrice(price) {
return price * discount;
}
getFinalPrice 表面上只需要 price,但实际上还依赖外面的 discount。
为什么需要理解它
隐式依赖会带来三个问题:
- 函数不好复用。
- 测试时不容易单独运行。
- 修改外部变量可能影响函数结果。
最小可运行代码:隐式依赖
const discount = 0.8;
function getFinalPrice(price) {
return price * discount;
}
console.log(getFinalPrice(100));
运行结果:
80
代码逐行解释
第1行:
const discount = 0.8;
定义折扣变量。
第3行:
function getFinalPrice(price) {
定义计算最终价格的函数。
第4行:
return price * discount;
函数使用了参数 price,也使用了外部变量 discount。
第7行:
console.log(getFinalPrice(100));
传入价格 100,结果是 100 * 0.8,所以输出 80。
改进写法:把依赖变成参数
function getFinalPrice(price, discount) {
return price * discount;
}
console.log(getFinalPrice(100, 0.8));
console.log(getFinalPrice(100, 0.5));
运行结果:
80
50
代码逐行解释
第1行:
function getFinalPrice(price, discount) {
函数明确声明自己需要两个数据:价格和折扣。
第2行:
return price * discount;
使用传入的参数计算最终价格。
第5行:
console.log(getFinalPrice(100, 0.8));
用 8 折计算。
第6行:
console.log(getFinalPrice(100, 0.5));
用 5 折计算。
这个函数现在更容易复用,因为它不再偷偷依赖外部变量。
常见错误
错误一:函数内部大量使用外部变量。
function submitOrder() {
console.log(user);
console.log(cart);
console.log(config);
}
这个函数依赖 user、cart、config,但从参数列表完全看不出来。
错误二:以为函数能运行就代表设计合理。
代码能运行,不代表依赖关系清晰。
错误三:把所有依赖都变成全局变量。
const user = {};
const cart = {};
const config = {};
这会让代码越来越难拆分。
4.6 用命名空间对象初步缓解全局污染
概念解释
命名空间对象,就是用一个对象把相关变量和函数集中起来。
例如,不要这样写:
let cartItems = [];
function addItem() {}
function removeItem() {}
function getTotal() {}
可以先改成这样:
const Cart = {
items: [],
addItem() {},
removeItem() {},
getTotal() {}
};
这样全局作用域中只多了一个名字:Cart。
为什么需要它
在学习真正的模块系统之前,命名空间对象是一种简单的过渡方式。
它可以帮助初学者理解:
- 相关代码应该放在一起。
- 不同功能应该有不同边界。
- 全局名字越少,冲突风险越低。
最小可运行代码
const Cart = {
items: [],
addItem(name, price) {
Cart.items.push({
name: name,
price: price
});
},
getTotal() {
let total = 0;
for (const item of Cart.items) {
total += item.price;
}
return total;
}
};
Cart.addItem("鼠标", 50);
Cart.addItem("键盘", 100);
console.log(Cart.getTotal());
运行结果:
150
代码逐行解释
第1行:
const Cart = {
创建一个购物车对象。
第2行:
items: [],
用数组保存商品。
第4行:
addItem(name, price) {
定义添加商品的方法,接收商品名和价格。
第5行到第8行:
Cart.items.push({
name: name,
price: price
});
把商品对象放入 Cart.items 数组中。
第11行:
getTotal() {
定义计算总价的方法。
第12行:
let total = 0;
创建总价变量,初始值是 0。
第14行:
for (const item of Cart.items) {
遍历购物车中的每个商品。
第15行:
total += item.price;
把每个商品的价格加到总价中。
第18行:
return total;
返回最终总价。
第22行和第23行:
Cart.addItem("鼠标", 50);
Cart.addItem("键盘", 100);
添加两个商品。
第25行:
console.log(Cart.getTotal());
输出购物车总价。
常见错误
错误一:以为 const Cart 代表对象内容不能改。
const Cart = {
items: []
};
Cart.items.push("商品");
这段代码是可以运行的。
const 只表示变量名 Cart 不能重新指向另一个对象,不表示对象内部内容不能修改。
错误二:命名空间对象过大。
const App = {
login() {},
logout() {},
addCart() {},
removeCart() {},
request() {},
render() {},
formatDate() {},
formatPrice() {}
};
如果所有功能都塞进一个对象,代码仍然混乱。
错误三:误以为命名空间对象可以彻底解决依赖问题。
Cart.addItem("鼠标", Price.format(50));
这里 Cart 依然依赖 Price。如果 Price 没有提前准备好,仍然会报错。
命名空间只能缓解问题,不能彻底解决问题。
4.7 模块化真正解决的四个问题
概念解释
模块化真正想解决四个核心问题:
第一,封装。
模块内部的细节不应该全部暴露出来。
第二,复用。
一个写好的功能,应该能在多个地方使用。
第三,隔离。
不同模块中的变量和函数不应该轻易冲突。
第四,依赖管理。
一个模块需要什么,应该明确表达出来。
为什么需要它
没有模块化时,代码关系像这样:
user.js 可能影响 cart.js
cart.js 可能影响 price.js
price.js 可能影响 main.js
main.js 又可能依赖所有文件
代码之间的关系是一团乱麻。
模块化之后,理想状态是:
main 需要 cart
cart 需要 price
user 和 cart 相互独立
关系变得清楚,修改也更安全。
最小可运行代码:把依赖通过参数传入
const PriceTool = {
format(price) {
return "¥" + price.toFixed(2);
}
};
function showProduct(name, price, formatter) {
const text = name + ":" + formatter.format(price);
console.log(text);
}
showProduct("JavaScript 教材", 59, PriceTool);
运行结果:
JavaScript 教材:¥59.00
代码逐行解释
第1行:
const PriceTool = {
创建价格工具对象。
第2行:
format(price) {
定义格式化价格的方法。
第3行:
return "¥" + price.toFixed(2);
把数字价格转成带人民币符号、保留两位小数的字符串。
第7行:
function showProduct(name, price, formatter) {
定义显示商品的函数。
它需要三个数据:
- 商品名
name - 商品价格
price - 价格格式化工具
formatter
第8行:
const text = name + ":" + formatter.format(price);
使用传入的 formatter 格式化价格。
第9行:
console.log(text);
输出商品信息。
第12行:
showProduct("JavaScript 教材", 59, PriceTool);
调用函数,并明确把 PriceTool 传进去。
这样比在函数内部偷偷使用全局 PriceTool 更清晰。
常见错误
错误一:依赖没有显式表达。
function showProduct(name, price) {
console.log(name + PriceTool.format(price));
}
这个函数偷偷依赖 PriceTool。
错误二:所有模块互相调用。
User.login();
Cart.update();
Price.calculate();
User.refreshCart();
Cart.checkUser();
如果模块之间互相依赖,结构会越来越复杂。
错误三:过早追求复杂架构。
本节只需要理解为什么需要模块化。暂时不需要自己设计复杂模块系统。
5. 本章完整示例
下面做一个小型“商品总价展示程序”。
这个例子不使用后面章节的 import 和 export,只用当前已经学过的对象、函数、数组、循环来模拟模块化思想。
这段代码可以在浏览器控制台运行,也可以保存为 cart-app.js 后用 Node.js 运行:
const ProductData = {
list: [
{ name: "JavaScript 教材", price: 59 },
{ name: "练习本", price: 12 },
{ name: "黑色签字笔", price: 6 }
]
};
const PriceTool = {
sum(products) {
let total = 0;
for (const product of products) {
total += product.price;
}
return total;
},
format(amount) {
return "¥" + amount.toFixed(2);
}
};
function createCartApp(data, priceTool) {
return {
run() {
const total = priceTool.sum(data.list);
const message = "商品总价:" + priceTool.format(total);
console.log(message);
}
};
}
const CartApp = createCartApp(ProductData, PriceTool);
CartApp.run();
运行结果:
商品总价:¥77.00
这个例子中有三个相对独立的部分:
第一部分:
const ProductData = {
list: [...]
};
负责保存商品数据。
第二部分:
const PriceTool = {
sum(products) {},
format(amount) {}
};
负责价格计算和格式化。
第三部分:
function createCartApp(data, priceTool) {}
负责创建应用,并且通过参数明确说明自己需要:
- 商品数据
data - 价格工具
priceTool
最后:
const CartApp = createCartApp(ProductData, PriceTool);
CartApp.run();
把依赖传进去,然后启动程序。
这个例子体现了本节的核心思想:
- 不把所有变量随便放在全局。
- 相关功能放在一起。
- 依赖通过参数表达出来。
- 每一部分有相对明确的职责。
它还不是真正的现代模块化代码。真正的 export 和 import 会在 16.3 中正式学习。
6. 常见误区
误区一:以为“多个 JS 文件”就是模块化
不是。
如果多个 JS 文件都往全局作用域里添加变量和函数,它们只是“分文件”,不是“模块化”。
误区二:以为全局变量方便,所以可以随便用
全局变量短期方便,长期危险。
因为任何地方都可能读取它、修改它、覆盖它。
误区三:以为函数能运行就代表依赖清楚
例如:
function render() {
console.log(config.title);
}
这段代码能运行,但它偷偷依赖外部的 config。
更清楚的方式是:
function render(config) {
console.log(config.title);
}
误区四:忽略浏览器和 Node.js 的全局环境差异
浏览器普通脚本中的全局变量可能挂到 window 上。
Node.js 文件顶层变量通常不会自动挂到 global 上。
但是两者都应该避免随意污染全局环境。
误区五:过早学习复杂工具
刚开始不需要马上学习 Webpack、Vite、Babel。
它们属于后面的工程化内容。
本节只需要先理解:
代码一旦变多,就必须考虑隔离、依赖和复用。
7. 本章小结
本节学习了模块化的必要性。
全局污染会让变量和函数互相冲突。
依赖不清会让代码难以维护,也容易因为加载顺序错误而报错。
命名空间对象可以初步缓解问题,但不是真正完整的模块方案。
模块化真正要解决的是:
- 封装内部细节。
- 减少全局污染。
- 明确依赖关系。
- 提高代码复用能力。
- 让大型项目更容易维护。
下一节可以继续学习早期 JavaScript 是如何用 IIFE、CommonJS、AMD 等方式解决这些问题的。
8. 练习题
基础题
下面代码有什么问题?请说明原因,并尝试改成使用命名空间对象的形式。
let count = 0;
function add() {
count++;
}
function show() {
console.log(count);
}
add();
show();
变形题
下面代码存在隐式依赖。请改写它,让函数需要的数据通过参数传入。
const taxRate = 0.1;
function getPriceWithTax(price) {
return price + price * taxRate;
}
console.log(getPriceWithTax(100));
综合题
写一个简单的“学生成绩统计程序”,要求:
- 使用一个对象保存学生数据。
- 使用一个对象保存成绩计算方法。
- 至少包含一个计算平均分的方法。
- 不要把所有变量和函数都直接散落在全局作用域中。
- 最后输出平均分。