3、代码坏味道(下):全局数据、可变数据、发散式变化、霰弹式修改、依恋情结
好,咱们接着聊代码坏味道。上一章讲了几个常见的,这一章我挑了几个在实际项目中特别容易踩坑的。说实话,这些坏味道往往不是一眼就能看出来的,它们藏得比较深,但破坏力一点都不小。
我个人习惯把这一章的内容叫做「慢性毒药」——它们不会立刻让你的代码崩溃,但会慢慢腐蚀整个系统的可维护性。你想想看,一个项目维护了半年之后,改一个功能要改十几个文件,那种感觉,嗯,懂的都懂。
全局数据:看似方便,实则灾难
全局数据,说白了就是谁都能改、谁都能读的数据。我见过太多项目,一开始图省事,把一些配置、状态、缓存全扔到全局变量里。结果呢?项目一大了,你根本不知道这个变量在什么时候被谁改过。
举个例子,假设你有一个全局的 userSession 对象:
// 坏味道:全局数据
let userSession = {
userId: null,
token: null,
role: 'guest'
};
function loadUserProfile() {
// 这里隐式依赖了 userSession
fetch('/api/user/' + userSession.userId);
}
function checkPermission() {
// 这里也依赖了 userSession
if (userSession.role === 'admin') { ... }
}
我在项目中遇到过最离谱的一次,是一个全局的配置对象被三个不同的模块修改,最后线上出了问题,查了两天才发现是某个模块在初始化时覆盖了配置。从那以后,我对全局数据就特别敏感。
可变数据:改着改着就乱了
可变数据和全局数据经常是一对「好兄弟」。可变数据的问题在于,你无法保证一个对象在传递过程中没有被修改。尤其是当你把对象传给一个函数,函数内部偷偷改了它的属性——这种「副作用」是 bug 的温床。
为什么会这样?因为人脑很难追踪一个可变对象的所有引用路径。你想想看,一个对象被传了五层函数,中间任何一层改了它,后面的逻辑都可能出问题。
// 坏味道:可变数据
function processOrder(order) {
// 这里直接修改了传入的对象
order.status = 'processing';
order.updatedAt = new Date();
// ... 其他逻辑
}
// 调用方
const myOrder = { id: 1, status: 'pending' };
processOrder(myOrder);
// 调用方根本不知道 myOrder 已经被改了
我曾经在一个项目中,因为一个可变对象在循环中被反复修改,导致最终结果完全不对。调试的时候发现,每次循环打印出来的对象都不一样——嗯,那种感觉,就像你在追一个永远追不上的影子。
发散式变化:一个类,多个理由去改它
发散式变化,指的是一个类因为不同的原因而需要被修改。说白了,就是职责不单一。一个类如果同时承担了数据存储、业务逻辑、UI 渲染等多个职责,那它就会成为「修改热点」。
我见过一个类,里面既有数据库查询,又有 JSON 序列化,还有日志记录。每次需求变更,不管是改数据库、改格式还是改日志级别,都得动这个类。你说累不累?
| 变化原因 | 应该修改的类 | 实际修改的类(坏味道) |
|---|---|---|
| 数据库表结构变更 | UserRepository | UserService(同时处理了 DB、序列化、日志) |
| JSON 格式变更 | UserSerializer | UserService |
| 日志级别调整 | Logger | UserService |
解决发散式变化的方法很简单:把不同的变化原因分离到不同的类中。每个类只因为一个原因而改变。这就是单一职责原则的核心思想。
霰弹式修改:改一个功能,改一堆文件
霰弹式修改和发散式变化是「镜面关系」。发散式变化是一个类被多个原因修改,霰弹式修改是一个原因导致多个类被修改。
举个例子,假设你要修改某个业务规则,结果发现需要改五个不同的文件:一个配置文件、一个验证逻辑、一个数据库查询、一个前端展示、一个日志记录。这就是典型的霰弹式修改。
我记得有一次,团队要改一个支付流程中的税率计算。结果发现税率逻辑散落在订单模块、发票模块、报表模块和财务模块里。改了一个地方,漏了另一个地方,线上对账差了十几万。嗯,从那以后,我们就把所有税率计算收拢到了一个 TaxCalculator 类里。
依恋情结:一个类总想拿别人的东西
依恋情结,指的是一个方法对另一个类的数据或方法特别「依恋」。说白了,就是某个方法频繁地访问其他类的数据,而不是自己类的数据。
为什么会这样?有时候是因为职责划分不合理。比如,一个 Order 类里有一个方法,大部分时间都在操作 Customer 类的数据。那这个方法其实应该搬到 Customer 类里去。
// 坏味道:依恋情结
class Order {
constructor(customer) {
this.customer = customer;
}
// 这个方法大部分时间都在操作 customer 的数据
getCustomerDiscount() {
if (this.customer.isVip) {
return this.customer.vipDiscount;
}
if (this.customer.totalSpent > 10000) {
return 0.1;
}
return 0;
}
}
// 改进:把方法移到 Customer 类中
class Customer {
getDiscount() {
if (this.isVip) {
return this.vipDiscount;
}
if (this.totalSpent > 10000) {
return 0.1;
}
return 0;
}
}
我个人习惯是,写代码的时候多问问自己:「这个方法放在这里合理吗?它是不是更依赖另一个类的数据?」如果答案是肯定的,那就搬过去。别犹豫。
知识体系总览
下面这张图帮你梳理了本章五个坏味道的核心逻辑和关系。你可以把它当作一个快速参考。
避坑指南
最后,分享几个我自己的经验教训:
- 我曾经在一个项目中,为了「方便」,把数据库连接信息写在了全局配置里。结果测试环境和生产环境的配置混在一起,差点把测试数据刷到生产库。从那以后,全局配置一律用环境变量 + 显式注入。
- 我曾经写过一个工具类,里面全是静态方法,到处修改传入的对象。后来同事接手维护,每次改代码都要把所有调用点检查一遍。嗯,后来我重构成了纯函数,世界清净了。
- 我曾经遇到一个霰弹式修改的场景,改一个字段名要改 12 个文件。后来我们引入了统一的 DTO(数据传输对象),所有字段映射集中管理,修改成本降到了 1 个文件。
说白了,这些坏味道的本质都是「耦合」。全局数据是数据耦合,可变数据是时间耦合,发散式变化和霰弹式修改是职责耦合,依恋情结是逻辑耦合。解耦,就是重构的核心目标。
好,这一章就到这里。记住,识别坏味道只是第一步,动手重构才是关键。下一章我们聊聊具体的重构手法,到时候见。
公众号:蓝海资料掘金营,微信deep3321