Если вы разработчик программного обеспечения, то на собеседованиях вы можете столкнуться с вопросом: «Слышали ли вы раньше о принципах SOLID?». Возможно, вам уже попадался подобный вопрос. Уверен, вы слышали об этих принципах и даже использовали их в повседневной практике, но не сможете привести их примеры прямо сейчас. Узнаем же на реальных кейсах, что такое SOLID и как применять эти принципы в проектах на основе React/React Native, чтобы дать правильный ответ на следующем собеседовании.
SOLID — это группа из пяти важных правил проектирования программного обеспечения. Эти правила помогают сделать код понятным, гибким и удобным для сопровождения.
1. Принцип единой ответственности (SRP)
«Класс должен служить одной, четко определенной цели; это снижает необходимость в частых изменениях».
Мы должны придерживаться принципа единой ответственности (SRP) в проекте на React/React Native, следя за тем, чтобы каждый компонент в приложении имел конкретную, четко определенную цель. Например, компонент может отвечать за отображение чего-то, обработку пользовательского ввода или выполнение вызовов API для получения данных. Ограничив компонент одной четко определенной задачей, мы повышаем ясность и улучшаем сопровождаемость кодовой базы.
Вот несколько рекомендаций по реализации принципа единой ответственности (SRP) в React-приложении.
- Следите за тем, чтобы компоненты были небольшими и выполняли одну задачу.
- Не объединяйте несвязанные задачи в одном компоненте. Например, компонент, отвечающий за отображение формы, не должен также выполнять вызовы API для получения данных из списка.
- Используйте композицию. Создавайте многократно используемые компоненты пользовательского интерфейса, объединяя более мелкие компоненты. Такая практика позволяет разбивать сложные пользовательские интерфейсы на более мелкие и управляемые части, которые можно легко использовать повторно в разных местах приложения.
- Грамотно обращайтесь с пропсами и состоянием. Пропсы — это посыльные, которые передают данные и действия дочерним компонентам. Состояние же является личным блокнотом компонента, хранящим его уникальную информацию. Используйте состояние для информации, которая не ограничивается одним компонентом.
Чтобы объяснить эту идею на примере антипаттерна, предлагаю взглянуть на следующий фрагмент базового кода. Задача этого кода — получать и показывать элементы списка.
import React, { useEffect, useState } from "react";
import { View, Text, Image } from "react-native";
import axios from "axios";
const ListItems = () => {
const [listItems, setListItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
axios.get("https://.../listitems/").then((res) => {
setListItems(res.data)
}).catch(e => {
errorToast(e.message);
}).finally(() => {
setIsLoading(false);
})
}, [])
if (isLoading){
return <Text>Loading...</Text>
}
return (
<View>
{listItems.map(item => {
return (
<View>
<Image src={{uri:item.img}} />
<Text>{item.name}</Text>
<Text>{item.description}</Text>
</View>
)
})}
</View>
);
};
export default ListItems;
На первый взгляд может показаться, что код хорошо написан, поскольку он соответствует тому, к чему мы привыкли. Однако, углубившись в определение компонента, мы начинаем замечать некоторые проблемы. Компонент ListItems берет на себя несколько ролей:
- Управление состоянием.
- Выполнение запросов к элементам списка и обработка процесса получения.
- Рендеринг компонента.
Хотя изначально ListItems может выглядеть как типичный компонент, он нарушает принцип единой ответственности (SRP), так как выполняет больше обязанностей, чем должен. Как же исправить ситуацию?
Как правило, если внутри компонента есть useEffect, можно создать пользовательский хук, который станет обрабатывать действие, а useEffect не будет находиться в компоненте.
В нашем случае мы создадим новый файл с пользовательским хуком. Этот хук будет отвечать за логику получения данных из списка и управления состоянием.
import React, { useEffect, useState } from "react";
import axios from "axios";
const useFetchListItems = () => {
const [listItems, setListItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
axios.get("https://.../listitems/").then((res) => {
setListItems(res.data)
}).catch(e => {
errorToast(e.message);
}).finally(() => {
setIsLoading(false);
})
}, [])
return { listItems, isLoading };
}
После разделения задач обработки состояния и получения элементов списка исходный компонент стал значительно проще и понятнее. Теперь его единственной обязанностью является отображение информации, что делает его более удобным для сопровождения и расширения.
Отрефакторенный компонент, следующий принципу SRP, будет выглядеть следующим образом:
import { useFetchListItems } from "hooks/useFetchListItems";
const ListItems = () => {
const { listItems, isLoading } = useFetchListItems();
if (isLoading){
return <Text>Loading...</Text>
}
return (
<View>
{listItems.map(item => {
return (
<View>
<Image src={{uri:item.img}} />
<Text>{item.name}</Text>
<Text>{item.description}</Text>
</View>
)
})}
</View>
);
};
export default ListItems;
Но если мы внимательно посмотрим на новый хук, то увидим, что он выполняет несколько задач. Он обрабатывает и управление состоянием, и получение элементов списка. Таким образом, он не совсем соответствует идее выполнения только одной задачи, то есть не соблюдает принцип единой ответственности.
Чтобы исправить ситуацию, мы можем отделить логику получения элементов списка. Проще говоря, создадим новый файл api.js и перенесем туда код, отвечающий за получение элементов списка:
import axios from "axios";
import errorToast from "./errorToast";
const fetchListItems = () => {
return axios
.get("https://.../listitems/")
.catch((e) => {
errorToast(e.message);
})
.then((res) => res.data);
};
Новый отрефакторенный пользовательский хук:
import { useEffect, useState } from "react";
import { fethListItems } from "./api";
const useFetchListItems = () => {
const [listItems, setListItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchListItems()
.then((listItems) => setListItems(listItems))
.finally(() => setIsLoading(false));
}, []);
return { listItems, isLoading };
};
Поговорим о том, как сохранить простоту в программировании. Следование принципу единой ответственности (SRP) помогает организовать код и избежать ошибок. Но это не всегда просто. Иногда усложняется структура файлов, а планирование может занять дополнительное время.
В нашем примере мы позаботились о том, чтобы каждый файл выполнял одну задачу, что немного усложнило структуру, но помогло соблюсти принцип SRP. Однако помните, что строгое следование SRP не всегда является оптимальным решением. Иногда лучше лишь немного усложнить код вместо того, чтобы делать его чрезмерно сложным.
Бывают ситуации, когда мы не обязаны строго следовать SRP. Перечислим такие случаи:
- Работа с компонентами форм. Формы выполняют множество задач (проверка данных, управление состоянием, обновление информации). Разделение этих задач может привести к путанице, особенно если используются другие инструменты или библиотеки.
- Работа с компонентами таблиц. Таблицы также выполняют различные задачи, например отображают данные и управляют взаимодействием пользователей. Разделение этих задач на отдельные части вносит в код путаницу.
2. Принцип открытости/закрытости (OCP)
«Программные сущности (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации».
- Открыты для расширения. Это означает, что вы можете добавлять новые модели поведения, функциональные возможности и особенности в компонент, не изменяя его работу.
- Закрыты для модификации. После создания и реализации компонента React следует избегать прямых манипуляций с его исходным кодом, если только это не является неизбежным.
Рассмотрим следующий базовый компонент React Native:
import React from 'react';
interface IListItem {
title: string;
image: string;
isAuth: boolean;
onClickMember: () => void;
onClickGuest: () => void;
}
const ListItem: React.FC<ILıstItem> = ({ title, image, isAuth, onClickMember, onClickGuest }: IListItem) => {
const handleMember = () => {
// Определенная логика
onClickMember();
};
const handleGuest = () => {
// Определенная логика
onClickGuest();
};
return (
<View>
<Image source={{uri:image}} />
<Text>{title}</Text>
{
isAuth ?
<Button onClick={handleMember}>Add to cart +</Button>
:
<Button onClick={handleGuest}>Show Modal</button>
}
</View>
);
};
Как видите, приведенный выше код не соответствует OCP. Он нарушает этот принцип, отображая разные функции в зависимости от статуса аутентификации.
Если нужно отображать разные кнопки с разной логикой, то лучше изменить этот блок кода:
interface IButtonHandler {
handle(): void;
}
export const GuestButtonHandler: React.FC<{ onClickGuest?: () => void }> = ({ onClickGuest }) => {
const handle = () => {
// Логика для пользователей-гостей
onClickGuest();
};
return <Button onClick={handle}>Show Modal</Button>;
};
export const MemberButtonHandler: React.FC<{ onClickMember?: () => void }> = ({ onClickMember }) => {
const handle = () => {
// Логика для пользователей-участников
onClickMember();
};
return <Button onClick={handle}>Add to cart +</Button>;
};
import React from 'react';
interface IListItem {
title: string;
image: string;
}
export const ListItem: React.FC<IListItem> = ({ title, image, children}) => {
return (
<View>
<Image source={{ uri: image }} />
<Text>{title}</Text>
{children}
</View>
);
};
export default ListItem;
Мы избавляемся от ненужного кода и создаем новый пропс, который является дочерним, чтобы другие компоненты могли расширять этот компонент, передавая его в качестве дочернего:
import {ListItem} from "../index.tsx"
import {GuestButtonHandler, MemberButtonHandler} from "../handlers"
const App = () => {
return (
<ListItem title={item.title} image={item.image}>
isAuth ? <MemberButtonHandler /> : <GuestButtonHandler />
</ListItem>
);
};
export default App
Итак, компонент ListItem открыт для расширения и закрыт для модификации. Этот метод более эффективен, потому что теперь у нас есть разделенные компоненты, которые не требуют много пропсов, чтобы показывать различные элементы. Нам просто следует показать соответствующий элемент с необходимыми функциями. Кроме того, если компонент выполняет множество действий, он может нарушить принцип единой ответственности (SRP). Таким образом, показанный новый способ помогает сохранить организованность и понятность кода.
3. Принцип подстановки Лисков (LSP)
«Объекты суперкласса должны быть заменяемы объектами его подклассов».
Это означает, что подклассы определенного класса должны иметь возможность заменить суперкласс без нарушения функциональности.
Рассмотрим пример. Если "RacingCar"
(гоночный автомобиль) является подклассом "Car"
(автомобиля), то мы должны иметь возможность заменить экземпляры "Car"
на "RacingCar"
, не столкнувшись с неприятными сюрпризами. Это означает, что "RacingCar"
должен соответствовать всем ожиданиям класса "Car"
.
В контексте React принцип подстановки Лисков (LSP) заключается в возможности легко заменять родительские компоненты и при этом выполнять те же действия, что и дочерний компонент. Если один компонент использует другой, все должно продолжать работать как прежде.
Теперь посмотрим на код:
const SuccessButton = () => {
return (
<Text>Success</Text>
);
};
Итак, мы хотим создать компонент SuccessButton
, но функциональность кнопки нельзя заменить на Text
, поскольку такое действие нарушает принцип. Вместо этого нужно просто вернуть кнопку:
const SuccessButton = () => { return ( <TouchableOpacity style={styles.button} onPress={onPress}> <Text>Success</Text> </TouchableOpacity> ); };
Уже лучше, но этого недостаточно. Мы также должны унаследовать все функции самой кнопки:
const SuccessButton = () => {
return (
<TouchableOpacity style={styles.button} onPress={onPress} {…props}>
<Text>Success</Text>
</TouchableOpacity>
);
};
Итак, мы унаследовали все атрибуты кнопки и передаем их новой кнопке. Кроме того, любой экземпляр SuccessButton можно использовать вместо экземпляра Button без изменения поведения программы и с соблюдением принципа подстановки Лисков.
4. Принцип разделения интерфейса (ISP)
«Код не должен зависеть от методов, которые он не использует «. В контексте приложений React перефразируем это так: «Компоненты не должны зависеть от пропсов, которые они не используют».
Рассмотрим пример для лучшего понимания этого принципа:
const ListItem = ({item}) => {
return (
<View>
<Image source={{uri:item.image}} />
<Text>{item.title}</Text>
<Text>{item.description}</Text>
</View>
);
};
Есть компонент ListItem
, которому нужно всего несколько данных из пропса item
, а именно image
, title
и description
. Передавая item
ListItem в качестве пропса, мы в итоге даем больше, чем нужно, потому что пропс item
может содержать данные, которые компоненту не нужны.
Чтобы решить эту проблему, можно ограничить пропс только теми данными, которые нужны компоненту.
const ListItem = ({image, title, description}) => {
return (
<View>
<Image source={{uri:image}} />
<Text>{title}</Text>
<Text>{description}</Text>
</View>
);
};
Теперь компонент соответствует принципу ISP.
5. Принцип инверсии зависимостей (DIP)
1. Модули высшего уровня не должны ничего импортировать из модулей низшего уровня. Оба должны зависеть от абстракций (например, интерфейсов).
2. Абстракции не должны зависеть от деталей. Нужно, чтобы Детали (конкретные реализации) зависели от абстракций.
В контексте React этот принцип гласит следующее: компоненты высшего уровня не должны напрямую зависеть от компонентов низшего уровня, но и те, и другие должны зависеть от общей абстракции. В данном случае под компонентом понимается любая часть приложения, будь то React-компонент, функция, модуль, компонент на основе класса или сторонняя библиотека. Посмотрим на примеры:
const CreateListItemForm = () => {
const handleCreateListItemForm = async (e) => {
try {
const formData = new FormData(e.currentTarget);
await axios.post("https://myapi.com/listItems", formData);
} catch (err) {
console.error(err.message);
}
};
return (
<form onSubmit={handleCreateListItemForm}>
<input name="title" />
<input name="description" />
<input name="image" />
</form>
);
};
В компоненте выше показана форма для создания элемента списка путем отображения формы и отправки предоставленных данных в API.
Рассмотрим следующий сценарий. Существует другая форма для редактирования элемента списка с таким же пользовательским интерфейсом, но отличающаяся только логикой (в нашем примере это конечная точка API). Наша форма будет непригодна для повторного использования, так как нужна другая конечная точка для отправки формы редактирования. Поэтому нам необходимо создать компонент, который не будет зависеть от конкретного модуля низшего уровня.
const ListItemForm = ({ onSubmit }) => {
return (
<form onSubmit={onSubmit}>
<input name="title" />
<input name="description" />
<input name="image" />
</form>
);
};
Мы убрали зависимость от формы и теперь можем задать необходимую логику с помощью пропсов.
const CreateListItemForm = () => {
const handleCreateListItem = async (e) => {
try {
const formData = new FormData(e.currentTarget);
await axios.post("https://myapi.com/listItems", formData);
} catch (err) {
console.error(err.message);
}
};
return <ListItemForm onSubmit={handleCreateListItem} />;
};
const EditListItemForm = () => {
const handleEditListItem = async (e) => {
try {
// Логика для редактирования
} catch (err) {
console.error(err.message);
}
};
return <ListItemForm onSubmit={handleEditListItem} />;
};
Благодаря такому упрощению и применению принципа DIP можно даже протестировать каждый компонент отдельно, не опасаясь непреднамеренного воздействия на другие связанные элементы, поскольку таковых не существует.
Заключение
В этой статье я постарался объяснить все принципы SOLID на конкретных примерах. Их не так-то просто запомнить сразу, но надеюсь, вы поняли, как они работают.
Читайте также:
- Стратегии рендеринга, которые должен знать каждый React-разработчик
- Лучшие библиотеки React 2024 года
- Топ-10 инструментов, которые понадобятся каждому React-разработчику в 2024 году
Читайте нас в Telegram, VK и Дзен
Перевод статьи ismail harmanda: Frontend Masters: Solid Principles in React / React Native