Освоение принципов React JS SOLID
Что такое SOLID принципы?
Принципы SOLID — это пять принципов проектирования, которые помогают нам поддерживать многократное использование нашего приложения, поддерживать его, масштабировать и слабо связывать.
Принципы SOLID:
- [S] — принцип единоличной ответственности
- [O] — принцип открытия-закрытия
- [L] — принцип подстановки Лисков
- [I] — Принцип разделения интерфейсов
- [D] — принцип инверсии зависимостей
Принцип единой ответственности
"Модуль должен отвечать перед одним и только одним действующим лицом". — Википедия.
Принцип единой ответственности гласит, что у компонента должна быть одна четкая цель или ответственность.
Он должен сосредоточиться на конкретной функциональности или поведении и избегать выполнения несвязанных задач. Следование SRP делает компоненты более сфокусированными, модульными и легко понимаемыми и модифицируемыми. Посмотрим реальную реализацию.
// Component responsible for rendering a user's profile information const UserProfile = ({ user }) => { return ( <div> <h1>User Profile</h1> <p>Name: {user.name}</p> <p>Email: {user.email}</p> </div> ); }; // Component responsible for rendering a user's profile picture const ProfilePicture = ({ user }) => { return ( <div> <h1>Profile Picture</h1> <img src={user.profilePictureUrl} alt="Profile" /> </div> ); }; // Parent component that combines both the UserProfile and ProfilePicture components const App = () => { const user = { name: "John Doe", email: "[email protected]", profilePictureUrl: "https://example.com/profile.jpg", }; return ( <div> <UserProfile user={user} /> <ProfilePicture user={user} /> </div> ); }; export default App;
В этом примере у нас есть два отдельных компонента: UserProfile
и ProfilePicture
. Компонент UserProfile
отвечает за отображение информации о профиле пользователя (имя и адрес электронной почты), а компонент ProfilePicture
отвечает за отображение изображения профиля пользователя. Каждый компонент имеет единственную ответственность и может быть повторно использован независимо.
Придерживаясь SRP, становится проще управлять этими компонентами и изменять их по отдельности. Например, если вы хотите внести изменения в изображение профиля пользователя, вы можете сосредоточиться на компоненте ProfilePicture
, не затрагивая компонент UserProfile
. Такое разделение задач улучшает организацию кода, удобство сопровождения и возможность повторного использования.
// Component responsible for rendering user profile information and profile picture const UserProfile = ({ user }) => { return ( <div> <h1>User Profile</h1> <p>Name: {user.name}</p> <p>Email: {user.email}</p> <img src={user.profilePictureUrl} alt="Profile" /> </div> ); }; export default UserProfile;
В этом примере у нас есть один компонент с именем UserProfile
, который отвечает за отображение как информации о профиле пользователя, так и изображения его профиля. Это нарушает SRP, поскольку у компонента несколько обязанностей.
Если требуются изменения либо в информации профиля пользователя, либо в изображении профиля, вам потребуется изменить этот единственный компонент, что противоречит принципу наличия одной причины для изменения. Становится все труднее понимать и поддерживать этот компонент по мере его усложнения.
Принцип открытого-закрытого
«Программные объекты (классы, модули, функции и т. д.) должны быть открыты для расширения, но закрыты для модификации». — Википедия.
Принцип открытости-закрытости подчеркивает, что компоненты должны быть открыты для расширения (могут добавлять новые поведения или функциональные возможности), но закрыты для модификации (существующий код должен оставаться неизменным).
Этот принцип поощряет создание кода, устойчивого к изменениям, модульного и легко поддерживаемого.
// Button.js import React from 'react'; const Button = ({ onClick, children }) => ( <button onClick={onClick}>{children}</button> ); export default Button; // IconButton.js import React from 'react'; import Button from './Button'; const IconButton = ({ onClick, children, icon }) => ( <Button onClick={onClick}> <span className="icon">{icon}</span> {children} </Button> ); export default IconButton;
В приведенном выше примере у нас есть компонент Button
, который отображает основную кнопку. Затем мы создаем компонент IconButton
, который расширяет функциональность компонента Button
. Он добавляет реквизит icon
и отображает значок вместе с дочерними элементами кнопки.
Используя этот подход, мы следовали принципу открытого-закрытого. Мы расширили функциональность компонента Button
, создав новый компонент (IconButton
) без изменения существующего кода компонента Button
. Это позволяет нам добавлять новые типы или варианты кнопок, не влияя на реализацию существующей кнопки.
Придерживаясь принципа открытости-закрытости, наш код становится более удобным в сопровождении, модульным и его легче расширять в будущем.
// Button.js import React from 'react'; const Button = ({ onClick, children, icon }) => { if (icon) { return ( <button onClick={onClick}> <span className="icon">{icon}</span> {children} </button> ); } else { return ( <button onClick={onClick}>{children}</button> ); } }; export default Button;
В этом примере компонент Button
был изменен для обработки случая, когда передается свойство icon
. Если указан реквизит icon
, он отображает кнопку со значком; в противном случае кнопка отображается без значка.
Проблема с этим подходом заключается в том, что он нарушает принцип открытого-закрытого, потому что мы изменили существующий компонент Button
вместо его расширения. Это делает компонент более хрупким и трудным в обслуживании в долгосрочной перспективе.
В будущем, если вы захотите добавить больше вариантов или типов кнопок, вам нужно будет снова изменить компонент Button
. Это нарушает принцип закрытости для модификации.
Принцип замены Лисков
"Объекты подтипа должны заменять объекты супертипа" — Википедия.
Принцип замещения Лискова (LSP) является одним из принципов SOLID и гласит, что объекты суперкласса должны заменяться объектами его подклассов, не влияя на корректность программы.
В контексте React.js давайте рассмотрим пример, в котором у нас есть базовый компонент с именем Button
и два подкласса PrimaryButton
и SecondaryButton
. PrimaryButton
и SecondaryButton
наследуются от компонента Button
. Согласно LSP, мы должны иметь возможность использовать экземпляры PrimaryButton
и SecondaryButton
везде, где ожидается экземпляр Button
, не вызывая никаких проблем.
class Button extends React.Component { render() { return ( <button>{this.props.text}</button> ); } } class PrimaryButton extends Button { render() { return ( <button style={{ backgroundColor: 'blue', color: 'white' }}>{this.props.text}</button> ); } } class SecondaryButton extends Button { render() { return ( <button style={{ backgroundColor: 'gray', color: 'black' }}>{this.props.text}</button> ); } } // Usage of the components function App() { return ( <div> <Button text="Regular Button" /> <PrimaryButton text="Primary Button" /> <SecondaryButton text="Secondary Button" /> </div> ); }
В приведенном выше примере PrimaryButton
и SecondaryButton
являются подклассами Button
. Мы видим, что оба подкласса наследуют метод render()
от базового класса и переопределяют его, чтобы обеспечить собственное поведение рендеринга.
Поскольку и PrimaryButton
, и SecondaryButton
являются подклассами Button
, мы можем свободно использовать экземпляры обоих подклассов в местах, где ожидается экземпляр Button
, например, в компоненте App
. Это демонстрирует принцип подстановки Лискова в действии, поскольку подклассы могут легко заменить базовый класс, не влияя на функциональность программы.
Обратите внимание, что это упрощенный пример, иллюстрирующий концепцию LSP в React.js с использованием компонентов класса. В реальном приложении вы обычно используете функциональные компоненты и ловушки вместо компонентов класса. Однако принцип остается прежним: производные компоненты должны иметь возможность заменять свои базовые компоненты, не вызывая никаких проблем.
class Button extends React.Component { render() { return ( <button>{this.props.text}</button> ); } } class PrimaryButton extends Button { render() { return ( <button style={{ backgroundColor: 'blue', color: 'white' }}>{this.props.text}</button> ); } } class SecondaryButton extends Button { render() { // Violation: Changing the behavior return ( <a href="#" style={{ backgroundColor: 'gray', color: 'black' }}>{this.props.text}</a> ); } } // Usage of the components function App() { return ( <div> <Button text="Regular Button" /> <PrimaryButton text="Primary Button" /> <SecondaryButton text="Secondary Button" /> </div> ); }
В этом примере класс SecondaryButton
нарушает принцип подстановки Лискова. Вместо того, чтобы отображать элемент <button>
, как базовый класс и PrimaryButton
, он отображает элемент <a>
. Это нарушает принцип, поскольку поведение производного класса (SecondaryButton
) отличается от поведения базового класса (Button
).
Когда мы используем SecondaryButton
в компоненте App
, он не будет работать должным образом по сравнению с Button
и PrimaryButton
. Это нарушает принцип, поскольку производный класс не предоставляет совместимой замены базовому классу.
Важно убедиться, что подклассы придерживаются того же поведения, что и суперкласс, при применении принципа подстановки Лискова.
Принцип разделения интерфейса
«Ни один код не должен зависеть от методов, которые он не использует.» — Википедия.
Принцип разделения интерфейсов (ISP) предполагает, что интерфейсы должны быть сфокусированы и адаптированы к конкретным требованиям клиента, а не быть слишком широкими и заставлять клиентов реализовывать ненужные функции. Посмотрим реальную реализацию.
// Interface for displaying user information interface DisplayUser { name: string; email: string; } // UserProfile component implementing DisplayUser interface const UserProfile: React.FC<DisplayUser> = ({ name, email }) => { return ( <div> <h2>User Profile</h2> <p>Name: {name}</p> <p>Email: {email}</p> </div> ); }; // Usage of the component const App: React.FC = () => { const user = { name: 'John Doe', email: '[email protected]', }; return ( <div> <UserProfile {...user} /> </div> ); };
В этом более коротком примере интерфейс DisplayUser
определяет свойства, необходимые для отображения информации о пользователе. Компонент UserProfile
— это функциональный компонент, который получает свойства name
и email
через свойства и соответствующим образом отображает профиль пользователя.
Компонент App
использует компонент UserProfile
для отображения профиля пользователя, передавая свойства name
и email
в качестве реквизита.
При разделении интерфейса компонент UserProfile
зависит только от интерфейса DisplayUser
, который предоставляет необходимые свойства для отображения профиля пользователя. Это способствует более сфокусированному и модульному дизайну, в котором компоненты можно повторно использовать без ненужных зависимостей.
Более короткий пример демонстрирует, как принцип разделения интерфейсов помогает поддерживать лаконичность и актуальность интерфейсов, что приводит к созданию более удобного для сопровождения и гибкого кода.
// Interface for user management interface UserManagement { addUser: (user: User) => void; displayUser: (userId: number) => void; } // UserProfile component implementing UserManagement interface const UserProfile: React.FC<UserManagement> = ({ addUser, displayUser }) => { // ... return ( // ... ); }; // Usage of the component const App: React.FC = () => { const userManager: UserManagement = { addUser: (user) => { // Add user logic }, displayUser: (userId) => { // Display user logic }, }; return ( <div> <UserProfile {...userManager} /> </div> ); };
В этом плохом примере интерфейс UserManagement
изначально имеет два метода: addUser
и displayUser
. Ожидается, что компонент UserProfile
реализует этот интерфейс.
Однако проблема возникает, когда мы пытаемся использовать компонент UserProfile
. Компонент UserProfile
получает интерфейс UserManagement
в качестве реквизита, но ему нужен только метод displayUser
для отображения профиля пользователя. Он не использует и не нуждается в методе addUser
.
Это нарушает принцип разделения интерфейса, поскольку компонент UserProfile
вынужден зависеть от интерфейса (UserManagement
), который включает методы, которые ему не нужны. Он вводит ненужные зависимости и может привести к сложности кода и потенциальным проблемам, если неиспользуемые методы вызываются или реализуются по ошибке.
Чтобы придерживаться принципа разделения интерфейсов, интерфейс UserManagement
должен быть разделен на более целенаправленные и конкретные интерфейсы, позволяя компонентам зависеть только от тех интерфейсов, которые им требуются.
Принцип инверсии зависимости
«Одна сущность должна зависеть от абстракций, а не от конкретики» — Википедия.
Принцип инверсии зависимостей (DIP) подчеркивает, что компоненты высокого уровня не должны зависеть от компонентов низкого уровня. Этот принцип способствует слабой связи и модульности и упрощает обслуживание программных систем. Посмотрим реальную реализацию.
// Abstraction: Interface or contract const DataService = () => { return { fetchData: () => {} }; }; // High-level component const App = ({ dataService }) => { const [data, setData] = useState([]); useEffect(() => { dataService.fetchData().then((result) => { setData(result); }); }, [dataService]); return ( <div> <h1>Data:</h1> <ul> {data.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> </div> ); }; // Dependency: Low-level component const DatabaseService = () => { const fetchData = () => { // Simulated fetching of data from a database return Promise.resolve(['item1', 'item2', 'item3']); }; return { fetchData }; }; // Dependency Injection: Providing the implementation const AppContainer = () => { const dataService = DataService(); // Creating the abstraction const databaseService = DatabaseService(); // Creating the low-level dependency // Injecting the dependency return <App dataService={dataService} />; }; export default AppContainer;
В этом более коротком примере у нас есть абстракция DataService
, которая представляет собой контракт на выборку данных. Компонент App
зависит от этой абстракции через свойство dataService
.
Компонент App
извлекает данные с помощью метода dataService.fetchData
и соответствующим образом обновляет состояние компонента.
DatabaseService
— низкоуровневый компонент, обеспечивающий реализацию извлечения данных из базы данных.
Компонент AppContainer
отвечает за создание абстракции (dataService
) и низкоуровневой зависимости (databaseService
). Затем он внедряет зависимость dataService
в компонент App
.
Следуя DIP, компонент App
зависит от абстракции (DataService
), а не от низкоуровневого компонента (DatabaseService
). Это обеспечивает лучшую модульность, тестируемость и гибкость при замене различных реализаций DataService
, сохраняя при этом компонент App
без изменений.
// High-level component const App = () => { const [data, setData] = useState([]); useEffect(() => { // Violation: App depends directly on a specific low-level implementation fetchDataFromDatabase().then((result) => { setData(result); }); }, []); const fetchDataFromDatabase = () => { // Simulated fetching of data from a specific database return Promise.resolve(['item1', 'item2', 'item3']); }; return ( <div> <h1>Data:</h1> <ul> {data.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> </div> ); }; export default App;
В этом примере компонент App
напрямую зависит от конкретной низкоуровневой реализации fetchDataFromDatabase
для извлечения данных из базы данных.
Это нарушает принцип инверсии зависимостей, поскольку высокоуровневый компонент (App
) тесно связан с конкретным низкоуровневым компонентом (fetchDataFromDatabase
). Любые изменения или замены в низкоуровневой реализации потребуют модификации высокоуровневого компонента.
Чтобы придерживаться принципа инверсии зависимостей, высокоуровневый компонент (App
) должен зависеть от абстракции или интерфейса, а не от конкретной низкоуровневой реализации. При этом высокоуровневый компонент отделяется от конкретных реализаций, что делает его более гибким и простым в обслуживании.
Заключение
Принципы SOLID предоставляют рекомендации, которые позволяют разработчикам создавать хорошо спроектированные, удобные в сопровождении и расширяемые программные решения. Придерживаясь этих принципов, разработчики могут добиться модульности, повторного использования кода, гибкости и снижения сложности кода.
Я надеюсь, что этот блог дал ценную информацию и вдохновил вас на применение этих принципов в ваших существующих или следующих проектах React.
Если вам нравится моя работа, пожалуйста:
1. Пожертвовать 💕💸
Оплата на сайте Revolut или используйте QR-код выше.
Ваше пожертвование подпитывает мое стремление продолжать создавать значимую работу. Спасибо! 🦁💚