好的重构与坏的重构
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
这些年来我招过很多开发人员。其中不少人一开始都坚信我们的代码需要大幅重构。但事实是:几乎在所有情况下,其他开发人员都发现他们重构后的代码更难理解和维护,而且通常速度更慢,bug也更多。
别误会我的意思。重构本身并没有错。它是维护代码库健康的关键环节。问题在于,糟糕的重构确实很糟糕。而且,我们很容易陷入这样的陷阱:在试图改进代码的同时,反而让情况变得更糟。
那么,让我们来探讨一下好的重构和坏的重构之间的区别,以及如何避免成为那种人人都害怕在代码库附近看到的开发者。
重构的利弊
抽象既可以是好事,也可以是坏事。关键在于何时以及如何运用抽象。让我们来看看一些常见的陷阱以及如何避免它们。
1. 大幅改变编码风格
我见过最常见的错误之一是开发人员在重构过程中彻底改变编码风格。这种情况通常发生在开发人员背景不同或对某种编程范式有强烈偏好时。
我们来看一个例子。假设我们有一段需要清理的代码:
前:
// 🫤 this code could be cleaner
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';
// 🚩 adopted a completely different style + library
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)和一种完全不同的编码风格。对于不熟悉这种方法的团队来说,维护起来可能会是一场噩梦。
好的重构:
// ✅ cleaner and more conventional
function processUsers(users: User[]): FormattedUser[] {
return users
.filter(user => user.age >= 18)
.map(user => ({
name: user.name.toUpperCase(),
age: user.age,
isAdult: true
}));
}
filter此版本通过使用更符合 JavaScript 惯用风格的方法(例如 ` get()` 和 `get()` )改进了原始代码map。它更简洁、更易读,但并没有引入全新的范式或外部依赖项。
2. 不必要的抽象
我曾经雇佣过一个人,他在不了解底层代码的情况下添加了大量新的抽象概念。他开始将不应该分组的东西分组,并且随着时间的推移,他(有意地)偏离了既定的逻辑。他还合并了一些不应该合并的配置(不同的 API 需要不同的配置)。
前:
// 🫤 this code could be cleaner
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;
}
糟糕的重构:
// 🚩 there are way more layers and abstractions here than necessary
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();
};
这次重构引入了一个具有多个方法的类,这看起来似乎更“面向对象”,但实际上它更复杂,也更难一眼理解。
好的重构:
// ✅ cleaner and more conventional
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 来获取数据:
// Throughout the app
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:
// One-off component
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:
// Consistent approach
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,降低代码性能等等。
前:
// 🫤 a bit too much hard coded stuff here
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;
});
}
糟糕的重构:
// 🚩 where did the caching go?
function fetchUserData(userId: string) {
return api.fetchUser(userId);
}
重构者可能认为他们在简化代码,但实际上他们移除了一个重要的缓存机制,而这个机制原本是为了减少 API 调用并提高性能。
好的重构:
// ✅ cleaner code preserving the existing behavior
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),而我们却开发了一个运行缓慢且臃肿的单页应用程序。
两年间,我们除了一个速度更慢、漏洞百出、维护难度更高的网站复制品之外,什么都没推出。为什么?因为负责这个项目的人(也就是我——我就是造成这一切的罪魁祸首)之前都没接触过这个网站。我当时年轻无知。
让我们来看一个现代的例子:
糟糕的重构:
// 🚩 a single page app for an SEO-focused site is a bad idea
function App() {
return (
<Router>
<Switch>
<Route path="/product/:id" component={ProductDetails} />
</Switch>
</Router>
);
}
这种方法看似现代简洁,但完全是客户端渲染。对于严重依赖搜索引擎优化的电商网站来说,这可能会是灾难性的。
好的重构:
// ✅ server render an SEO-focused site
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 函数,它们的设置各不相同,比如超时时间和内存分配。
这是我们最初的配置。
前:
// 😕 we had this same code 40+ times in the codebase, we could perhaps consolidate
export const quickFunction = functions
.runWith({ timeoutSeconds: 60, memory: '256MB' })
.https.onRequest(...);
export const longRunningFunction = functions
.runWith({ timeoutSeconds: 540, memory: '1GB' })
.https.onRequest(...);
这个人决定将所有这些功能封装到一个createApi函数中。
糟糕的重构:
// 🚩 blindly consolidating settings that should not be
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 选项通过 API 传递。
好的重构:
// ✅ setting good defaults, but letting anyone override
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 时就遇到了问题,而这些问题本来是可以避免的,当初这种盲目重构是行不通的。
如何正确重构
值得注意的是,代码重构是必要的。但要正确地进行重构。我们的代码并不完美,需要清理,但要保持与代码库的一致性,熟悉代码,并谨慎选择抽象层。
以下是一些成功重构的技巧:
- 循序渐进:进行小的、可管理的更改,而不是进行大刀阔斧的重写。
- 在进行重大重构或新的抽象之前,务必深入理解代码。
- 与现有代码风格保持一致:一致性是可维护性的关键。
- 避免引入过多新的抽象概念:除非确实有必要,否则保持简单。
- 未经团队同意,应避免添加新的库,尤其是编程风格截然不同的库。
- 重构之前先编写测试,并随着重构的进行不断更新测试。这样可以确保保留原有的功能。
- 要求你的同事遵守这些原则。
用于改进重构的工具和技巧
为了确保重构是有益的而不是有害的,请考虑以下技术和工具:
语法检查工具
使用代码检查工具来强制执行一致的代码风格并发现潜在问题。Prettier可以帮助自动格式化为一致的风格,而ESLint可以提供更细致的一致性检查,并且您可以使用自己的插件轻松自定义这些检查。
代码审查
在合并重构代码之前,务必进行全面的代码审查,以获取同事的反馈。这有助于及早发现潜在问题,并确保重构后的代码符合团队标准和预期。
测试
编写并运行测试,确保重构后的代码不会破坏现有功能。Vitest是一款速度极快、稳定可靠且易于使用的测试运行器,默认情况下无需任何配置。对于视觉测试,可以考虑使用Storybook。React Testing Library是一套用于测试 React 组件的优秀工具集(也支持Angular和其他变体)。
(右图)人工智能工具
让 AI 帮助你进行代码重构,至少可以帮助你实现与现有编码风格和规范相匹配的代码重构。
Visual Copilot是一款在前端编码过程中保持代码一致性的实用工具。这款人工智能驱动的工具可以帮助您将设计稿转化为代码,同时保持与您现有编码风格一致,并正确利用您的设计系统组件和标记。
结论
重构是软件开发中必不可少的一部分,但必须谨慎进行,并尊重现有的代码库和团队协作方式。重构的目标是在不改变代码外部行为的前提下,改进代码的内部结构。
记住,最好的重构通常对最终用户来说是不可见的,但却能显著简化开发人员的工作。它们在不影响整个系统的情况下,提高了代码的可读性、可维护性和效率。
下次当你想要对一段代码进行“大刀阔斧”的修改时,不妨先退一步。彻底理解代码,考虑修改的影响,并进行循序渐进的改进,你的团队最终会感谢你的。
未来的你(以及你的同事)会欣赏这种保持代码库整洁和易于维护的周全方法。
哦,对了,你喜欢看YouTube视频吗?我本人也做过一个关于这个主题的视频:
关于我
大家好,我是Builder.io的 CEO Steve 。我们开发开发者工具,比如 Visual Copilot,它可以将 Figma 设计稿转换成高质量的代码。你应该试试看。
文章来源:https://dev.to/builderio/good-refactoring-vs-bad-refactoring-2361