Есть ли смысл применять SOLID в React?
- воскресенье, 11 мая 2025 г. в 00:00:07
Ещё несколько лет назад принципы SOLID были неотъемлемой частью собеседований для разработчиков любого уровня. Вопросы вроде «Расскажите, что означает каждая буква в SOLID» звучали так же часто, как «Что такое замыкание в JavaScript?». Это считалось своеобразной классикой, обязательной для понимания любого уважающего себя программиста.
Однако в последнее время, особенно во фронтенд-разработке и в мире React, акцент на SOLID заметно снизился и, например, вопросы о нем на собеседованиях встречаются всё реже. В первую очередь, это связано с переходом к функциональному стилю программирования, широким использованием хуков и отказом от классовых компонентов, что отодвигает принципы ООП на второй план.
Тем не менее, я убеждён, что принципы SOLID по-прежнему актуальны и полезны, даже в контексте функционального подхода. JavaScript и React не запрещают применять лучшие практики из ООП — наоборот, они предоставляют гибкость для использования различных парадигм.
В этой статье я хочу поделиться своим взглядом на то, как каждый из принципов SOLID может быть применен в разработке React-приложений. Здесь не будет революционных идей, особенно для опытных разработчиков, но, возможно, эта информация окажется полезной для начинающих разработчиков, стремящихся писать более качественный и поддерживаемый код.
Давайте начнем с обозначения терминологии и понятий о которых пойдет речь ниже.
SOLID — это аббревиатура из пяти принципов, которые помогают писать гибкий, масштабируемый и сопровождаемый код. Эти принципы направлены на улучшение архитектуры программного обеспечения и минимизацию ошибок при внесении изменений в систему. Аббревиатура расшифровывается как:
S — Single Responsibility Principle (Принцип единственной ответственности)
O — Open/Closed Principle (Принцип открытости/закрытости)
L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
I — Interface Segregation Principle (Принцип разделения интерфейса)
D — Dependency Inversion Principle (Принцип инверсии зависимостей)
Принципы SOLID были популяризированы Робертом С. Мартином (он же Uncle Bob) в начале 2000-х годов, но формировались по частям ранее, в частности в трудах Барбары Лисков и других авторов. Эти идеи стали основой многих современных подходов к чистой архитектуре (Clean Architecture) и проектированию систем.
Давайте рассмотрим каждый из принципов и как их можно применять при разработке React приложений.
Первый и, наверное, самый очевидный принцип - Single Responsibility Principle или принцип единой ответственности. Он гласит: модуль (в контексте React - компонент, хук или функция) должны иметь одну причину для изменения.
Давайте рассмотрим компонент, который загружает данные, фильтрует их и рендерит список пользователей.
function UserList() {
const [users, setUsers] = useState([]);
const [filter, setFilter] = useState('');
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then((data) => setUsers(data));
}, []);
const filteredUsers = users.filter((user) =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul>
{filteredUsers.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Уверен, что каждый из нас делал что-то подобное, но фактически этот компонент нарушает принцип единой ответственности, т.к. решает сразу три задачи. Давайте попробуем отрефакторить его и разделить на составные части, каждая из которых имеет свою область ответственности:
Сделаем отдельный хук для загрузки данных:
function useUsers() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then((data) => setUsers(data));
}, []);
return users;
}
Фильтрацию реализуем через отдельную утилиту:
function filterUsers(users, filter) {
return users.filter((user) =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
}
Ну и собственно рендеринг оставим самому компоненту
function UserList() {
const users = useUsers();
const [filter, setFilter] = useState('');
const filteredUsers = filterUsers(users, filter);
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul>
{filteredUsers.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
В итоге мы получаем:
Переиспользуемость: хук и утилиту можно использовать в других частях приложения.
Тестируемость: логику легче тестировать отдельно.
Поддерживаемость: изменения в одной части не ломают другую.
SRP помогает создавать масштабируемый и понятный код. В реальной работе это экономит время и снижает количество проблем.
Open-Closed Principle гласит: программные сущности должны быть открыты для расширения, но закрыты для изменения. Более простым языком наш код должен быть готов к новым фичам (открыт для расширения), но при этом не нужно постоянно переписывать старый код (закрыт для изменений).
Очень часто велик соблазн переиспользовать компонент созданный пару месяцев назад для новой фичи, допилив его немного напильником. Но если каждый раз править компоненты, можно наломать дров и добавить багов.
Давайте представим что мы создали компонент для адресной книги, как в телефоне, он показывает аватарку, имя и телефон пользователя.
function UserCard({ user }) {
return (
<div className="card">
<img src={user.avatar} alt="Avatar" />
<h3>{user.name}</h3>
<p>{user.phone}</p>
</div>
);
}
Код работает отлично, но тут приходит задача: нужно сделать список чатов, как в Telegram. Карточка чата выглядит почти так же, только вместо телефона — последнее сообщение. Что делать?
Плохой подход: переписать UserCard, чтобы он принимал и телефон, и сообщение, добавив кучу условий. Это ломает принцип OCP, потому что мы меняем старый код, рискуя сломать адресную книгу.
Хороший подход: сделать компонент более универсальным, чтобы он был открыт для новых сценариев.
function Card({ title, subtitle, image }) {
return (
<div className="card">
<img src={image} alt="Avatar" />
<h3>{title}</h3>
<p>{subtitle}</p>
</div>
);
}
Теперь этот компонент можно использовать и для адресной книги, и для чатов:
// Для адресной книги
<Card title={user.name} subtitle={user.phone} image={user.avatar} />
// Для чатов
<Card title={chat.name} subtitle={chat.lastMessage} image={chat.avatar} />
В итоге наш компонент:
Открыт для расширения: мы можем использовать Card для групп, каналов или чего угодно еще, просто передавая разные данные.
Закрыт для изменений: нам не нужно трогать код Card, чтобы добавить новый тип карточки. Адресная книга продолжает работать без багов.
На самом деле этот принцип встречается повсюду, и вы, скорее всего, уже используете его, даже не замечая. Вот несколько типичных случаев:
React-router - ты можешь добавить новую страницу, просто указав новый маршрут. Роутер открыт для новых страниц и закрыт для изменений — тебе не нужно править его код, чтобы добавить новую страницу.
Библиотеки вроде react-hook-form позволяют создавать кастомные поля (например, выпадающий список или чекбокс), не трогая их исходный код. Ты просто передаешь свои компоненты, а библиотека делает остальное.
Любые библиотеки типа Material UI. Мы не меняем исходных компонентов предоставляемых библиотекой, но на их базе строим свои собственные
Чтобы ваш код соответствовал OCP, придерживайтесь этих простых правил:
Делайте компоненты универсальными: вместо конкретных данных (например, user.phone) используйте абстрактные пропсы (subtitle).
Используйте композицию: вместо изменения компонента создавайте новые, которые используют старые.
Пишите переиспользуемые хуки: инкапсулируйте логику в хуки, чтобы их можно было использовать в разных местах.
Тестируйте гибкость: проверяйте, можно ли добавить новую фичу, не трогая старый код.
Принцип подстановки Барбары Лисков гласит, что объекты производного класса должны быть взаимозаменяемы с объектами базового класса без изменения поведения программы. Проще говоря, если у вас есть базовый компонент или класс, то любой его наследник или вариация должны соответствовать ожиданиям, заданным базовым компонентом, и не нарушать его контракт.
Давайте представим компонент IconButton
. Вначале это может быть просто базовая кнопка-иконка, но со временем ее функционал может расширяться: могут появиться состояния (активна/неактивна), какие-нибудь счетчики, например количество товаров в корзине и т.д.
Следуя ранее рассмотренным принципам SOLID нам не стоит допиливать функциональность базового компонента для этой цели. Лучшим решением будет создание "расширенных" компонентов, добавляющих необходимый функционал.
// BaseButton.jsx
function BaseButton({ className, children, onClick }) {
return (
<button className={`base-button ${className || ''}``} onClick={onClick}>
{children}
</button>
);
}
// IconButton.jsx
function IconButton({ icon, onClick }) {
return (
<BaseButton className="icon-button" onClick={onClick}>
<span>{icon}</span>
</BaseButton>
);
}
// CartButton.jsx
function CartButton({ icon, count, onClick }) {
return (
<BaseButton className="cart-button" onClick={onClick}>
<span>{icon}</span>
<span>{count}</span>
</BaseButton>
);
}
Обратите внимание что все типы кнопок теперь используют базовый функционал и при этом на место базового компонента можно поставить расширенный и ничего не сломается. Функциональность компонентов изолирована, и если нам нужно изменить что-то в одном из компонентов можно не бояться сломать все остальные. Также упрощается написание тестов и стилей - каждый новый компонент использует базу от предыдущих и требует всего пару доп строк кода для каждого кейса.
Дядюшка Боб сформулировал этот принцип так:
Программные сущности не должны зависеть от методов, которые они не используют
С одной стороны - это чисто Тайпскриптовая тема, но мы можем увидеть почему соблюдение этого принципа важно и при работе с чистым JS.
Давайте начнем с примера. Представьте что в приложении у нас существуют пользователи различного типа. У каждого типа свои возможности:
Админ: может удалять посты, банить пользователей, редактировать контент.
Модератор: может удалять посты и редактировать контент, но не банить пользователей.
Обычный пользователь: может только создавать посты. Возникает вопрос: как типизировать пользователей? Создать один общий интерфейс для всех или отдельные интерфейсы для каждого типа?
Если мы выберем единый интерфейс, то столкнемся с проблемой:
interface User {
createPost: () => void;
deletePost?: () => void; // Опционально для обычных пользователей
banUser?: () => void; // Только для админов
editContent?: () => void; // Для админов и модераторов
}
В этом случае мы получаем:
Обычные пользователи получают доступ к методам (deletePost, banUser), которые они не должны использовать.
Опциональные методы (?) снижают строгость Тайпскрипта и теряется смысл от его использования.
Компоненты, использующие User, вынуждены проверять наличие методов, что усложняет код.
Это классический пример "жирного интерфейса", который заставляет компоненты зависеть от ненужных методов.
Оптимальным решением нашей проблемы может стать использование разделения интерфейсов. Я приведу немного гиперболизированный синтетический пример, просто чтобы вы поняли принцип:
interface PostCreator {
createPost: () => void;
}
interface PostEditor {
editContent: () => void;
}
interface PostDeleter {
deletePost: () => void;
}
interface UserBanner {
banUser: () => void;
}
type RegularUser = PostCreator;
type Moderator = PostCreator & PostEditor & PostDeleter;
type Admin = PostCreator & PostEditor & PostDeleter & UserBanner;
То же самое по смыслу можно реализовать с помощью классов, в том числе и в обычном JavaScript.
ISP становится особенно актуальным, когда мы работаем с сторонними библиотеками. Наверняка у вас бывало, что вы используете всего пару методов из большого npm-пакета, а после очередного обновления приложение ломается. Почему? Разработчики библиотеки изменили или удалили метод, который вы даже не использовали, но он был частью того же "жирного" интерфейса.
Возьмем, к примеру, библиотеки вроде Lodash или Moment.js. В прошлом разработчики часто подключали весь модуль, чтобы использовать всего несколько функций. Это приводило к тому что:
Вы тащили в проект кучу ненужного кода.
Обновление библиотеки могло сломать приложение из-за изменений в неиспользуемых методах.
Ваш код зависел от огромного интерфейса библиотеки, хотя вам нужны были лишь отдельные функции.
Современные подходы и модульные библиотеки позволяют следовать ISP используя только необходимый функционал.
//вместо
import _ from 'lodash'; // Импортируем весь модуль
const debouncedFn = _.debounce(myFunction, 300);
//используем
import debounce from 'lodash/debounce'; // Импортируем только debounce
const debouncedFn = debounce(myFunction, 300);
Принцип разделения интерфейсов помогает создавать более чистый и поддерживаемый код, будь то TypeScript или чистый JavaScript. Разбивая "жирные" интерфейсы на маленькие и специализированные, мы:
Упрощаем тестирование и отладку.
Делаем компоненты независимыми от лишней функциональности.
Снижаем риски, связанные с обновлением библиотек.
Оптимизируем размер бандла приложения.
Повышаем читаемость и гибкость кода.
Если мы откроем какую-нибудь Вики, то найдем там следующее определение этого принципа:
Модули высокого уровня не должны зависеть от модулей низкого уровня. И те, и другие должны зависеть от абстракций.
Давайте рассмотрим хук для получения пользователей, который мы рассматривали в самом начале статьи:
function useUsers() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then((data) => setUsers(data));
}, []);
return users;
}
Он нарушает принцип инверсии зависимостей, т.к. зависит от fetch - модуля более низкого уровня. Если у нас возникнет потребность заменить fetch на что-то еще - будет необходимо идти по всему приложению и исправлять код, связанный с получением данных.
Оптимальное решение для подобных случаев состоит в том, чтобы компонент или хук зависел от какого-нибудь абстрактного API клиента, а fetch или условный axios "подстраивался" бы под интерфейс этого клиента. Мы можем создать абстрактный интерфейс для провайдера данных и использовать его:
export interface User {
id: number;
name: string;
}
export interface UserService {
getUsers(): Promise<User[]>;
}
Теперь создадим конкретную реализацию с использование fetch:
import { UserService, User } from "./types";
export const fetchUserService: UserService = {
async getUsers(): Promise<User[]> {
const res = await fetch("/api/users");
return res.json();
},
};
И теперь собственно наш хук. После изменений он будет зависеть не от fetch, а от UserService:
function useUsers() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetchUserService.getUsers().then(setUsers);
}, [userService]);
return users;
}
Теперь хук зависит от UserService и если в последующем мы решим использовать axios вместо fetch нам не придется трогать код компонентов. Мы просто реализуем UserService с использованием нового подхода и будем использовать его как раньше.
Принципы SOLID, несмотря на их происхождение из объектно-ориентированного программирования, остаются актуальными и полезными в контексте разработки React-приложений, даже в эпоху функционального программирования и хуков. Они помогают создавать гибкий, масштабируемый и поддерживаемый код, что особенно важно в условиях быстро меняющихся требований и роста сложности проектов.
Практические рекомендации:
Дробите компоненты и логику на небольшие, специализированные части.
Используйте композицию и хуки для повышения переиспользуемости.
Стремитесь к абстракциям в зависимостях и интерфейсах.
Регулярно проверяйте, можно ли добавить новую функциональность без изменения старого кода.
SOLID — это не догма, а инструмент для улучшения качества кода. И по моему мнению, в мире React эти принципы не теряют своей ценности, а адаптируются к новым реалиям функционального программирования. Их осознанное применение помогает писать код, который легче понимать, тестировать и развивать, что особенно важно для долгосрочных проектов.
Данная статья фактически является компиляцией серии постов вышедших в моем Телеграм канале. В нем я делюсь своим опытом, пишу реальные истории из повседневной работы в качестве разработчика и преподавателя, рассказываю о своих проектах. Подписывайтесь, будет интересно и полезно https://t.me/+iEqVnqxCHfhlZmYy.