skip to content
Блог о разработке и IT

Принципы SOLID в React

/ 5 min read

Рассмотрим 5 принципов SOLID на примере React-компонентов

1. Принцип Единой Ответственности (Single Responsibility Principle)

Компонент должен делать только одну вещь

Пример нарушения принципа единой ответственности в React-компоненте:

SingleResponsibilityViolation.tsx
import { Button, Stack, Typography } from "@mui/joy";
import axios from "axios";
interface CellProps {
title: string;
subtitle: string;
blogPostId: string;
}
// Ячейка таблицы для просмотра информации о посте и удаления поста в блоге
export default function Cell({ title, subtitle, blogPostId }: CellProps) {
// Удаление поста
const handleDeleteClick = async () => {
const res = await axios.delete(`/api/posts/${blogPostId}`);
if (res.data.success) {
location.reload();
}
};
return <Stack>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md">{subtitle}</Typography>
<Button onClick={handleDeleteClick} variant="soft">Удалить</Button>
</Stack>
}

Вынесем код удаления поста из компонента. Будем прокидывать его в колбэк:

SingleResponsibilityCompliance.tsx
import { Button, Stack, Typography } from "@mui/joy";
import axios from "axios";
import React from "react";
interface CellProps {
title: string;
subtitle: string;
onDeleteClick: (e: React.SyntheticEvent) => void;
}
// Ячейка таблицы для просмотра информации о посте и удаления поста в блоге
export default function Cell({ title, subtitle, onDeleteClick }: CellProps) {
return <Stack>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md">{subtitle}</Typography>
<Button onClick={onDeleteClick} variant="soft">Удалить</Button>
</Stack>
}

2. Принцип открытости-закрытости (Open-close Principle)

Предусматривайте возможность добавления функционала минимальными изменениями в уже написанном коде компонента

OpenCloseViolation.tsx
import { Button, Stack, Typography } from "@mui/joy";
import React from "react";
interface CellProps {
title: string;
subtitle: string;
canEdit: boolean;
onEditClick: (e: React.SyntheticEvent) => void;
canDelete: boolean;
onDeleteClick: (e: React.SyntheticEvent) => void;
}
// Ячейка таблицы для просмотра информации о посте в блоге
export default function Cell({
title, subtitle, canEdit, onEditClick,
canDelete, onDeleteClick
}: CellProps) {
return <Stack direction="row" spacing={2}>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md">{subtitle}</Typography>
{canEdit ? <Button onClick={onEditClick} variant="soft">Редактировать</Button> : null}
{canDelete ? <Button onClick={onDeleteClick} variant="soft">Удалить</Button> : null}
</Stack>;
}

Создадим пропс endBlock и будем прокидывать туда все кнопки, которые захотим

OpenCloseCompliance.tsx
import { Stack, Typography } from "@mui/joy";
import React from "react";
interface CellProps {
title: string;
subtitle: string;
endBlock?: React.ReactNode;
}
// Ячейка таблицы для просмотра информации о посте в блоге
export default function Cell({
title, subtitle, endBlock,
}: CellProps) {
return <Stack direction="row" spacing={2}>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md">{subtitle}</Typography>
{endBlock}
</Stack>;
}

3. Принцип подстановки Барбары Лисков (Liskov Substitution Principle)

«Отнаследованный» компонент не должен изменять контракт, унаследованный от родителя, кроме добавления новых пропсов

LiskovViolation.tsx
import { Button, Stack, Typography } from "@mui/joy";
import React from "react";
interface CellProps {
title: string;
subtitle: string;
date: Date;
endBlock?: React.ReactNode;
}
// Ячейка таблицы для просмотра информации о посте в блоге
function Cell({
title, subtitle, date, endBlock,
}: CellProps) {
return <Stack direction="row" spacing={2}>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md">{subtitle}</Typography>
<Typography level="body-sm">{date.toLocaleDateString()}</Typography>
{endBlock}
</Stack>;
}
interface AdminCellProps {
title: string;
subtitle: string;
date: string;
endBlock?: React.ReactNode;
onEditClick: () => void;
onDeleteClick: () => void;
}
// Ячейка таблицы для администратора
function AdminCell({
title, subtitle, date, endBlock, onEditClick, onDeleteClick
}: AdminCellProps) {
return <Cell title={title} subtitle={subtitle} date={new Date(date)} endBlock={endBlock || <>
<Button onClick={onEditClick} variant="soft">Редактировать</Button>
<Button onClick={onDeleteClick} variant="soft">Удалить</Button>
</>} />;
}

Мы не сможем подставить AdminCell вместо Cell из-за изменения типа пропса date

LiskovCompliance.tsx
import { Button, Stack, Typography } from "@mui/joy";
import React from "react";
interface CellProps {
title: string;
subtitle: string;
date: Date;
endBlock?: React.ReactNode;
}
// Ячейка таблицы для просмотра информации о посте в блоге
function Cell({
title, subtitle, date, endBlock,
}: CellProps) {
return <Stack direction="row" spacing={2}>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md">{subtitle}</Typography>
<Typography level="body-sm">{date.toLocaleDateString()}</Typography>
{endBlock}
</Stack>;
}
interface AdminCellProps extends CellProps {
onEditClick: () => void;
onDeleteClick: () => void;
}
// Ячейка таблицы для администратора
function AdminCell({
title, subtitle, date, endBlock, onEditClick, onDeleteClick
}: AdminCellProps) {
return <Cell title={title} subtitle={subtitle} date={date} endBlock={endBlock || <>
<Button onClick={onEditClick} variant="soft">Редактировать</Button>
<Button onClick={onDeleteClick} variant="soft">Удалить</Button>
</>} />;
}

4. Принцип разделения интерфейса (Interface Segregation Principle)

В пропсы нужно прокидывать только то, что нужно

InterfaceSegrgationViolation.tsx
import { Stack, Typography } from "@mui/joy";
interface User {
id: string;
name: string;
role: string;
}
interface CellProps {
title: string;
subtitle: string;
author: User;
}
// Ячейка таблицы с заголовком, подзаголовком и именем автора
export default function Cell({ title, subtitle, author }: CellProps) {
return <>
<Stack direction="row" spacing={2}>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md" color="neutral">{subtitle}</Typography>
<Typography level="body-md">{author.name}</Typography>
</Stack>
</>;
}

В пропс передается сущность User, которая имеет поля id, role, которые не используются в компоненте.

Заменим пропс author на пропс authorName:

IntefaceSegragationCompliance.tsx
import { Stack, Typography } from "@mui/joy";
interface CellProps {
title: string;
subtitle: string;
authorName: string;
}
// Ячейка таблицы с заголовком, подзаголовком и именем автора
export default function Cell({ title, subtitle, authorName }: CellProps) {
return <>
<Stack direction="row" spacing={2}>
<Typography level="title-md">{title}</Typography>
<Typography level="body-md" color="neutral">{subtitle}</Typography>
<Typography level="body-md">{authorName}</Typography>
</Stack>
</>;
}

5. Принцип инверсии зависимостей (Dependency Inversion Principle)

  • Нельзя импортить низкоуровневые модули внутри высокоуровневых
  • Низкоуровневый модуль должен реализовывать интерфейс, находящийся на высоком уровне. Высокоуровневый модуль должен работать с любым модулем переданным в аргументы/пропсы, реализующим этот интерфейс
DependencyInversionViolation.tsx
import { useEffect, useState } from "react";
// Мы не сможем использовать компонент с блогами из другого источника
import { getBlogsRequest } from "./Blogs.webapi";
import Cell from "./Cell";
// Список блогов
export default function BlogsPage() {
const [blogs, setBlogs] = useState<{
title: string;
subtitle: string;
}[]>([]);
const sendGetBlogsPostRequest = async () => {
const blogsFromWebApi = await getBlogsRequest();
setBlogs(blogsFromWebApi);
};
useEffect(() => {
sendGetBlogsPostRequest();
}, []);
return <>
{blogs?.map(blog => <Cell title={blog.title} subtitle={blog.subtitle} />)}
</>;
}

Мы не сможем использовать этот компонент с блогами из другого источника

DependencyInversionCompliance.tsx
import { useEffect, useState } from "react";
// Избавились от ключевого слова import
// import { getBlogsRequest } from "./Blogs.webapi";
import Cell from "./Cell";
interface Blog {
title: string;
subtitle: string;
}
interface IGetBlogsRequest {
(): Promise<Blog[]>;
}
interface BlogsPageProps {
getBlogsRequest: IGetBlogsRequest;
}
// Список блогов
// прокидываем getBlogsRequest в пропс при вызове компонента
// getBlogsRequest реализует интерфейс IGetBlogsRequest
// Любой модуль, реализующий интерфейс IGetBlogsRequest, можно будет прокинуть в этот пропс
export default function BlogsPage({ getBlogsRequest }: BlogsPageProps) {
const [blogs, setBlogs] = useState<{
title: string;
subtitle: string;
}[]>([]);
const sendGetBlogsPostRequest = async () => {
const blogsFromWebApi = await getBlogsRequest();
setBlogs(blogsFromWebApi);
};
useEffect(() => {
sendGetBlogsPostRequest();
}, []);
return <>
{blogs?.map(blog => <Cell title={blog.title} subtitle={blog.subtitle} />)}
</>;
}