Освоение принципов 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-код выше.

Ваше пожертвование подпитывает мое стремление продолжать создавать значимую работу. Спасибо! 🦁💚

2. Подпишись 🤗