作者:James Nash
原文:Applying SOLID principles in React 发布于 2022 年 7 月 12 日
译者:legend80s@
JavaScript与编程艺术
🤝 AI价值:详细介绍 SOLID 原则如何让 React 代码变得更易维护,看了诸多文章唯独该篇的案例无 class 完全使用 hooks 更贴近我们日常开发,希望带给你启发。如日常封装组件不支持传入 className 和 style 就违反了里氏替换原则。 本文字数较多,希望大家耐心看完一定会有和别处不一样的收获。
随着软件行业的不断发展和犯错,最佳实践和良好的软件设计原则逐渐形成和概念化,以避免在未来重复相同的错误。面向对象编程(OOP)的世界尤其是一个充满这类最佳实践的宝库,而 SOLID 毫无疑问是其中最具影响力的原则之一。
SOLID 是一个缩写词,其中每个字母代表五个设计原则之一:
- 单一职责原则(SRP)
- 开闭原则(OCP)
- 里氏替换原则(LSP)
- 接口分离原则(ISP)
- 依赖倒置原则(DIP)
在本文中,我们将讨论每个原则的重要性,并看看如何将 SOLID 原则应用于 React 应用程序。
不过,在开始之前,需要指出一个重要的问题。SOLID 原则是基于面向对象编程语言提出的,其解释严重依赖于类和接口的概念,而 JavaScript 并没有真正意义上的类和接口。我们通常认为的 JavaScript 中的"类"实际上是通过其原型系统模拟出来的类似类的结构,而接口根本不是语言的一部分(尽管 TypeScript 的加入在这方面有所帮助)。更重要的是,我们编写的现代 React 代码远非面向对象,如果有什么的话,它更像是函数式的。
好消息是,像 SOLID 这样的软件设计原则是语言无关的,并且具有较高的抽象层次,这意味着如果我们稍微放宽一些解释,我们就可以将它们应用到我们更偏向函数式的 React 代码中。
那么,让我们开始吧。
🥇 单一职责原则(SRP)
原始定义指出"每个类应该只有一个职责",即只做一件事。我们可以简单地将这个定义扩展为"每个函数/模块/组件应该只做一件事",但要理解"一件事"的含义,我们需要从两个不同的角度来审视我们的组件------内部(即组件内部做了什么)和外部(其他组件是如何使用这个组件的)。
我们先从内部开始。为了确保我们的组件内部只做一件事,我们可以:
- 将功能过多的大组件拆分为更小的组件;
- 将与主组件功能无关的代码提取到单独的工具函数中;
- 将相关功能封装到自定义钩子中。
现在,让我们看看如何应用这个原则。我们先考虑以下示例组件,它显示一个活跃用户的列表:
jsx
const ActiveUsersList = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
const loadUsers = async () => {
const response = await fetch('/some-api');
const data = await response.json();
setUsers(data);
};
loadUsers();
}, []);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return (
<ul>
{users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user =>
<li key={user.id}>
<img src={user.avatarUrl} />
<p>{user.fullName}</p>
<small>{user.role}</small>
</li>
)}
</ul>
);
};
尽管这个组件现在相对较短,但它已经做了很多事情------它获取数据、过滤数据、渲染组件本身以及各个列表项。让我们看看如何将其拆分。
首先,每当我们在组件中同时使用 useState
和 useEffect
钩子时,这是一个将它们提取到自定义钩子中的好机会:
jsx
const useUsers = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
const loadUsers = async () => {
const response = await fetch('/some-api');
const data = await response.json();
setUsers(data);
};
loadUsers();
}, []);
return { users };
};
const ActiveUsersList = () => {
const { users } = useUsers();
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return (
<ul>
{users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user =>
<li key={user.id}>
<img src={user.avatarUrl} />
<p>{user.fullName}</p>
<small>{user.role}</small>
</li>
)}
</ul>
);
};
现在,我们的 useUsers
钩子只关心一件事------从 API 获取用户数据。它还使我们的主组件更具可读性,不仅因为它变得更短了,还因为我们用一个域钩子替换了需要解析用途的结构化钩子,而这个域钩子的用途从其名称上就可以立即看出。
接下来,让我们看看我们的组件渲染的 JSX。每当我们在组件中循环映射一个对象数组时,我们需要注意它为每个数组项生成的 JSX 的复杂性。如果它是一个没有附加任何事件处理器的一行代码,那么将其保留在内联代码中是可以的,但对于更复杂的标记,将其提取到一个单独的组件中可能是一个好主意:
jsx
const UserItem = ({ user }) => {
return (
<li>
<img src={user.avatarUrl} />
<p>{user.fullName}</p>
<small>{user.role}</small>
</li>
);
};
const ActiveUsersList = () => {
const { users } = useUsers();
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return (
<ul>
{users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo).map(user =>
<UserItem key={user.id} user={user} />
)}
</ul>
);
};
与之前的更改一样,我们将渲染用户项的逻辑提取到一个单独的组件中,从而使主组件变得更小、更具可读性。
最后,我们还有从 API 获取的所有用户中筛选出活跃用户的逻辑。这个逻辑相对独立,并且可以在应用程序的其他部分重用,因此我们可以轻松地将其提取到一个工具函数中:
jsx
const getOnlyActive = (users) => {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return users.filter(user => !user.isBanned && user.lastActivityAt >= weekAgo);
};
const ActiveUsersList = () => {
const { users } = useUsers();
return (
<ul>
{getOnlyActive(users).map(user =>
<UserItem key={user.id} user={user} />
)}
</ul>
);
};
到目前为止,我们的主组件已经足够简洁明了,我们可以停止进一步拆分。然而,如果我们仔细观察,我们会发现它仍然在做不止一件事。目前,我们的组件从 API 获取数据,然后对其应用过滤,但理想情况下,我们只想获取数据并渲染它,而无需进行任何额外的操作。因此,作为最后一步改进,我们可以将这个逻辑封装到一个新的自定义钩子中:
jsx
const useActiveUsers = () => {
const { users } = useUsers();
const activeUsers = useMemo(() => {
return getOnlyActive(users);
}, [users]);
return { activeUsers };
};
const ActiveUsersList = () => {
const { activeUsers } = useActiveUsers();
return (
<ul>
{activeUsers.map(user =>
<UserItem key={user.id} user={user} />
)}
</ul>
);
};
在这里,我们创建了 useActiveUsers
钩子来处理获取和筛选数据的逻辑(我们还对筛选后的数据进行了记忆化处理,以确保性能),而我们的主组件只做最基本的------渲染从钩子中获取的数据。
现在,根据我们对"一件事"的解释,我们可以争论说组件仍然在先获取数据,然后渲染它,这并不是"一件事"。我们甚至可以进一步拆分,将调用钩子的操作放在一个组件中,然后将结果作为属性传递给另一个组件,但在实际应用中,我很少发现这种做法真正有益,所以让我们对定义宽容一些,接受"渲染组件获取的数据"作为"一件事"。
现在,我们来看外部视角。我们的组件永远不会孤立存在,相反,它们是更大的系统的一部分,它们通过向其他组件提供功能或使用其他组件提供的功能来进行交互。因此,从外部来看,单一职责原则关注的是一个组件可以用于多少种用途。
为了更好地理解这一点,让我们考虑以下示例。想象一个类似 Telegram 或 FaceBook Messenger 的消息应用,以及一个显示单条消息的组件。它可以像这样简单:
jsx
const Message = ({ text }) => {
return (
<div>
<p>{text}</p>
</div>
);
};
如果我们想支持发送图片,组件会变得稍微复杂一些:
jsx
const Message = ({ text, imageUrl }) => {
return (
<div>
{imageUrl && <img src={imageUrl} />}
{text && <p>{text}</p>}
</div>
);
};
进一步地,如果我们想支持语音消息,组件会变得更加复杂:
jsx
const Message = ({ text, imageUrl, audioUrl }) => {
if (audioUrl) {
return (
<div>
<audio controls>
<source src={audioUrl} />
</audio>
</div>
);
}
return (
<div>
{imageUrl && <img src={imageUrl} />}
{text && <p>{text}</p>}
</div>
);
};
不难想象,随着时间的推移,当我们添加对视频、贴纸等的支持时,这个组件会不断膨胀,最终变成一团乱麻。让我们回顾一下发生了什么。
一开始,我们的组件符合单一职责原则,它只做一件事------渲染消息。然而,随着应用程序的发展,我们逐渐向其中添加了越来越多的功能。我们从渲染逻辑中的小条件变化开始,然后更加激进地完全替换渲染树,而在某个时刻,原本对"一件事"的定义变得过于宽泛、过于通用。我们从一个单一用途的组件开始,最终却变成了一个多功能的"万能组件"。
解决这个问题的方法是摒弃通用的 Message
组件,转而使用更专业化的、单一用途的组件:
jsx
const TextMessage = ({ text }) => {
return (
<div>
<p>{text}</p>
</div>
);
};
const ImageMessage = ({ text, imageUrl }) => {
return (
<div>
<img src={imageUrl} />
{text && <p>{text}</p>}
</div>
);
};
const AudioMessage = ({ audioUrl }) => {
return (
<div>
<audio controls>
<source src={audioUrl} />
</audio>
</div>
);
};
这些组件内部的逻辑彼此非常不同,因此它们自然会独立演化。
应该指出的是,这类问题总是随着应用程序的增长逐渐出现。你希望重用一个几乎能满足需求的现有组件,于是添加了一个额外的属性并相应地调整了内部逻辑。下次,其他人遇到类似的情况时,他们没有创建独立的组件并提取共享逻辑,而是添加了另一个参数和另一个 if
判断。问题就这样不断累积。
要打破这个循环,下次当你准备调整一个现有组件以适应你的需求时,考虑一下你这样做的原因是因为它有意义且能让组件更具可复用性,还是仅仅因为你想偷懒。警惕"万能组件"的问题,并注意如何定义它的单一职责。
从实践角度来看,一个组件是否已经超出了其最初目的并需要拆分的一个良好迹象是,组件中存在大量 if
判断来改变行为。这也适用于普通的 JavaScript 函数------如果你不断添加控制函数内部执行流程的参数以产生不同的结果,那么你可能面对的是一个功能过多的函数。另一个迹象是拥有大量可选属性的组件。如果在不同的上下文中使用该组件时,你总是传递一组不同的属性,那么很可能你面对的实际上是一个伪装成一个组件的多个组件。
总结一下,单一职责原则关注的是保持组件的小巧和单一用途。这样的组件更容易理解、测试和修改,我们也不太可能引入无意的代码重复。
🔒 开闭原则(OCP)
开闭原则指出:"软件实体应该对扩展开放,但对修改关闭"。由于我们的 React 组件和函数也是软件实体,我们不需要对这一定义进行任何调整,而是可以直接采用其原始形式。
开闭原则倡导我们以一种方式构建组件,使其可以在不修改原始代码的情况下进行扩展。为了更好地理解这一点,让我们考虑以下场景------我们正在开发一个应用程序,在不同的页面上使用共享的 Header
组件,而根据当前所在的页面,Header
应该渲染略有不同的 UI:
jsx
const Header = () => {
const { pathname } = useRouter();
return (
<header>
<Logo />
<Actions>
{pathname === '/dashboard' && <Link to="/events/new">创建活动</Link>}
{pathname === '/' && <Link to="/dashboard">进入仪表盘</Link>}
</Actions>
</header>
);
};
const HomePage = () => (
<>
<Header />
<OtherHomeStuff />
</>
);
const DashboardPage = () => (
<>
<Header />
<OtherDashboardStuff />
</>
);
在这里,我们根据当前所在的页面渲染不同的链接组件。很容易意识到,如果我们在应用程序中添加更多页面,这种实现方式就会变得糟糕。每次创建一个新页面时,我们都需要回到 Header
组件并调整其实现,以确保它知道要渲染哪个操作链接。这种方法会使我们的 Header
组件变得脆弱,并且与使用它的上下文紧密耦合,这违背了开闭原则。
为了解决这个问题,我们可以使用组件组合。我们的 Header
组件不需要关心它将渲染什么内容,而是可以将这个责任委托给使用它的组件,通过 children
属性来实现:
jsx
const Header = ({ children }) => (
<header>
<Logo />
<Actions>{children}</Actions>
</header>
);
const HomePage = () => (
<>
<Header>
<Link to="/dashboard">进入仪表盘</Link>
</Header>
<OtherHomeStuff />
</>
);
const DashboardPage = () => (
<>
<Header>
<Link to="/events/new">创建活动</Link>
</Header>
<OtherDashboardStuff />
</>
);
通过这种方式,我们完全移除了 Header
组件内部的可变逻辑,现在可以使用组合来插入任何我们想要的内容,而无需修改组件本身。可以这样理解:我们在组件中提供了一个占位符,我们可以将其插入任何内容。而且,我们并不局限于每个组件只有一个占位符------如果需要多个扩展点(或者 children
属性已经被用于其他目的),我们可以使用任意数量的属性来实现。如果我们需要从 Header
向使用它的组件传递一些上下文,我们可以使用渲染属性模式。正如你所看到的,组合可以非常强大。
遵循开闭原则,我们可以减少组件之间的耦合,并使它们更具可扩展性和可复用性。
🔁 里氏替换原则(LSP)
里氏替换原则建议设计对象的方式应该是:"子类型对象应该可以替换超类型对象"。在其原始定义中,子类型/超类型的关系是通过类继承实现的,但情况并非如此。从更广泛的意义上说,继承仅仅是基于一个对象创建另一个对象,同时保留类似的实现,而我们在 React 中经常这样做。
一个非常基础的子类型/超类型关系的例子可以通过使用 styled-components
库(或任何其他使用类似语法的 CSS-in-JS 库)构建的组件来展示:
jsx
import styled from 'styled-components';
const Button = (props) => { /* ... */ };
const StyledButton = styled(Button)`
border: 1px solid black;
border-radius: 5px;
`;
const App = () => {
return <StyledButton onClick={handleClick} />;
};
在上述代码中,我们基于 Button
组件创建了 StyledButton
。这个新的 StyledButton
组件添加了一些 CSS 样式,但它保留了原始 Button
的实现,因此,在这个上下文中,我们可以将 Button
和 StyledButton
视为超类型和子类型组件。
此外,StyledButton
也符合它所基于的组件的接口------它接受与 Button
相同的属性。因此,我们可以在应用程序的任何地方轻松地将 StyledButton
替换为 Button
,而不会破坏应用程序或需要进行任何其他更改。这就是遵循里氏替换原则所带来的好处。
这里还有一个更有趣的基于一个组件创建另一个组件的例子:
tsx
type Props = React.InputHTMLAttributes<HTMLInputElement>;
const Input = (props: Props) => { /* ... */ };
const CharCountInput = (props: Props) => {
return (
<div>
<Input {...props} />
<span>字符数:{props.value.length}</span>
</div>
);
};
在上述代码中,我们使用了一个基础的 Input
组件来创建一个增强版本的组件,它还可以显示输入框中的字符数量。尽管我们添加了新的逻辑,但 CharCountInput
仍然保留了原始 Input
组件的功能。组件的接口也保持不变(两个输入组件接受相同的属性),因此再次遵循了里氏替换原则。
在具有共同特征的组件上下文中,里氏替换原则特别有用,例如图标或输入框------一个图标组件应该可以替换为另一个图标,更具体的 DatePickerInput
和 AutocompleteInput
组件应该可以替换为更通用的 Input
组件,等等。然而,我们应该承认,这个原则并不能也不应该总是被遵循。很多时候,我们创建子组件的目的是为了添加其超组件所没有的新功能,而这通常会破坏超组件的接口。这是一个完全有效的用例,我们不应该试图强行在每个地方都应用 LSP。
对于那些确实适用 LSP 的组件,我们需要确保不要不必要地破坏这一原则。让我们看看可能违反这一原则的两种常见方式。
第一种情况是毫无理由地切断部分属性:
tsx
type Props = { value: string; onChange: () => void };
const CustomInput = ({ value, onChange }: Props) => {
// ...一些额外的逻辑
return <input value={value} onChange={onChange} />;
};
在这里,我们重新定义了CustomInput
的属性,而不是使用<input>
期望的属性。结果,我们丢失了<input>
可以接受的大部分属性,从而破坏了它的接口。为了解决这个问题,我们应该使用<input>
期望的属性,并通过展开运算符将它们全部传递下去:
tsx
type Props = React.InputHTMLAttributes<HTMLInputElement>;
const CustomInput = (props: Props) => {
// ...一些额外的逻辑
return <input {...props} />;
};
另一种破坏LSP的方式是为某些属性使用别名。这可能发生在我们想要使用的属性与局部变量命名冲突时:
tsx
type Props = React.HTMLAttributes<HTMLInputElement> & {
onUpdate: (value: string) => void;
};
const CustomInput = ({ onUpdate, ...props }: Props) => {
const onChange = (event) => {
// ...一些逻辑
onUpdate(event.target.value);
};
return <input {...props} onChange={onChange} />;
};
为了避免这种冲突,你应该有一个良好的局部变量命名规范。例如,为每个onSomething
属性匹配一个handleSomething
的局部函数是很常见的:
tsx
type Props = React.HTMLAttributes<HTMLInputElement>;
const CustomInput = ({ onChange, ...props }: Props) => {
const handleChange = (event) => {
// ...一些逻辑
onChange(event);
};
return <input {...props} onChange={handleChange} />;
};
🎈🧩 接口分离原则(ISP)
根据ISP,"客户端不应该依赖于它们不使用的接口。"为了适用于React应用,我们将把它翻译为"组件不应该依赖于它们不使用的属性。"
我们在这里对ISP的定义进行了延伸,但并没有过度延伸------无论是属性还是接口,都可以被视为组件与外部世界(使用它的上下文)之间的契约,因此我们可以在这两者之间找到相似之处。最终,这并不是关于严格遵循定义,而是关于应用通用原则来解决问题。
为了更好地说明ISP所针对的问题,我们将在下一个例子中使用TypeScript。假设有一个应用,用于渲染视频列表:
tsx
type Video = {
title: string;
duration: number;
coverUrl: string;
};
type Props = {
items: Video[];
};
const VideoList = ({ items }: Props) => {
return (
<ul>
{items.map((item) => (
<Thumbnail key={item.title} video={item} />
))}
</ul>
);
};
我们用于每个项目的Thumbnail
组件可能如下所示:
tsx
type Props = {
video: Video;
};
const Thumbnail = ({ video }: Props) => {
return <img src={video.coverUrl} />;
};
Thumbnail
组件虽然小巧简单,但它有一个问题------它期望以video
对象作为属性传入,但实际上只使用了其中的一个属性。
让我们看看为什么这会成为一个问题。假设除了视频,我们还决定在同一列表中显示直播流的缩略图,且这两种媒体资源会混合在一起。
我们将引入一个新的类型来定义直播流对象:
tsx
type LiveStream = {
name: string;
previewUrl: string;
};
这是更新后的VideoList
组件:
tsx
type Props = {
items: Array<Video | LiveStream>;
};
const VideoList = ({ items }: Props) => {
return (
<ul>
{items.map((item) => {
if ('coverUrl' in item) {
// 这是一个视频
return <Thumbnail video={item} />;
} else {
// 这是一个直播流,但我们能拿它怎么办呢?
}
})}
</ul>
);
};
正如你所见,这里有一个问题。我们可以轻松区分视频和直播流对象,但我们不能将后者传递给Thumbnail
组件,因为Video
和LiveStream
是不兼容的。首先,它们的类型不同,TypeScript会立即报错。其次,它们将缩略图URL存储在不同的属性中------视频对象将其称为coverUrl
,直播流对象则称为previewUrl
。这就是依赖于比实际需要更多的属性所带来的问题------组件的可复用性降低了。因此,让我们来修复它。
我们将重构Thumbnail
组件,确保它只依赖于它实际需要的属性:
tsx
type Props = {
coverUrl: string;
};
const Thumbnail = ({ coverUrl }: Props) => {
return <img src={coverUrl} />;
};
通过这个改动,我们现在可以使用它来渲染视频和直播流的缩略图:
tsx
type Props = {
items: Array<Video | LiveStream>;
};
const VideoList = ({ items }: Props) => {
return (
<ul>
{items.map((item) => {
if ('coverUrl' in item) {
// 这是一个视频
return <Thumbnail coverUrl={item.coverUrl} />;
} else {
// 这是一个直播流
return <Thumbnail coverUrl={item.previewUrl} />;
}
})}
</ul>
);
};
接口分离原则倡导最小化系统组件之间的依赖关系,使它们之间的耦合度更低,从而更具可复用性。
🙃 依赖倒置原则(DIP)
依赖倒置原则指出:"应该依赖于抽象,而不是具体实现。"换句话说,一个组件不应该直接依赖于另一个组件,而是它们都应该依赖于某个共同的抽象。在这里,"组件"指的是应用程序的任何部分,无论是React组件、工具函数、模块还是第三方库。这个原则可能在抽象层面上较难理解,因此我们直接进入示例。
下面是一个LoginForm
组件,它在表单提交时将用户凭据发送到某个API:
tsx
import api from '~/common/api';
const LoginForm = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (evt) => {
evt.preventDefault();
await api.login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">登录</button>
</form>
);
};
在这段代码中,我们的LoginForm
组件直接引用了api
模块,因此它们之间存在紧密耦合。这是不好的,因为这种依赖关系使得代码变更更加困难,一个组件的变更会影响其他组件。依赖倒置原则倡导打破这种耦合,让我们看看如何实现。
首先,我们将从LoginForm
组件中移除对api
模块的直接引用,而是通过属性注入所需的逻辑:
tsx
type Props = {
onSubmit: (email: string, password: string) => Promise<void>;
};
const LoginForm = ({ onSubmit }: Props) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (evt) => {
evt.preventDefault();
await onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">登录</button>
</form>
);
};
通过这个改动,我们的LoginForm
组件不再依赖于api
模块。提交凭据到API的逻辑通过onSubmit
回调进行了抽象,现在由父组件提供具体实现逻辑的责任。
为了实现这一点,我们将创建一个连接了api
的LoginForm
版本,将表单提交逻辑委托给api
模块:
tsx
import api from '~/common/api';
const ConnectedLoginForm = () => {
const handleSubmit = async (email, password) => {
await api.login(email, password);
};
return <LoginForm onSubmit={handleSubmit} />;
};
ConnectedLoginForm
组件充当api
和LoginForm
之间的粘合剂,而它们自身则完全独立。我们可以分别对它们进行迭代和测试,而无需担心破坏依赖关系,因为根本不存在依赖关系。只要LoginForm
和api
都遵循约定的共同抽象,整个应用程序就会按预期运行。
在过去,这种创建"无逻辑"展示性组件,然后通过注入逻辑的方式也被许多第三方库使用过。最著名的例子是Redux,它通过connect
高阶组件将组件中的回调属性绑定到dispatch
函数。随着Hooks的引入,这种做法变得不那么相关,但通过高阶组件注入逻辑在React应用中仍然有其用途。
总结一下,依赖倒置原则旨在最小化应用程序不同组件之间的耦合。正如你可能已经注意到的,最小化是SOLID原则中反复出现的主题------从最小化组件的职责范围到最小化组件之间的感知和依赖关系。
总结
尽管SOLID原则起源于面向对象编程(OOP)的问题,但它们的应用范围远不止于此。在本文中,我们通过灵活地解释这些原则,成功地将它们应用到我们的React代码中,使代码更具可维护性和健壮性。
然而,重要的是要记住,盲目遵循这些原则可能会导致过度工程化的代码。因此,我们需要学会识别何时进一步拆解或解耦组件会引入不必要的复杂性,而收益却微乎其微。
<正文完>
SOLID 原则译者总结
1. 单一职责原则(SRP)
- 核心思想:一个组件/函数/模块应该只做一件事。
- 在 React 中的应用:通过拆分大型组件、提取工具函数和封装自定义 Hook,确保组件内部和外部的职责单一化。
- 好处:组件更易于理解和测试,减少代码重复。
2. 开闭原则(OCP)
- 核心思想:软件实体应该对扩展开放,但对修改关闭。
- 在 React 中的应用 :通过组件组合、子组件传递(如通过
children
或 Render Props)来实现扩展,而不修改原始组件。 - 好处:减少组件之间的耦合,提高组件的可复用性和可扩展性。
3. 里氏替换原则(LSP)
- 核心思想:子类型对象应该可以替换超类型对象。
- 在 React 中的应用:通过继承或扩展组件时,确保子组件保留父组件的接口和行为。
- 好处:提高组件的可互换性和可复用性,减少因组件替换带来的错误。
4. 接口分离原则(ISP)
- 核心思想:客户端不应该依赖于它们不使用的接口。
- 在 React 中的应用:组件应该只依赖于它们实际需要的属性,而不是多余的接口。
- 好处:减少组件之间的依赖,提高组件的可复用性和灵活性。
5. 依赖倒置原则(DIP)
- 核心思想:依赖于抽象,而不是具体实现。
- 在 React 中的应用:通过回调、高阶组件或上下文(Context)注入逻辑,而不是直接依赖于具体实现。
- 好处:减少组件之间的直接依赖,提高代码的可维护性和可测试性。
实际应用中的建议
- 适度应用原则:SOLID 原则提供了很好的设计指导,但过度应用可能导致代码复杂化。在实际开发中,需要根据项目的复杂度和需求灵活应用。
- 持续重构:随着项目的演进,组件的职责可能会发生变化。定期回顾代码,识别并拆分过大的组件或逻辑,有助于保持代码的清晰和可维护性。
- 测试驱动开发(TDD):通过编写单元测试,可以更好地验证组件的职责是否单一,以及是否符合开闭原则和依赖倒置原则。
- 团队共识:在团队中推广 SOLID 原则,确保团队成员对代码设计有共同的理解和标准,有助于提高整体代码质量。
结语
SOLID 原则虽然是面向对象编程的产物,但其核心思想在函数式编程和 React 中同样适用。通过灵活地应用这些原则,我们可以构建出更健壮、更可维护的 React 应用。然而,这些原则并不是绝对的,而是需要根据实际情况灵活调整。在实际开发中,我们需要找到平衡,避免过度工程化,同时保持代码的清晰和可维护性。