7、基本重构手法(上):提炼函数、内联函数、提炼变量、内联变量、改变函数声明、封装变量
重构这件事,说白了就是「在不改变外部行为的前提下,改善内部结构」。听起来简单,做起来全是细节。我这些年带团队,发现很多同学不是不想重构,而是不知道从哪下手。今天咱们就把最基础的六种手法掰开揉碎讲清楚。
这六种手法,就像木匠手里的刨子、凿子、锤子。单独看都不复杂,但组合起来能解决大部分代码坏味道。我个人习惯把它们分成两组:一组是「提炼」和「内联」的对称操作,另一组是「改变声明」和「封装变量」的结构调整。
提炼函数:把意图从实现中解放出来
提炼函数是最基础、最常用的重构手法。它的核心逻辑很简单:当你看到一段代码需要注释才能看懂时,就该把它提炼成一个函数。
我在项目中遇到过最典型的场景:一个函数写了300行,里面混杂着数据校验、业务计算、日志输出。每次改需求都提心吊胆。后来我强制自己:如果一个函数超过20行,就停下来想想能不能拆。
提炼函数的关键判断标准
- 代码片段有明确的「做什么」的意图
- 片段内部存在重复逻辑
- 片段与周围代码的耦合度低
- 片段长度超过5-10行
举个例子,看看这段代码:
// 重构前
function printOwing(invoice) {
let outstanding = 0;
for (const o of invoice.orders) {
outstanding += o.amount;
}
console.log(`客户:${invoice.customer}`);
console.log(`欠款:${outstanding}`);
console.log(`日期:${new Date().toLocaleDateString()}`);
}
这段代码干了三件事:计算欠款、打印客户信息、打印日期。咱们把计算和打印分开:
// 重构后
function printOwing(invoice) {
const outstanding = calculateOutstanding(invoice);
printDetails(invoice, outstanding);
}
function calculateOutstanding(invoice) {
let result = 0;
for (const o of invoice.orders) {
result += o.amount;
}
return result;
}
function printDetails(invoice, outstanding) {
console.log(`客户:${invoice.customer}`);
console.log(`欠款:${outstanding}`);
console.log(`日期:${new Date().toLocaleDateString()}`);
}
我的小技巧:提炼函数时,先想好函数名。如果名字很难起,说明这段代码可能「职责不单一」。我曾经花10分钟想一个函数名,最后发现应该拆成两个函数。
内联函数:当间接层变成负担
有提炼就有内联。内联函数是提炼函数的逆操作。什么时候需要内联?当函数体比函数名更容易理解时。
你想想看,有些函数就是一行代码,比如:
function getRating(driver) {
return moreThanFiveLateDeliveries(driver) ? 2 : 1;
}
function moreThanFiveLateDeliveries(driver) {
return driver.numberOfLateDeliveries > 5;
}
这种间接层完全没有必要。直接内联:
function getRating(driver) {
return driver.numberOfLateDeliveries > 5 ? 2 : 1;
}
注意:内联函数不是「删代码」,而是「消除不必要的间接层」。我曾经见过有人把内联理解成「把所有函数都写在一起」,结果搞出一个500行的怪物函数。内联的前提是:函数本身已经足够简单,不需要名字来解释。
提炼变量:给表达式一个名字
提炼变量和提炼函数思路类似,只不过对象是表达式而不是语句块。当表达式太复杂时,给它一个名字,代码瞬间就清晰了。
我记得有一次review代码,看到这样的表达式:
if (order.price * order.quantity - order.discount > 1000) {
// 处理大额订单
}
提炼变量后:
const basePrice = order.price * order.quantity;
const finalPrice = basePrice - order.discount;
const isLargeOrder = finalPrice > 1000;
if (isLargeOrder) {
// 处理大额订单
}
嗯,这里要注意:提炼变量不是越多越好。如果一个变量只使用一次,而且表达式本身很简单,那就没必要提炼。我见过有人把 const a = 1 + 2 也提炼成变量,这就过度了。
内联变量:去掉多余的中间人
内联变量是提炼变量的逆操作。当变量名不比表达式本身更清晰时,就把它内联掉。
举个例子:
const basePrice = order.basePrice;
const quantity = order.quantity;
const total = basePrice * quantity;
如果 basePrice 和 quantity 只使用一次,直接内联更简洁:
const total = order.basePrice * order.quantity;
什么时候该内联变量?
- 变量只使用一次
- 变量名没有提供额外信息
- 表达式本身很简短
改变函数声明:改名、改参数、改签名
函数声明是函数的「门面」。一个好的函数名,胜过千言万语。改变函数声明包括:改名、增删参数、改变参数顺序。
我见过最糟糕的函数名是 processData() —— 它几乎等于什么都没说。还有 doSomething()、handleIt(),这些名字在代码里就是灾难。
改名看起来简单,但有个坑:如果函数被多处调用,改名会影响所有调用方。我的建议是分两步走:
- 先添加新函数,把旧函数委托给新函数
- 逐个修改调用方,最后删除旧函数
// 第一步:添加新函数
function calculateTotalPrice(order) {
return order.price * order.quantity;
}
// 旧函数委托给新函数
function calcPrice(order) {
return calculateTotalPrice(order);
}
// 第二步:逐个修改调用方,最后删除 calcPrice
避坑指南:我曾经一次性改了20个调用方的函数名,结果漏了一个,编译报错找了一下午。后来我学乖了,用IDE的重构功能,或者至少用全局搜索确认所有调用点。
封装变量:给数据加上「看门人」
封装变量,说白了就是把直接访问的变量变成通过函数访问。为什么要这么做?因为直接暴露的数据,一旦需要增加逻辑(比如校验、缓存、日志),所有调用方都得改。
看个例子:
// 重构前:直接暴露
let defaultOwner = { firstName: "张三", lastName: "李四" };
// 重构后:封装
function getDefaultOwner() {
return Object.assign({}, defaultOwner); // 返回副本,防止外部修改
}
function setDefaultOwner(owner) {
// 可以加校验逻辑
if (!owner.firstName || !owner.lastName) {
throw new Error("owner 必须包含 firstName 和 lastName");
}
defaultOwner = owner;
}
重要提醒:封装变量不只是加 getter/setter。关键是要控制数据的「读写权限」和「副作用」。我见过有人封装了变量,但 getter 直接返回原始引用,外部照样能修改内部状态——这就白封装了。
封装变量的另一个好处是:将来可以轻松改成「计算属性」或「延迟加载」。比如:
function getExpensiveData() {
if (!cachedData) {
cachedData = computeExpensiveData();
}
return cachedData;
}
这种改动,调用方完全无感知。这就是封装的魅力。
这六种手法,我建议你从「提炼函数」和「提炼变量」开始练手。它们最容易上手,也最容易看到效果。等你熟练了,再尝试「内联」和「改变声明」。最后才是「封装变量」——因为它涉及数据所有权,改起来要更谨慎。
记住一句话:重构不是炫技,是为了让代码更容易理解和修改。每次重构前问自己:「这样做,下一个读代码的人会更容易理解吗?」如果答案是肯定的,那就动手吧。