8、基本重构手法(中):引入参数对象、函数组合成类、函数组合成变换、拆分阶段、搬移函数、搬移字段

好,咱们接着聊重构手法。上一章我讲了几个基础手法,今天这六个,说实话,是我在日常项目中用得最频繁的。它们解决的是同一个核心问题:代码的职责放错了地方

你想想看,一个函数参数太多,是不是看着就头疼?几个函数老在一起操作同一份数据,是不是该把它们凑一块?一个计算过程里混着不同阶段的事情,是不是该拆开?这些场景,今天的手法就是专门来治的。

核心思想:让每个代码单元只做一件事,并且把这件事做好。数据在哪,操作就该在哪。

8.1 引入参数对象

先说说这个。我见过太多函数,参数列表长得像购物清单。五个、七个、甚至十几个参数往里塞。调用的时候,你得小心翼翼地数着顺序,生怕传错。

什么时候该用? 当你发现一组参数总是同时出现、一起传递的时候。比如经纬度、起止日期、分页参数。

看个例子。这是重构前的代码:

function calculateBookingCost(
  checkInDate, checkOutDate, 
  roomType, roomCount, 
  customerLevel, discountCode
) {
  // 计算逻辑...
}

调用的时候长这样:

const cost = calculateBookingCost(
  '2025-06-01', '2025-06-05', 
  'deluxe', 2, 
  'gold', 'SUMMER20'
);

嗯,你看出问题了吧?参数太多,而且checkInDatecheckOutDate明显是一对,roomTyperoomCount也是一对。这时候就该引入参数对象了。

重构后:

class BookingPeriod {
  constructor(checkIn, checkOut) {
    this.checkIn = checkIn;
    this.checkOut = checkOut;
  }
  get nights() {
    return (this.checkOut - this.checkIn) / (1000 * 60 * 60 * 24);
  }
}

class RoomRequest {
  constructor(type, count) {
    this.type = type;
    this.count = count;
  }
}

function calculateBookingCost(
  period, roomRequest, customerLevel, discountCode
) {
  // 计算逻辑...
}

我的习惯:引入参数对象时,别只做个简单的数据容器。把相关的校验逻辑、派生计算也放进去。比如上面的nights属性,就是顺带的事。这样调用方更干净,逻辑也更内聚。

我曾经在一个电商项目中,看到一个订单处理函数有12个参数。我花了半天时间,把它们分成了三个参数对象:CustomerInfoOrderItemsShippingDetails。改完之后,调用代码从一团乱麻变成了清晰的三行。测试也好写了。

8.2 函数组合成类

这个手法,说白了就是:一群函数老在一起操作同一份数据,那就把它们和那个数据绑在一起,变成一个类。

为什么会这样?很多时候我们一开始只写了一个函数,后来需求多了,在旁边又加了一个函数,再后来又加了一个。它们都操作同一个全局变量或者同一个数据结构。这时候,就该考虑组合成类了。

举个例子,假设我们有一些报表计算的函数:

let salesData = [];

function totalRevenue(data) { /* 计算总收入 */ }
function averageOrderValue(data) { /* 计算平均订单金额 */ }
function topSellingProduct(data) { /* 找出最畅销产品 */ }
function revenueByCategory(data) { /* 按类别统计收入 */ }

这些函数都依赖salesData,而且经常一起使用。组合成类之后:

class SalesReport {
  constructor(data) {
    this.data = data;
  }

  totalRevenue() { /* 计算总收入 */ }
  averageOrderValue() { /* 计算平均订单金额 */ }
  topSellingProduct() { /* 找出最畅销产品 */ }
  revenueByCategory() { /* 按类别统计收入 */ }
}

你看,数据和方法现在在一起了。调用方只需要创建一个SalesReport实例,然后调用它的方法。清晰多了。

注意:别为了组合而组合。如果这些函数只是恰好用了同一个参数,但彼此之间没有共享状态,那用参数对象就够了,没必要硬凑成一个类。我见过有人把完全不相关的函数硬塞到一个类里,美其名曰“封装”,结果类变得臃肿不堪。

8.3 函数组合成变换

这个手法和上一个有点像,但思路不同。组合成变换,是把一组输入数据,通过一系列计算,变换成另一组输出数据。它强调的是数据流的转换,而不是对象的行为。

我一般在处理数据管道的时候用这个。比如从数据库里拿出来的原始数据,需要经过清洗、丰富、格式化,最后变成前端需要的结构。

看个例子:

// 原始数据
const rawOrder = {
  id: 'ORD-001',
  items: [
    { productId: 'P1', price: 100, quantity: 2 },
    { productId: 'P2', price: 50, quantity: 1 }
  ],
  customerId: 'C001',
  orderDate: '2025-05-20'
};

// 分散在各处的转换逻辑
function enrichWithProductNames(order) { /* 查表补充产品名 */ }
function calculateSubtotals(order) { /* 计算小计 */ }
function applyDiscounts(order) { /* 应用折扣 */ }
function formatForDisplay(order) { /* 格式化输出 */ }

组合成变换后,我们可以用一个清晰的管道来表达:

function transformOrder(rawOrder) {
  return pipe(
    enrichWithProductNames,
    calculateSubtotals,
    applyDiscounts,
    formatForDisplay
  )(rawOrder);
}

每个函数都是纯函数,输入一个订单,输出一个订单。整个变换过程是透明的、可测试的。

我的建议:当你发现数据在多个函数之间流转,而且每个函数都返回同一种数据结构时,组合成变换比组合成类更合适。它更函数式,副作用更少。

8.4 拆分阶段

这个手法,专门对付那种“一个函数里混着好几个不同阶段逻辑”的情况。

我记得有一次接手一个支付处理函数,里面混着:验证输入、计算金额、调用支付网关、更新数据库、发送通知邮件。五个阶段全在一个函数里,两百多行。每次改一个地方,都担心影响到其他地方。

拆分阶段的核心思路是:找出代码中的不同阶段,每个阶段只做一件事,阶段之间通过明确的数据结构传递。

重构前:

function processPayment(order) {
  // 阶段1: 验证
  if (!order.customer || !order.items.length) {
    throw new Error('Invalid order');
  }
  
  // 阶段2: 计算
  let total = 0;
  for (const item of order.items) {
    total += item.price * item.quantity;
  }
  
  // 阶段3: 支付
  const paymentResult = paymentGateway.charge(order.customer.id, total);
  
  // 阶段4: 更新
  database.updateOrderStatus(order.id, 'paid');
  
  // 阶段5: 通知
  emailService.sendConfirmation(order.customer.email);
}

重构后,每个阶段独立成函数:

function processPayment(order) {
  const validatedOrder = validateOrder(order);
  const calculatedOrder = calculateTotal(validatedOrder);
  const paymentResult = processPayment(calculatedOrder);
  updateOrderStatus(calculatedOrder, paymentResult);
  sendConfirmation(calculatedOrder);
}

function validateOrder(order) { /* 只做验证 */ }
function calculateTotal(order) { /* 只做计算 */ }
function processPayment(order) { /* 只做支付 */ }
function updateOrderStatus(order, result) { /* 只做更新 */ }
function sendConfirmation(order) { /* 只做通知 */ }

现在,每个函数都可以独立测试、独立修改。想换支付网关?只改processPayment一个函数就行。

判断标准:如果你发现一个函数里,前半部分和后半部分处理的是不同层次的事情,或者前半部分的输出是后半部分的输入,那就该拆了。

8.5 搬移函数

这个手法,解决的是“函数放错了地方”的问题。

你想想看,一个函数里,大部分操作都依赖另一个类的数据,那它是不是该搬到那个类里去?

举个例子:

class Order {
  constructor(items, customer) {
    this.items = items;
    this.customer = customer;
  }
}

class OrderService {
  calculateTotal(order) {
    let total = 0;
    for (const item of order.items) {
      total += item.price * item.quantity;
    }
    // 还调用了 customer 的折扣信息
    total *= order.customer.discountRate;
    return total;
  }
}

你看,calculateTotal虽然写在OrderService里,但它操作的全是Order的数据。它应该搬到Order类里去:

class Order {
  constructor(items, customer) {
    this.items = items;
    this.customer = customer;
  }

  calculateTotal() {
    let total = 0;
    for (const item of this.items) {
      total += item.price * item.quantity;
    }
    total *= this.customer.discountRate;
    return total;
  }
}

调用方也从orderService.calculateTotal(order)变成了order.calculateTotal()。更自然,不是吗?

避坑指南:我曾经把一个函数搬错了方向。那个函数虽然用了A类的数据,但它的核心逻辑是B类的业务规则。搬过去之后,A类变得不伦不类。后来我意识到,搬移函数的关键不是看它用了谁的数据,而是看它属于谁的行为

8.6 搬移字段

和搬移函数类似,这次搬的是字段。当一个字段经常被另一个类访问,或者它的生命周期和另一个类更相关时,就该搬了。

看个例子:

class Customer {
  constructor(name, phone) {
    this.name = name;
    this.phone = phone;
  }
}

class Order {
  constructor(customer, items) {
    this.customer = customer;
    this.items = items;
    this.phone = customer.phone; // 这里存了一份客户的电话
  }
  
  sendNotification() {
    // 用 this.phone 发短信
  }
}

这里Order里存了一份phone,但电话是客户的属性。如果客户改了电话,订单里的电话就过时了。应该搬回去:

class Order {
  constructor(customer, items) {
    this.customer = customer;
    this.items = items;
  }
  
  sendNotification() {
    // 用 this.customer.phone 发短信
  }
}

现在,电话只有一份,在Customer里。改电话只需要改一个地方。

我的经验:搬移字段的时候,要特别注意有没有其他地方也在用这个字段。我曾经搬了一个字段,结果忘了更新一个序列化逻辑,导致数据导出全乱了。所以,搬字段之前,先全局搜索一下所有引用点。

知识体系图

下面这张图,帮你理清今天这六个手法的关系:

基本重构手法(中)知识体系 核心问题:职责放错了地方 数据层面重组 行为层面重组 引入参数对象 减少参数数量 搬移字段 字段归属调整 搬移函数 函数归属调整 拆分阶段 阶段分离 组合手法:函数组合成类 / 函数组合成变换 目标:高内聚、低耦合、职责清晰

这张图把今天的手法分成了两类:数据层面和行为层面。数据层面的手法关注数据怎么组织、参数怎么传递;行为层面的手法关注函数和阶段怎么划分。而组合手法,则是把数据和行为一起打包。

好了,这六个手法就讲完了。说实话,这些手法看起来简单,但真正用好需要大量练习。我建议你从自己项目里找一个参数多的函数,试试引入参数对象;再找一个职责不清的类,试试搬移函数。动手做一遍,比看十遍都管用。


公众号:蓝海资料掘金营,微信deep3321