4、基本重构手法(下):移动函数、移动字段、封装字段、引入参数对象

好,咱们接着聊。上一节讲了提炼函数和变量,算是重构的「基本功」。这一节要聊的四个手法,更偏向于「搬家」和「整理」。

说白了,代码写久了,东西放错地方是常有的事。函数不该待在这个类里,字段跑到了别人的地盘上,参数列表长得像购物清单……这些我都遇到过。别急,有办法治。

4.1 移动函数(Move Function)

一个函数,如果它跟另一个类的交互比跟自己的类还频繁,那它就该搬家。

什么时候该搬?

  • 函数里频繁调用另一个类的 getter 或 setter
  • 函数依赖另一个类的内部数据比依赖自己的还多
  • 函数在代码里被调用的位置,明显更适合放在目标类里

我记得有一次接手一个订单系统,有个 calculateTotal 函数写在 Order 类里,结果它里面全是 order.items 的循环计算。你想想看,这逻辑明显该属于 OrderItem 或者 ItemCollection。搬过去之后,Order 类清爽多了。

核心原则: 函数应该和它操作的数据待在一起。

操作步骤:

  1. 检查源函数在当前上下文中的所有引用
  2. 检查目标类是否已经有类似功能
  3. 将函数复制到目标类,调整参数
  4. 在源函数中委托调用目标函数
  5. 逐步替换所有调用方
  6. 删除源函数
// 重构前:函数在 Order 里,却疯狂操作 items
class Order {
  constructor(items) {
    this.items = items;
  }
  getTotalPrice() {
    let total = 0;
    for (let item of this.items) {
      total += item.price * item.quantity;
    }
    return total;
  }
}

// 重构后:把计算逻辑移到 Item 里
class Item {
  constructor(price, quantity) {
    this.price = price;
    this.quantity = quantity;
  }
  getSubtotal() {
    return this.price * this.quantity;
  }
}

class Order {
  constructor(items) {
    this.items = items;
  }
  getTotalPrice() {
    return this.items.reduce((sum, item) => sum + item.getSubtotal(), 0);
  }
}
小技巧: 移动函数时,别急着删源函数。先委托,跑一遍测试,确认没问题再删。这叫「两步走」,稳得很。

4.2 移动字段(Move Field)

字段放错地方,比函数放错地方更隐蔽。因为字段不报错,它就在那儿,你甚至可能意识不到它碍事。

典型症状:

  • 某个字段在另一个类中被频繁访问
  • 字段的值只在特定场景下才有意义
  • 字段的更新逻辑散落在多个类中

我曾经在一个项目中看到 Customer 类里有个 discountRate 字段。结果呢?这个字段只在 Order 类里被用到。你说它待在那儿干嘛?搬过去之后,Order 自己就能算折扣,不用每次去问 Customer 要。

// 重构前:discountRate 在 Customer 里,但只有 Order 用
class Customer {
  constructor(name, discountRate) {
    this.name = name;
    this.discountRate = discountRate;
  }
}

class Order {
  constructor(customer, amount) {
    this.customer = customer;
    this.amount = amount;
  }
  getDiscountedAmount() {
    return this.amount * (1 - this.customer.discountRate);
  }
}

// 重构后:字段搬到真正使用它的地方
class Order {
  constructor(customer, amount, discountRate) {
    this.customer = customer;
    this.amount = amount;
    this.discountRate = discountRate;
  }
  getDiscountedAmount() {
    return this.amount * (1 - this.discountRate);
  }
}
注意: 移动字段时,要检查所有引用点。漏掉一个,就可能出现空指针或逻辑错误。我建议用 IDE 的「查找引用」功能扫一遍。

4.3 封装字段(Encapsulate Field)

这个手法最简单,但也最容易被忽视。说白了,就是给字段加上 getter 和 setter,不让外部直接访问。

为什么非要封装?

  • 直接暴露字段,后续想加校验逻辑就得改所有调用方
  • 字段类型或名称变了,影响面不可控
  • 面向对象的基本原则:数据应该由自己管理

嗯,这里要注意。很多人觉得「反正就一个字段,没必要封装」。我刚开始也这么想,直到有一次一个 public 的 age 字段被到处赋值,有人传了负数,有人传了字符串……从那以后,我再也不敢偷懒了。

// 重构前:直接暴露字段
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}

// 重构后:封装字段,加校验
class Person {
  constructor(name, age) {
    this.name = name;
    this._age = age;
  }
  get age() {
    return this._age;
  }
  set age(value) {
    if (value < 0 || value > 150) {
      throw new Error('年龄不合法');
    }
    this._age = value;
  }
}
我的习惯: 新写的类,所有字段默认私有。等确实需要外部访问时,再考虑暴露 getter。setter 能不加就不加,尽量通过构造函数或方法修改。

4.4 引入参数对象(Introduce Parameter Object)

这个手法,专门对付「参数爆炸」。一个函数有六七个参数,调用的时候自己都记不清顺序。你想想看,这种代码谁维护谁头疼。

什么时候该用?

  • 多个参数经常一起出现
  • 参数之间存在逻辑关联
  • 参数列表超过 3 个,且还在增长

我记得有个报表生成函数,参数有 startDate、endDate、region、category、minAmount、maxAmount……每次调用都得翻文档。后来我把它们打包成一个 ReportFilter 对象,整个世界清净了。

// 重构前:参数列表太长
function generateReport(startDate, endDate, region, category, minAmount, maxAmount) {
  // 生成报表逻辑
}

// 重构后:引入参数对象
class ReportFilter {
  constructor(startDate, endDate, region, category, minAmount, maxAmount) {
    this.startDate = startDate;
    this.endDate = endDate;
    this.region = region;
    this.category = category;
    this.minAmount = minAmount;
    this.maxAmount = maxAmount;
  }
}

function generateReport(filter) {
  // 使用 filter 对象
}
进阶用法: 参数对象不只是「装参数的袋子」。你可以在它上面加校验逻辑、默认值、甚至一些辅助方法。比如 filter.isValid() 或 filter.toQueryString()。

4.5 四种手法的对比

手法 解决的问题 适用场景 风险等级
移动函数 函数放错类 函数依赖另一个类的数据更多 中
移动字段 字段放错类 字段只在另一个类中被使用 中
封装字段 数据暴露在外 需要加校验或后续可能变化 低
引入参数对象 参数列表过长 多个参数经常一起出现 低

这四种手法,说白了都是在做一件事:让代码的归属更清晰。函数、字段、参数,它们都有自己的「家」。别让它们流浪。

下一节,我们会聊到更高级的重构手法——继承体系的重构。到时候你会发现,今天这些「搬家」手法,其实都是基础。

一句话总结: 代码放对位置,比写对逻辑更重要。位置错了,逻辑再对也是隐患。