译者 | 刘汪洋
审校 | 重楼
多年来,我招聘了许多开发人员,其中一些人坚信代码需要频繁重构。然而,事实是,几乎每次他们完成重构并将代码交付给其他开发人员时,大家往往发现这些代码反而变得更难理解和维护。更糟糕的是,重构后的代码通常运行效率更低,且问题频发。
需要明确的是,重构本身并无不妥。它是保持代码库健康和可持续发展的关键。然而,不当的重构会带来负面影响,试图改进代码时出现的错误,往往会适得其反,这种情况并不罕见。
接下来,我们将探讨如何区分好的重构与不良重构,并讨论如何避免成为那个让团队成员都不愿意接触代码库的开发者。
重构的优点、缺点与陷阱
在编程中,抽象既可能带来好处,也可能造成问题,关键在于何时以及如何应用。下面,我们将探讨一些常见的陷阱,并讨论如何避免这些问题。
1. 大幅改变编码风格
我经常看到开发人员在重构过程中完全改变编码风格,这是最常见的错误之一。通常,这种情况发生在开发人员来自不同背景或对某种编程范式有强烈偏好的情况下。
让我们来看一个例子。假设我们有一段需要重构的代码:
重构前:
// 🫤 这段代码可以更简洁
function processUsers(users: User[]) {
const result = [];
for (let i = 0; i < users.length; i++) {
if (users[i].age >= 18) {
const formattedUser = {
name: users[i].name.toUpperCase(),
age: users[i].age,
isAdult: true
};
result.push(formattedUser);
}
}
return result;
}
不好的重构:
import * as R from 'ramda';
// 🚩 采用了完全不同的风格和库
const processUsers = R.pipe(
R.filter(R.propSatisfies(R.gte(R.__, 18), 'age')),
R.map(R.applySpec({
name: R.pipe(R.prop('name'), R.toUpper),
age: R.prop('age'),
isAdult: R.always(true)
}))
);
尽管这个重构版本可能会受到函数式编程爱好者的青睐,但它引入了一个新库(Ramda)以及一种完全不同的编码风格。对于不熟悉这种方法的团队来说,维护起来可能会非常困难。
好的重构:
// ✅ 更加简洁且更符合惯例
function processUsers(users: User[]): FormattedUser[] {
return users
.filter(user => user.age >= 18)
.map(user => ({
name: user.name.toUpperCase(),
age: user.age,
isAdult: true
}));
}
这个版本通过使用 JavaScript 的 filter
和 map
方法改进了原始代码。它不仅更简洁易读,而且保持了代码库的连贯性,没有引入新的编程范式或外部依赖。
2. 不必要的抽象
我曾经遇到一位开发人员,他在不了解底层代码的情况下,添加了大量不必要的抽象。他将一些不应归类在一起的组件强行合并,导致代码变得难以维护。此外,他还将不应合并的配置(例如不同 API 需要的配置)混在一起,使得后续管理变得复杂。
重构前:
// 🫤 这段代码可以更简洁
function processUsers(users: User[]) {
const result = [];
for (let i = 0; i < users.length; i++) {
if (users[i].age >= 18) {
const formattedUser = {
name: users[i].name.toUpperCase(),
age: users[i].age,
isAdult: true
};
result.push(formattedUser);
}
}
return result;
}
不好的重构:
// 🚩 这里引入了过多的层级和抽象
class UserProcessor {
private users: User[];
constructor(users: User[]) {
this.users = users;
}
public process(): FormattedUser[] {
return this.filterAdults().formatUsers();
}
private filterAdults(): UserProcessor {
this.users = this.users.filter(user => user.age >= 18);
return this;
}
private formatUsers(): FormattedUser[] {
return this.users.map(user => ({
name: this.formatName(user.name),
age: user.age,
isAdult: true
}));
}
private formatName(name: string): string {
return name.toUpperCase();
}
}
const processUsers = (users: User[]): FormattedUser[] => {
return new UserProcessor(users).process();
};
这个重构引入了一个多方法的类,虽然看似更“面向对象”,但实际上增加了不必要的复杂性,导致代码变得难以理解和维护。
好的重构:
// ✅ 更加简洁且更符合惯例
const isAdult = (user: User): boolean => user.age >= 18;
const formatUser = (user: User): FormattedUser => ({
name: user.name.toUpperCase(),
age: user.age,
isAdult: true
});
function processUsers(users: User[]): FormattedUser[] {
return users.filter(isAdult).map(formatUser);
}
这个版本通过将逻辑分解为小而可重用的函数,避免了不必要的抽象,使代码更加简洁明了且易于维护。
3. 增加不一致性
有些开发人员在试图优化代码时,会对代码库的某个部分进行改动,使其与其他部分的实现方式完全不同。这种不一致会导致其他开发人员在处理不同风格的代码时感到困惑和沮丧。
假设我们有一个 React 应用,其中所有数据获取都使用 React Query:
// 应用程序的其他部分
import { useQuery } from 'react-query';
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery(['user', userId], fetchUser);
if (isLoading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
然而,某位开发人员在一个组件中决定改用 Redux Toolkit:
// 不一致的做法
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPosts } from './postsSlice';
function PostList() {
const dispatch = useDispatch();
const { posts, status } = useSelector((state) => state.posts);
useEffect(() => {
dispatch(fetchPosts());
}, [dispatch]);
if (status === 'loading') return <div>Loading...</div>;
return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}
这种不一致做法令人困惑,因为它仅为一个组件引入了完全不同的状态管理方式。
更好的做法是保持一致,继续使用 React Query:
// 保持一致的做法
import { useQuery } from 'react-query';
function PostList() {
const { data: posts, isLoading } = useQuery('posts', fetchPosts);
if (isLoading) return <div>Loading...</div>;
return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}
这个版本在整个应用中保持了一致性,继续使用 React Query 进行数据获取。这样不仅简化了代码,还避免了其他开发人员因单一组件而不得不学习新的模式。
请记住,代码库中的一致性至关重要。如果你需要引入新的模式,请确保先与团队达成共识,而不是孤立地引入不一致性。
4. 在未理解代码的情况下进行重构
我见过的最大问题之一是,有些开发人员在尚未充分理解代码的情况下进行重构,以此作为学习代码的手段。这种做法往往会带来严重后果。经验表明,你应该至少在使用某段代码 6-9 个月后再进行重构,否则可能会引入 bug 或降低性能。
重构前:
// 🫤 这里的硬编码较多
function fetchUserData(userId: string) {
const cachedData = localStorage.getItem(`user_${userId}`);
if (cachedData) {
return JSON.parse(cachedData);
}
return api.fetchUser(userId).then(userData => {
localStorage.setItem(`user_${userId}`, JSON.stringify(userData));
return userData;
});
}
不好的重构:
// 🚩 缓存机制被移除
function fetchUserData(userId: string) {
return api.fetchUser(userId);
}
重构者可能认为他们简化了代码,但实际上他们移除了减少 API 调用并提升性能的缓存机制。
好的重构:
// ✅ 保留现有行为的同时简化了代码
async function fetchUserData(userId: string) {
const cachedData = await cacheManager.get(`user_${userId}`);
if (cachedData) {
return cachedData;
}
const userData = await api.fetchUser(userId);
await cacheManager.set(`user_${userId}`, userData, { expiresIn: '1h' });
return userData;
}
这个重构保留了缓存行为,并通过使用更复杂的缓存管理器(带有过期时间设置)来优化缓存机制。
5. 理解业务背景
在未充分理解业务背景的情况下进行重构,可能导致项目失败。我曾在一家维护大量遗留代码的公司工作,并领导了一个将其电商平台迁移到新技术上的项目。当时我们选择了 Angular.js 作为技术栈。然而,这家公司高度依赖 SEO,而我们却构建了一个缓慢且臃肿的单页应用程序(SPA)。两年后,发布的结果是一个更慢且漏洞百出的难以维护的网站。原因在于,作为项目负责人,我之前从未在这个网站上工作过,缺乏对其业务背景的理解。我当时年轻且经验不足。
让我们来看看一个类似的现代例子:
不好的重构:
// 🚩 为依赖 SEO 的网站构建单页应用程序是个糟糕的主意
function App() {
return (
<Router>
<Switch>
<Route path="/product/:id" component={ProductDetails} />
</Switch>
</Router>
);
}
这种代码看似现代且简洁,但它完全依赖客户端渲染。对于一个高度依赖 SEO 的电商网站,这可能会带来灾难性的后果。
好的重构:
// ✅ 为 SEO 优化的网站使用服务器端渲染
export const getStaticProps: GetStaticProps = async () => {
const products = await getProducts();
return { props: { products } };
};
export default function ProductList({ products }) {
return (
<div>
...
</div>
);
}
这个基于 Next.js 的方法提供了开箱即用的服务器端渲染功能,对 SEO 至关重要。它不仅提升了初始页面的加载速度,还为网络连接较慢的用户提供了更好的体验。Remix 也适用于这种场景,提供了类似的服务器端渲染和 SEO 优化优势。
6. 过度整合代码
我曾招聘过一名程序员,他在加入团队的第一天就开始重构代码。我们有许多 Firebase 函数,不同函数在超时和内存分配方面的配置有所不同。 这是我们原有的设置:
重构前:
// 😕 代码库中有超过 40 处相同的代码,或许可以进行整合
export const quickFunction = functions
.runWith({ timeoutSeconds: 60, memory: '256MB' })
.https.onRequest(...);
export const longRunningFunction = functions
.runWith({ timeoutSeconds: 540, memory: '1GB' })
.https.onRequest(...);
这位新人决定将所有这些函数封装到一个 createApi
函数中。
不好的重构:
// 🚩 盲目地整合了不应整合的设置
const createApi = (handler: RequestHandler) => {
return functions
.runWith({ timeoutSeconds: 300, memory: '512MB' })
.https.onRequest((req, res) => handler(req, res));
};
export const quickFunction = createApi(handleQuickRequest);
export const longRunningFunction = createApi(handleLongRunningRequest);
这种重构将所有 API 的配置统一为相同的参数,并且没有提供单独覆盖的选项。问题在于,不同的函数可能需要不同的配置参数。
更好的方法是允许每个 API 传递自定义的 Firebase 配置:
好的重构:
// ✅ 设置了良好的默认值,但允许覆盖
const createApi = (handler: RequestHandler, options: ApiOptions = {}) => {
return functions
.runWith({ timeoutSeconds: 300, memory: '512MB', ...options })
.https.onRequest((req, res) => handler(req, res));
};
export const quickFunction = createApi(handleQuickRequest, { timeoutSeconds: 60, memory: '256MB' });
export const longRunningFunction = createApi(handleLongRunningRequest, { timeoutSeconds: 540, memory: '1GB' });
这种方法在保留抽象优势的同时,也保留了必要的灵活性。在整合或抽象代码时,一定要考虑所属服务的具体用例。不要为了追求“简洁”而牺牲代码的灵活性。确保你的抽象能够满足原始实现的所有需求。
最重要的是,在开始“改进”代码之前,必须深入理解它。我们曾在下一次部署 API 时遇到问题,而这些问题本可以通过避免这种盲目重构来规避。
如何正确地进行重构
正确地进行代码重构至关重要。尽管代码库不可能完美无缺,并且重构在某些时候是必要的,但重构时必须保持代码库的一致性,并在深入理解代码的基础上谨慎处理抽象。
以下是一些成功重构的建议:
- 逐步进行:采取小幅、可控的更改,而非大规模的重写。
- 深入理解代码:在进行重大重构或引入新抽象之前,务必充分理解现有代码。
- 保持与现有代码风格一致:一致性是提升代码可维护性的关键。
- 避免过度抽象:保持简单,除非确实有必要增加复杂性。
- 避免引入新库:特别是那些风格迥异的库,除非团队已达成共识,否则不要轻易引入。
- 在重构前编写测试,并在重构过程中更新测试。这能确保在改进过程中维持原有功能的稳定性。
- 确保团队成员遵循这些原则:确保所有团队成员都遵循这些重构原则,每个成员都应对其负责。
提高重构质量的工具和技巧
为确保重构能有效提升代码质量,可以考虑以下工具和技巧:
Linting 工具
使用 Linting 工具强制执行一致的代码风格并捕捉潜在问题。Prettier 可以自动格式化代码以保持风格一致,而 Eslint 能进行更细致的检查,且支持通过自定义插件适应团队的特殊需求。
代码审查
在合并重构后的代码之前,进行彻底的代码审查并获取同事的反馈。这样有助于及早发现潜在问题,确保代码符合团队标准和预期。
测试
编写并运行测试,确保重构不会破坏现有功能。Vitest 是一个快速、稳定且易于使用的测试工具,默认情况下无需复杂配置。对于视觉测试,可以使用 Storybook。React Testing Library 是测试 React 组件的优秀工具,而 Angular Testing Library 及其他变种适用于不同框架。
AI 工具
合理利用 AI 工具进行重构,特别是那些能够匹配现有编码风格和约定的工具。 Visual Copilot 是一个前端开发中非常有用的 AI 工具,它有助于将设计转化为代码,同时保持编码风格的一致性,并正确使用设计系统的组件和标记。这些工具和技巧可以帮助你在重构过程中保持代码质量,确保重构带来的改进是可持续且有意义的。
这些工具和技巧可以帮助你在重构过程中保持代码质量,确保重构带来的改进是可持续且有意义的。
结论
重构是软件开发中不可或缺的一部分,但它必须经过深思熟虑,并尊重现有代码库和团队的工作方式。重构的目标是在不改变代码外部行为的情况下,优化其内部结构。
请记住,最好的重构往往是让终端用户毫无察觉,却能极大地方便开发人员。通过提升代码的可读性、可维护性和效率,同时保持系统的稳定性,你将为整个团队创造更高的工作效率并减少技术债务。
所以,下次当你有“大计划”要改进某段代码时,先停下来,深入理解这段代码,评估改动可能带来的影响,并选择团队会感谢的渐进式改进方法。未来的你(以及你的同事们)一定会感激你这种周到且维护良好的代码库的做法。
译者介绍
刘汪洋,51CTO社区编辑,昵称:明明如月,一个拥有 5 年开发经验的某大厂高级 Java 工程师,拥有多个主流技术博客平台博客专家称号,博客阅读量 400W+,粉丝 3W+。2022 年腾讯云优秀创作者,2022 年阿里云技术社区最受欢迎技术电子书 TOP 10 《性能优化方法论》作者,慕课网:剖析《阿里巴巴 Java 开发手册》、深度解读《Effective Java》 技术专栏作者。
原文标题:Good Refactoring vs Bad Refactoring,作者:Steve Sewell
链接:https://dev.to/builderio/good-refactoring-vs-bad-refactoring-2361