Skip to main content

React中应用SOLID原则

在 React 中应用 SOLID 原则

随着软件行业的发展和犯错,最佳实践和良好的软件设计原则就会出现并概念化,以避免将来重复同样的错误。面向对象编程 (OOP) 领域尤其是此类最佳实践的金矿,而 SOLID 无疑是最具影响力的实践之一。

SOLID 是一个缩写词,其中每个字母代表五项设计原则中的一项,这些原则是:

  • 单一责任原则(SRP)
  • 开闭原则(OCP)
  • 里氏替换原理(LSP)
  • 接口隔离原则(ISP)
  • 依赖倒置原理(DIP)

在本文中,我们将讨论每个原则的重要性,并了解如何在 React 应用程序中应用从 SOLID 中学到的知识。

但在我们开始之前,有一个很大的警告。SOLID 原则是根据面向对象的编程语言来构思和概述的。这些原则及其解释在很大程度上依赖于类和接口的概念,而 JS 实际上两者都没有。我们通常认为的 JS 中的“类”仅仅是使用其原型系统模拟的类似类,接口根本不是该语言的一部分(尽管添加 TypeScript 确实有所帮助)。更重要的是,我们编写现代 React 代码的方式远非面向对象——如果有的话,那就是它感觉更实用。

不过好消息是,像 SOLID 这样的软件设计原则与语言无关,并且具有高度的抽象性,这意味着如果我们足够努力地观察并自由地进行解释,我们将能够将它们应用到我们更实用的 React 代码中。

所以让我们采取一些自由行动。

单一职责原则(SRP)

最初的定义指出“每个类应该只有一个责任”,也就是只做一件事。我们可以简单地将定义推断为“每个函数/模块/组件应该只做一件事”,但是要理解“一件事”意味着什么,我们需要从两个不同的角度来检查我们的组件 - 内部(意味着组件做什么内部)和外部(其他组件如何使用该组件)。

我们将从内部开始。为了确保我们的组件在内部做一件事,我们可以:

  • 将功能过多的大型组件分解为较小的组件
  • 将与主要组件功能无关的代码提取到单独的实用函数中
  • 将连接的功能封装到自定义挂钩中

现在让我们看看如何应用这个原则。我们首先考虑以下显示活动用户列表的示例组件:

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>
);
};

尽管这个组件现在相对较短,但它已经做了很多事情——它获取数据、过滤数据、呈现组件本身以及各个列表项。让我们看看如何分解它。

首先,每当我们连接时useStateuseEffect钩子,这是将它们提取到自定义钩子中的好机会:

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 的复杂性。如果它是一个没有附加任何事件处理程序的单行代码,那么将其保持内联是完全可以的,但对于更复杂的标记,将其提取到单独的组件中可能是一个好主意:

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 获得的所有用户列表中过滤掉不活跃用户的逻辑。该逻辑相对独立,可以在应用程序的其他部分重用,因此我们可以轻松地将其提取到实用函数中:

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>
);
};

至此,我们的主要组件已经足够简短和简单了,我们可以停止分解它并结束了。然而,如果我们仔细观察,我们会发现它的作用仍然超出了应有的范围。目前,我们的组件正在获取数据,然后对其应用过滤,但理想情况下,我们只想获取数据并渲染它,而不需要任何额外的操作。所以作为最后一个改进,我们可以将这个逻辑封装到一个新的自定义钩子中:

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钩子来处理获取和过滤逻辑(我们还记住了过滤后的数据以实现良好的措施),而我们的主要组件则只做最低限度的工作 - 渲染从钩子获取的数据。

现在,根据我们对“一件事”的解释,我们可以认为组件仍然是首先获取数据,然后渲染它,这不是“一件事”。我们可以进一步拆分它,在一个组件中调用一个钩子,然后将结果作为 props 传递给另一个组件,但我发现很少有情况这在现实世界的应用程序中实际上是有益的,所以让我们原谅这个定义并接受“渲染组件获取的数据”为“一件事”。

现在从外部角度来看。我们的组件从来都不是孤立存在的,相反,它们是更大系统的一部分,在系统中它们通过向其他组件提供功能或使用其他组件提供的功能进行交互。因此,SRP 的外部视图关注的是一个组件可以用于多少用途。

为了更好地理解它,让我们考虑以下示例。想象一个消息应用程序(如 Telegram 或 FB Messenger)和一个显示单条消息的组件。它可以像这样简单:

const Message = ({ text }) => {
return (
<div>
<p>{text}</p>
</div>
);
};

如果我们想将图像与文本一起发送,该组件会变得更复杂一些:

const Message = ({ text, imageUrl }) => {
return (
<div>
{imageUrl && <img src={imageUrl} />}
{text && <p>{text}</p>}
</div>
);
};

更进一步,我们还可以添加对语音消息的支持,这将使组件进一步复杂化:

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>
);
};

不难想象,随着时间的推移,我们添加了对视频、贴纸等的支持,这个组件将不断增长并变得一团糟。让我们回顾一下这里发生的事情。

一开始,我们的组件遵循 SRP,并且它只做一件事——呈现一条消息。然而,随着应用程序的发展,我们逐渐为其添加越来越多的功能。我们从渲染逻辑中的小条件更改开始,然后更积极地完全替换渲染树,并且在此过程中,该组件的“一个事物”的原始定义变得太宽泛、太通用。我们从单一用途的组件开始,最终得到一个多用途的全能型组件。

解决这个问题的方法是摆脱通用 Message 组件,转而使用更专业、单一用途的组件:

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 改变组件行为的语句很好地表明组件已经超出了其最初的用途并需要拆分。它也适用于普通的 JS 函数——如果你不断添加控制函数内部执行流的参数以产生不同的结果,你可能会发现一个函数做得太多了。另一个标志是组件有很多可选的 props。如果您通过在不同上下文中提供不同的属性子集来使用这样的组件,那么您很可能会处理伪装成一个组件的多个组件。

总而言之,单一职责原则涉及保持我们的组件较小且用途单一。这样的组件更容易推理,更容易测试和修改,并且我们不太可能引入无意的代码重复。

开闭原理(OCP)

OCP 规定“软件实体应该对扩展开放,但对修改关闭”。由于我们的 React 组件和函数是软件实体,因此我们根本不需要改变定义,而是可以采用其原始形式。

开放封闭原则主张以允许在不更改原始源代码的情况下扩展组件的方式构建组件。为了看到它的实际效果,让我们考虑以下场景 - 我们正在开发一个 Header 在不同页面上使用共享组件的应用程序,并且根据我们所在的页面,Header 应该呈现略有不同的 UI:

const Header = () => {
const { pathname } = useRouter();

return (
<header>
<Logo />
<Actions>
{pathname === "/dashboard" && (
<Link to="/events/new">Create event</Link>
)}
{pathname === "/" && <Link to="/dashboard">Go to dashboard</Link>}
</Actions>
</header>
);
};

const HomePage = () => (
<>
<Header />
<OtherHomeStuff />
</>
);

const DashboardPage = () => (
<>
<Header />
<OtherDashboardStuff />
</>
);

在这里,我们根据当前所在的页面呈现到不同页面组件的链接。如果我们考虑一下当我们开始添加更多页面时会发生什么,很容易意识到这种实现是糟糕的。每次创建新页面时,我们都需要返回 Header 组件并调整其实现,以确保它知道要呈现哪个操作链接。这种方法使我们的 Header 组件变得脆弱并且与其使用的上下文紧密耦合,并且违背了开闭原则。

为了解决这个问题,我们可以使用组件组合。我们的 Header 组件不需要关心它将在内部渲染什么,相反,它可以使用 childrenprop 将这个责任委托给将使用它的组件:

const Header = ({ children }) => (
<header>
<Logo />
<Actions>{children}</Actions>
</header>
);

const HomePage = () => (
<>
<Header>
<Link to="/dashboard">Go to dashboard</Link>
</Header>
<OtherHomeStuff />
</>
);

const DashboardPage = () => (
<>
<Header>
<Link to="/events/new">Create event</Link>
</Header>
<OtherDashboardStuff />
</>
);

通过这种方法,我们完全删除了内部的变量逻辑Header,现在可以使用组合来放置我们想要的任何东西,而无需修改组件本身。考虑这个问题的一个好方法是,我们在可以插入的组件中提供一个占位符。而且我们也不限于每个组件一个占位符 - 如果我们需要有多个扩展点(或者如果 propschildren 已经用于不同的目的),我们可以使用任意数量的 props。如果我们需要将一些上下文从Header使用它的组件传递,我们可以使用 render props 模式。正如您所看到的,构图的力量非常强大。

遵循开闭原则,我们可以减少组件之间的耦合,并使它们更具可扩展性和可重用性。

里氏替换原理(LSP)

LSP 建议以“子类型对象应该可以替代父类型对象”的方式设计对象。在其原始定义中,子类型/超类型关系是通过类继承实现的,但不一定是这样。从更广泛的意义上讲,继承只是将一个对象基于另一个对象,同时保留类似的实现,这是我们在 React 中经常做的事情。

子类型/父类型关系的一个非常基本的示例可以通过使用 styled-components 库(或使用类似语法的任何其他 CSS-in-JS 库)构建的组件来演示:

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} />;
};

上面的代码中,我们是 StyledButton 基于 Button 组件来创建的。这个新 StyledButton 组件添加了一些 CSS 类,但保留了原始 的实现 Button,因此,在这种情况下,我们可以将 ButtonStyledButton 视为超类型和子类型组件。

此外,StyledButton 还符合其所基于的组件的接口 - 它采用与 Button 自身相同的 props。因此,我们可以轻松地交换应用程序中的任何位置,而无需破坏它或需要进行任何其他更改 StyledButtonButton 这就是我们遵守里氏替换原则所获得的好处。

这是一个将一个组件基于另一个组件的更有趣的示例:

type Props = InputHTMLAttributes<HTMLInputElement>;

const Input = (props: Props) => {
/* ... */
};

const CharCountInput = (props: Props) => {
return (
<div>
<Input {...props} />
<span>Char count: {props.value.length}</span>
</div>
);
};

在上面的代码中,我们使用一个基本 Input 组件来创建它的增强版本,它还可以显示输入中的字符数。虽然我们为其添加了新的逻辑,但 CharCountInput 仍然保留了原始组件的功能 Input。组件的接口也保持不变(两个输入采用相同的 props),因此再次观察到 LSP。

里氏替换原则在共享共同特征的组件(例如图标或输入)的上下文中特别有用 - 一个图标组件应该可以交换为另一个图标,更具体,并且组件应该可以交换为更通用的组件 DatePickerInput,AutocompleteInput 等等 Input。然而,我们应该承认,这一原则不能也不应始终得到遵守。通常,我们创建子组件的目的是添加其超级组件所没有的新功能,这通常会破坏超级组件的接口。这是一个完全有效的用例,我们不应该试图在任何地方都使用 LSP。

至于 LSP 确实有意义的组件,我们需要确保不会不必要地违反原则。让我们看一下发生这种情况的两种常见方式。

第一个是无缘无故地砍掉一部分道具:

type Props = { value: string; onChange: () => void };

const CustomInput = ({ value, onChange }: Props) => {
// ...some additional logic

return <input value={value} onChange={onChange} />;
};

在这里,我们重新定义 props,而 CustomInput 不是使用期望的 props <input />。结果,我们丢失了很大一部分属性,这些属性<input />可能会破坏其接口。为了解决这个问题,我们应该使用原始版本期望的 props<input />并使用扩展运算符将它们全部传递下来:

type Props = InputHTMLAttributes<HTMLInputElement>;

const CustomInput = (props: Props) => {
// ...some additional logic

return <input {...props} />;
};

破坏 LSP 的另一种方法是对某些属性使用别名。当我们要使用的属性与局部变量有命名冲突时,可能会发生这种情况:

type Props = HTMLAttributes<HTMLInputElement> & {
onUpdate: (value: string) => void;
};

const CustomInput = ({ onUpdate, ...props }: Props) => {
const onChange = (event) => {
/// ... some logic
onUpdate(event.target.value);
};

return <input {...props} onChange={onChange} />;
};

为了避免此类冲突,您需要为局部变量制定良好的命名约定。例如,handleSomething 每个 onSomething 属性都有一个匹配的本地函数是很常见的:

type Props = HTMLAttributes<HTMLInputElement>;

const CustomInput = ({ onChange, ...props }: Props) => {
const handleChange = (event) => {
/// ... some logic
onChange(event);
};

return <input {...props} onChange={handleChange} />;
};

接口隔离原则(ISP)

根据 ISP 的说法,“客户端不应依赖于他们不使用的接口。” 为了 React 应用程序,我们将其翻译为“组件不应该依赖于它们不使用的 props”。

我们在这里扩展了 ISP 的定义,但这并不是一个很大的扩展——道具和接口都可以定义为对象(组件)和外部世界(使用它的上下文)之间的契约,所以我们可以画出两者之间的相似之处。最后,这不是严格遵守定义,而是应用通用原则来解决问题。

为了更好地说明 ISP 所针对的问题,我们将在下一个示例中使用 TypeScript。让我们考虑一下呈现视频列表的应用程序:

type Video = {
title: string;
duration: number;
coverUrl: string;
};

type Props = {
items: Array<Video>;
};

const VideoList = ({ items }) => {
return (
<ul>
{items.map((item) => (
<Thumbnail key={item.title} video={item} />
))}
</ul>
);
};

Thumbnail 用于每个项目的组件可能如下所示:

type Props = {
video: Video;
};

const Thumbnail = ({ video }: Props) => {
return <img src={video.coverUrl} />;
};

Thumbnail 组件非常小且简单,但它有一个问题 - 它期望将完整的视频对象作为道具传入,同时仅有效地使用其属性之一。

要了解为什么会出现问题,请想象一下,除了视频之外,我们还决定显示直播流的缩略图,并将两种媒体资源混合在同一列表中。

我们将引入一种定义直播流对象的新类型:

type LiveStream = {
name: string;
previewUrl: string;
};

这是我们更新的 VideoList 组件:

type Props = {
items: Array<Video | LiveStream>;
};

const VideoList = ({ items }) => {
return (
<ul>
{items.map((item) => {
if ("coverUrl" in item) {
// it's a video
return <Thumbnail video={item} />;
} else {
// it's a live stream, but what can we do with it?
}
})}
</ul>
);
};

正如你所看到的,我们遇到了一个问题。我们可以轻松区分视频和直播流对象,但我们无法将后者传递给组件,Thumbnail 因为 VideoLiveStream 不兼容。首先,它们有不同的类型,因此 TypeScript 会立即抱怨。其次,它们包含不同属性下的缩略图 URL - 视频对象调用它 coverUrl,实时流对象调用它 previewUrl。这就是问题的症结所在,因为组件依赖的 props 超过了它们实际需要的数量——它们的可重用性变得越来越差。所以让我们修复它。

我们将重构我们的 Thumbnail 组件以确保它仅依赖于它所需的 props:

type Props = {
coverUrl: string;
};

const Thumbnail = ({ coverUrl }: Props) => {
return <img src={coverUrl} />;
};

通过此更改,现在我们可以使用它来渲染视频和直播流的缩略图:

type Props = {
items: Array<Video | LiveStream>;
};

const VideoList = ({ items }) => {
return (
<ul>
{items.map((item) => {
if ("coverUrl" in item) {
// it's a video
return <Thumbnail coverUrl={item.coverUrl} />;
} else {
// it's a live stream
return <Thumbnail coverUrl={item.previewUrl} />;
}
})}
</ul>
);
};

接口隔离原则主张最大限度地减少系统组件之间的依赖关系,从而减少它们的耦合,从而提高可重用性。

依赖倒置原则(DIP)

依赖倒置原则指出“应该依赖抽象,而不是具体”。换句话说,一个组件不应该直接依赖于另一个组件,而是它们都应该依赖于一些共同的抽象。这里,“组件”指的是我们应用程序的任何部分,无论是 React 组件、实用函数、模块还是第三方库。这个原则可能很难抽象地理解,所以让我们直接跳到一个例子。

下面我们有 LoginForm 一个组件,在提交表单时将用户凭据发送到某些 API:

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">Log in</button>
</form>
);
};

在这段代码中,我们的 LoginForm 组件直接引用了 api 模块,因此它们之间存在紧密的耦合。这很糟糕,因为这种依赖性使得更改代码变得更具挑战性,因为一个组件的更改会影响其他组件。依赖倒置原则主张打破这种耦合,所以让我们看看如何实现这一点。

首先,我们将从 api 内部删除对模块的直接引用 LoginForm,而是允许通过 props 注入所需的功能:

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">Log in</button>
</form>
);
};

通过此更改,我们的 LoginForm 组件不再依赖于 api 模块。向 API 提交凭证的逻辑通过 onSubmit 回调被抽象出来,现在父组件有责任提供该逻辑的具体实现。

为此,我们将创建一个连接版本,LoginForm 将表单提交逻辑委托给 api 模块:

import api from "~/common/api";

const ConnectedLoginForm = () => {
const handleSubmit = async (email, password) => {
await api.login(email, password);
};

return <LoginForm onSubmit={handleSubmit} />;
};

ConnectedLoginFormapi 组件充当和之间的粘合剂 LoginForm,而它们本身保持彼此完全独立。我们可以迭代它们并单独测试它们,而不必担心破坏依赖的移动部件,因为没有。只要两者 LoginForm 都 api 遵守商定的共同抽象,整个应用程序就会继续按预期工作。

过去,许多第三方库也使用这种创建“哑”表示组件然后向其中注入逻辑的方法。最著名的例子是 Redux,它会 dispatch 使用 connect 高阶组件 (HOC) 将组件中的回调 props 绑定到函数。随着钩子的引入,这种方法变得不太重要,但通过 HOC 注入逻辑在 React 应用程序中仍然有用。

总而言之,依赖倒置原则旨在最大限度地减少应用程序不同组件之间的耦合。您可能已经注意到,最小化是贯穿所有 SOLID 原则的一个反复出现的主题——从最小化单个组件的职责范围到最小化跨组件意识和它们之间的依赖关系。

结论

尽管 SOLID 原则诞生于 OOP 世界的问题,但其应用范围却远远超出了 OOP 世界。在本文中,我们了解了如何通过对这些原则进行一定的灵活性解释,成功地将它们应用到我们的 React 代码中,并使其更加可维护和健壮。

但重要的是要记住,教条和严格遵循这些原则可能会造成损害,并导致过度设计的代码,因此我们应该学会认识到组件的进一步分解或解耦何时会引入复杂性而几乎没有任何好处。