8、基本重构手法(中):引入参数对象、函数组合成类、函数组合成变换、拆分阶段、搬移函数、搬移字段
好,咱们接着聊重构手法。上一章我讲了几个基础手法,今天这六个,说实话,是我在日常项目中用得最频繁的。它们解决的是同一个核心问题:代码的职责放错了地方。
你想想看,一个函数参数太多,是不是看着就头疼?几个函数老在一起操作同一份数据,是不是该把它们凑一块?一个计算过程里混着不同阶段的事情,是不是该拆开?这些场景,今天的手法就是专门来治的。
核心思想:让每个代码单元只做一件事,并且把这件事做好。数据在哪,操作就该在哪。
8.1 引入参数对象
先说说这个。我见过太多函数,参数列表长得像购物清单。五个、七个、甚至十几个参数往里塞。调用的时候,你得小心翼翼地数着顺序,生怕传错。
什么时候该用? 当你发现一组参数总是同时出现、一起传递的时候。比如经纬度、起止日期、分页参数。
看个例子。这是重构前的代码:
function calculateBookingCost(
checkInDate, checkOutDate,
roomType, roomCount,
customerLevel, discountCode
) {
// 计算逻辑...
}
调用的时候长这样:
const cost = calculateBookingCost(
'2025-06-01', '2025-06-05',
'deluxe', 2,
'gold', 'SUMMER20'
);
嗯,你看出问题了吧?参数太多,而且checkInDate和checkOutDate明显是一对,roomType和roomCount也是一对。这时候就该引入参数对象了。
重构后:
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个参数。我花了半天时间,把它们分成了三个参数对象:CustomerInfo、OrderItems、ShippingDetails。改完之后,调用代码从一团乱麻变成了清晰的三行。测试也好写了。
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