
Всем привет! Меня зовут Дмитрий, и я руководитель фронтенд-разработки в компании Интелси.
Сегодня хочу рассказать о принципе единственной ответственности (Single Responsibility Principle) — первом из пяти принципов SOLID, сформулированных Робертом Мартином в его книге "Agile Software Development: Principles, Patterns, and Practices". Суть этого принципа звучит так: «Класс должен иметь только одну причину для изменения» (A class should have only one reason to change).
Если вы скажете, что в React редко используются классы, вы будете правы. Однако этот принцип легко переносится и на другие сущности, такие как компоненты. Мы ещё вернёмся к этому. Конечно, классы не так часто используют в React-приложениях, но сейчас важно разобраться в самой идее.
Формулировка принципа довольно сжатая и не всегда интуитивно понятная. Если вдумываться только в неё, может быть сложно уловить суть. Иногда можно встретить интерпретацию вроде: «Каждый класс должен решать строго одну задачу», что может создать впечатление, будто класс должен содержать лишь один метод.
Получается, что этот класс соответствует принципу:
class Cat {
meow() {
console.log('Meow!');
}
}
а этот - нет:
class Cat {
meow() {
console.log('Meow!');
}
eat() {
console.log('Eating...');
}
}
Но на самом деле это не так. Оба эти класса соответствуют данному принципу. Почему? Потому, что они все относятся к одной и той же сущности - кошке. Они характеризуют её стандартное поведение. В данном случае, класс имеет только одну причину для изменения - изменение поведения кошки, что и задаётся с помощью соответствующего класса. И по сути, таких методов может быть много. Но если они задают поведение кошки, то они не нарушают данный принцип.
Но что тогда нарушает принцип?
class Cat {
meow() {
console.log('Meow!');
}
eat() {
console.log('Eating...');
}
takeToVet() {
console.log('Taking the cat to the vet...');
}
heal() {
console.log('Healing the cat...');
}
}
В данном примере явное нарушение принципа. Почему? Потому, что поход к ветеринару - это не поведение самой кошки. Обычно, кошка не ходит к ветеринару сама. Кошку приводит на прием её хозяин. Аналогичная ситуация с методом лечения. Кошка ничего не делает для лечения. Кошку лечит ветеринар. Два этих действия никак не относятся к поведению кошки, что является нарушение принципа единой ответственности.
А как правильно сделать? Ну, например, вот так:
class Cat {
meow() {
console.log('Meow!');
}
eat() {
console.log('Eating...');
}
}
class Owner {
takeToVet(cat: Cat) {
console.log('Taking the cat to the vet...', cat);
}
}
class Vet {
heal(cat: Cat) {
console.log('Healing the cat...', cat);
}
}
У нас теперь есть классы, которые задают кошку, владельца кошки и ветеринара. И каждый из них делает именно то, что они и делают в реальном мире. Кошка мяукает и кушает. Хозяин, если кошка заболела, может повести её к ветеринару. А ветеринар лечит кошку, если хозяин приведёт её к нему.
С одной стороны, теперь всё понятно. Но у нас может возникнуть ситуация, когда число методов у класса может сильно разрастись.
class Cat {
meow() { }
eat() { }
walk() { }
sleep() { }
hunt() { }
// ... n+ методов
}
C одной стороны, эти методы характеризуют кошку. Ведь все действия - это именно то, что и делают кошки. Но с другой стороны, нам становится ясным, что тут что-то не так. Почему так? Всё потому, что все принципы проектирования приложений - это просто список рекомендаций. Но все случаи подобными рекомендациями покрыть не получится. И если методов становится слишком много, то одни скажут, что принцип сохраняется, а другие, что он нарушился.
В любом случае, много методов в одном классе - это не очень хорошо. Порой стоит перейти на новый уровень абстракции.
class CatVoice { meow() { } }
class CatFeeding { eat() { } }
class CatMovement { walk() { } }
class CatSleeping { sleep() { } }
class CatHunting { hunt() { } }
class Cat {
voice: CatVoice;
feeding: CatFeeding;
movement: CatMovement;
sleeping: CatSleeping;
hunting: CatHunting;
constructor() {
this.voice = new CatVoice();
this.feeding = new CatFeeding();
this.movement = new CatMovement();
this.sleeping = new CatSleeping();
this.hunting = new CatHunting();
}
}
Для улучшения кода можно использовать композицию. Теперь принцип единой ответственности снова соблюдается, ведь мы выделили новые классы, которые реализуют только одну сущность. А класс кошки просто делегирует логику соответствующим классам.
Получается, что можно сделать вывод, что если класс не реализует стандартное поведение сущности, как например лечение для кошки, то он точно нарушает данный принцип. Если же все методы реализуют поведение самой этой сущности, то вопрос соблюдения принципа зависит от нашего собственного мнения. Нет максимального количества методов, которые могут содержаться в одном классе. Это всё ложится на плечи разработчика.
Но давайте же поговорим про React. Если составить аналогию с компонентами, то по сути, в React приложениях компоненты реализуют абстрактные сущности по аналогии с классами. Раньше именно классы использовались для создания компонентов. Сейчас от классовых компонентах отошли, но суть осталась.
Я думаю, что для примера с React компонентами, кошка - не самый лучший вариант. React предназначен для реализации фронтенда. Так давайте же мыслить в контексте фронтенд приложений.
Предположим, что у нас есть компонент, который отображает данные профиля пользователя. Вот его реализация.
export function UserProfile() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser()
.then(data => {
setUser(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Я думаю, что часто можно увидеть подобную реализацию. На первый взгляд, всё нормально. Но в данной реализации нарушен принцип единой ответственности. Этот компонент отвечает и за загрузку данных, и за их отображение. По сути, он совмещает в себе две функции. Одна из них - загрузка данных, а другая - отображение данных. Но почему это плохо? Представим, что данные можно также загрузить из localStorage. То есть, данные имеют тот же самый формат. Соответственно и отображаться они будут аналогично. Но вот загружены они могут быть из двух источников.
По сути, нам хотелось бы, чтобы компонент, который отображает данные, не зависел от того, как и откуда мы их получаем. Его задача - отображение данных. А загрузка данных должна быть в другом месте.
Давайте исправим данную проблему.
export function useUser() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser()
.then(data => {
setUser(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, []);
return { user, isLoading, error };
}
export function UserProfileView({ user, isLoading, error }) {
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
export function UserProfileContainer() {
const { user, isLoading, error } = useUser();
return <UserProfileView user={user} isLoading={isLoading} error={error} />;
}
Что было реализовано
В приведённой реализации мы выделили три сущности:
Хук — отвечает за получение данных с сервера.
Компонент отображения — занимается исключительно визуализацией данных пользователя.
Компонент-обёртка — связывает получение данных и их отображение.
Каждая из этих частей выполняет только одну функцию, что соответствует принципу единственной ответственности (Single Responsibility Principle).
Почему такой подход предпочтительнее
На первый взгляд может показаться, что количество кода увеличилось. Однако структурное разделение несёт в себе важные преимущества — как с точки зрения сопровождения, так и с точки зрения гибкости архитектуры.
Преимущество 1. Простота внесения изменений
Если в дальнейшем нужно будет изменить источник данных — например, использовать данные не с сервера, а из локального хранилища, — достаточно:
Создать новый хук с тем же интерфейсом.
Заменить импорт в одном компоненте.
Изменения затрагивают лишь небольшую часть кода. Не требуется вникать в детали работы других компонентов или переписывать логику. Благодаря этому уменьшается риск ошибок и необходимость повторного тестирования.
Преимущество 2. Повторное использование компонентов
Компонент, отвечающий за отображение, можно использовать повторно в разных частях приложения. Данные можно загрузить один раз, а отобразить — в нескольких местах.
Если же объединять логику загрузки и отображения в одном компоненте, каждый его вызов будет инициировать отдельный запрос. Это неэффективно и усложняет контроль.
Преимущество 3. Упрощение поддержки и повышение читаемости
Чем меньше ответственности у компонента, тем легче его поддерживать. Если компонент делает только одну вещь, его проще тестировать, проще понять и легче использовать повторно.
При работе с небольшими изолированными компонентами можно опираться только на их интерфейс, не погружаясь в внутреннюю реализацию. Это особенно важно в больших проектах, где над кодом работают несколько человек или команд.
Вывод
Принцип единственной ответственности позволяет строить архитектуру, которая:
проще в сопровождении;
менее подвержена ошибкам при изменениях;
легко масштабируется;
обеспечивает повторную используемость кода.
Такой подход делает проект более устойчивым и предсказуемым в долгосрочной перспективе.