Принципы SOLID в React: так ли все с ними гладко?

Поговорим о принципах SOLID с точки зрения приложений React. Я прекрасно понимаю, что статей на эту тему уже достаточно много, и знаю это потому, что прочитал большинство из них. Должен сказать, что не согласен с тем, как подается информация в таких материалах (иначе я бы, конечно, не стал публиковать эту статью). Проблема, на мой взгляд, заключается в том, что авторы рассказывают только о том, как прекрасны принципы SOLID, не упоминая об их возможных изъянах и подводных камнях. Никто не задается вопросом: а применимы ли вообще принципы SOLID в React-приложениях? Именно это мы и обсудим, так что читайте дальше.

Глава S: Single Responsibility Principle (принцип единой ответственности)

Самый первый принцип максимально прост для понимания: компонент должен иметь единую ответственность, или, другими словами, должна быть только одна причина для существования этого компонента.

Однако если переключиться из режима потребления в режим мышления, то можно сразу же заметить одну проблему. Учитывая то, что любой нелистовой компонент в приложении является корнем поддерева, существует вероятность того, что почти наверняка некоторые из компонентов будут иметь более одной ответственности: у компонента пять дочерних компонентов, и он контролирует состояние трех из них. Таким образом, корневой компонент App в значительной степени нарушает этот принцип!

Так что же делать? Как применять принцип единой ответственности? Если внимательно присмотреться, то можно обнаружить две группы компонентов: те, которые легко спроектировать с учетом принципа единой ответственности; и другие, своего рода оркестранты, на которых лежит большая ответственность (или контроль). Вы можете называть их умными/глупыми компонентами, контейнерными/презентационными  —  не имеет значения, лишь бы вы понимали суть этого разделения.

Я буду называть их Менеджерами (Managers) и Работниками (Workers). Считаю, что, введя простой набор правил, можно заставить все компоненты приложения соблюдать принцип единой ответственности. Дадим определение этим правилам.

  1. Менеджеры никогда не должны выполнять работу за Работников, другими словами, их единственной обязанностью является контроль и композиция Работников (или других Менеджеров).
  2. Работники не должны быть в курсе бизнес-логики и должны делегировать задания только другим Работникам, а не Менеджерам.

Обдумаем эти два правила. Что означает фраза “Менеджеры никогда не должны выполнять работу за Работников”? Это значит, что Менеджеры никогда не должны иметь собственного JSX-содержимого. Все, что они возвращают, должно состоять только из Работников и/или других Менеджеров, или, говоря еще проще, у них не должно быть собственных HTML-элементов. Как следствие, у них не будет и CSS, поскольку стилизовать будет нечего. Можно с уверенностью сказать, что Менеджеры представляют собой (java)script-часть приложения  —  они несут всю бизнес-логику и управляют большинством пользовательских взаимодействий.

А как насчет Работников, которые “не должны быть в курсе бизнес-логики”? Тут все обстоит как раз наоборот. Работники несут на себе все содержимое и стили (HTML- и CSS-части приложения). Они практически не используют JavaScript, прибегая к нему только для поддержки своего изолированного существования.

Должен заметить, что в отношении стилизации есть довольно существенная оговорка (слишком существенная, чтобы отвлекаться на нее).

Тот факт, что Работники не могут делегировать задания Менеджерам, также не означает, что Менеджеры не могут быть дочерними элементами Работников в дереве компонентов. Мы поговорим об этом в последней главе.

Глава O: Open/Closed Principle (принцип открытости/закрытости)

Этот принцип определяется просто: компонент должен быть открыт для расширений и закрыт для модификаций. Однако понять разницу между расширением и модификацией не так просто. С точки зрения самого компонента любое расширение является модификацией. Поэтому можно сделать вывод, что этот принцип опирается исключительно на точку зрения внешнего потребителя компонента:

“Мне все равно, что вы будете делать с компонентом до тех пор, пока это не требует от меня изменения способа его использования!”.

Возможно, это вам знакомо. Это потому, что данный принцип является основой обратной совместимости, и он встречается буквально повсюду. Веб-API и тренды в проектировании всегда будут развиваться, и вы должны постоянно быть готовыми к эволюции компонентов, а в процессе этой эволюции, в свою очередь, всегда нужно* принимать во внимание интересы существующих потребителей. Рассмотрим принцип открытости/закрытости на простом практическом примере.

Перед вами поставили задачу создать компонент Button, который при наличии соответствующего условия будет отображать иконку, и вы ее выполнили:

interface ButtonProps
extends React.PropsWithChildren,
React.HTMLAttributes<HTMLButtonElement> {
icon?: React.ReactNode;
}

export const Button: React.FunctionComponent<ButtonProps> = ({
children,
icon,
...props
}) => {
return (
<button {...props} className="Button">
<span className="ButtonContent">
{!!icon && <span className="ButtonContentNode">{icon}</span>}
<span className="ButtonContentNode">{children}</span>
</span>
</button>
);
};

Через некоторое время дизайнер по продукту говорит: “Нам нужна кнопка с иконкой справа”. Каковы будут ваши действия? Сделайте паузу и подумайте, как бы вы поступили.

Добиться такого поведения можно несколькими способами, например введя новое свойство iconPlacement со значением по умолчанию left/start, которое будет управлять положением иконки. Если потребителей будет все устраивать, то я не буду возражать против этого, но лично я бы ввел два новых свойства и одно устаревшее:

interface ButtonProps
extends React.PropsWithChildren,
React.HTMLAttributes<HTMLButtonElement> {
/**
* @deprecated Используйте вместо этого`iconStart`
*/
icon?: React.ReactNode;
iconStart?: React.ReactNode;
iconEnd?: React.ReactNode;
}

export const Button: React.FunctionComponent<ButtonProps> = ({
children,
icon,
iconStart = icon, // Возврат к `icon`, если не предоставлено
iconEnd,
...props
}) => {
return (
<button {...props} className="Button">
<span className="ButtonContent">
{iconStart && <span className="ButtonContentNode">{iconStart}</span>}
<span className="ButtonContentNode">{children}</span>
{iconEnd && <span className="ButtonContentNode">{iconEnd}</span>}
</span>
</button>
);
};

Надеюсь, вы теперь лучше понимаете, что такое принцип открытости/закрытости и как его реализовать в React-приложении. Обратите внимание, что этот принцип не является идеальным решением (ремарка: идеала вообще не существует). Расширения неизбежно приводят к появлению больших файлов и в какой-то момент становятся контрпродуктивными. Иногда лучше пойти по пути расширения компонентов и создания их подтипов:

import { Button, ButtonProps } from './Button';

interface IconButtonProps extends Omit<ButtonProps, 'icon'> {
iconStart?: React.ReactNode;
iconEnd?: React.ReactNode;
}

export const IconButton: React.FunctionComponent<IconButtonProps> = ({
children,
iconStart,
iconEnd,
...props
}) => {
return (
<Button {...props}>
<span className="IconButtonContent">
{!!iconStart && (
<span className="IconButtonContentNode">{iconStart}</span>
)}
<span className="IconButtonContentNode">{children}</span>
{!!iconEnd && <span className="IconButtonContentNode">{iconEnd}</span>}
</span>
</Button>
);
};

Заметили потенциальную проблему в этом компоненте? Если нет, не волнуйтесь  —  мы поговорим об этом в следующей главе.

Глава L: Liskov Substitution Principle (принцип подстановки Лисков)

Принцип подстановки Лисков основан на ковариации: вы должны быть в состоянии заменить любой супертип его подтипом. Для тех, кто не изучал информатику в школе, это название может показаться несколько странным. Если я расширяю тип A, создавая новый тип B, то нелогично называть тип B подтипом A, а A  —  супертипом B, но так оно и есть: помните, что деревья в информатике растут вниз.

Так какую же ошибку я допустил в приведенном выше компоненте IconButton? IconButton расширяет компонент Button, другими словами, является подкомпонентом Button. Теперь у меня к вам два вопроса. Насчет первого вы, наверное, уже догадались, а вот второй будет посложнее.

  1. Можно ли заменить все вхождения компонента Button на его подтип IconButton?
  2. Целесообразно ли делать такую замену?

Ответ на первый вопрос: нет. Опустив свойство icon компонента Button, я фактически нарушил принцип подстановки Лисков. Компонент IconButton  —  скорее “ребенок-сирота”, а не классический подтип Button. Оба типа являются независимыми и незамещаемыми или, используя модный технический жаргон, инвариантами относительно друг друга.

Ответ на второй вопрос тот же: нет. Почему? Потому что в мои планы никогда не входило создание подтипа, или, другими словами, IconButton не должен был заменять Button.

Это отличный пример композиции и наследования в программировании: принцип подстановки Лисков основан на наследовании, а React  —  на композиции (мы удостоверимся в этом позже). Тот факт, что React основан на композиции, не означает, что в нем нет места наследованию. Если вы посмотрите на компонент Button, приведенный выше, то заметите, что я намеренно сделал его подтипом HTMLButtonElement, включив две важные функции:

  • extends React.PropsWithChildren принимает содержимое;
  • extends React.HTMLAttributes<HTMLButtonElement> принимает атрибуты.

Поскольку компонент Button соблюдает принцип подстановки Лисков**, я могу просто взять и спокойно заменить им все вхождения HTML-элемента button в приложении, что и было задумано.

Так почему же не заставить IconButton соблюдать принцип подстановки Лисков? Это, конечно, возможно, но очень скоро код превратится в кашу с запутанными компонентами и целым набором вещей, которые могут доставить немало хлопот.

Если вы находитесь на ранней стадии разработки приложений на React, я бы просто посоветовал игнорировать этот принцип. Если вы уверенный React-разработчик, то всегда начинайте с вопроса: предназначен ли этот компонент для замены базового компонента или элемента, который он расширяет? Если да, то убедитесь, что вы соблюдаете принцип подстановки Лисков, правильно передавая свойства, дочерние элементы, атрибуты и ref. Если нет, не делайте этого.

Глава I: Interface Segregation Principle (принцип разделения интерфейсов)

Если бы мне пришлось выбирать один из принципов SOLID, которому можно слепо следовать, не задавая вопросов, то это, несомненно, был бы принцип разделения интерфейсов. Его суть заключается в следующем: компонент не должен зависеть от свойств, которые он не использует.

На первый взгляд этот принцип может показаться до странности простым: зачем кому-то внедрять свойства, которые компонент не использует? Если мы используем правильную конфигурацию линтера, то даже не сможем нарочно сделать это. В чем же загвоздка?

Загвоздка заключается в рекурсивном характере этого принципа. Рассмотрим следующий компонент:

import Image, { type ImageProps } from 'next/image';

import { type User } from './types';

interface UserAvatarProps extends ImageProps {
user: User;
}

export const UserAvatar: React.FunctionComponent<UserAvatarProps> = ({
user,
...props
}) => {
return <Image {...props} src={user.imageUrl} alt={user.name} />;
};

Сначала кажется, что здесь нет неиспользуемых свойств. Мы используем user, и это единственное свойство, которое у нас есть. Однако тип User рекурсивно перетаскивает в компонент кучу неиспользуемых свойств, таких как email, id и т. д. Все это компоненту совершенно не нужно, и здесь нарушается принцип разделения интерфейсов. Тут полезно вспомнить наше обсуждение компонентов, названных Работниками (они не должны быть в курсе бизнес-логики). Более того, можно утверждать, что они вообще не должны знать ничего внешнего (но это не всегда возможно).

Исправим ситуацию.

import Image, { type ImageProps } from 'next/image';

interface AvatarProps extends ImageProps {
imageUrl: string;
altText: string;
}

export const Avatar: React.FunctionComponent<AvatarProps> = ({
imageUrl,
altText,
...props
}) => {
return <Image {...props} src={imageUrl} alt={altText} />;
};

Проанализируем изменения. Самое главное, что мы сделали,  —  это удалили импорт в строке 3 в первом примере. Тем самым мы фактически отделили UserAvatar от интерфейса User, что, в свою очередь, значительно повысило возможность повторного использования нашего компонента. Более того, мы даже убрали из именования упоминание о “User”  —  теперь аватары могут быть и у домашних питомцев.

Глава D: Dependency Inversion Principle (принцип инверсии зависимостей)

В заключительной главе расскажем о единственном и неповторимом крестном отце композиции в React, принципе всех принципов  —  принципе инверсии зависимостей. Официальная дефиниция этого принципа несколько сложна для понимания, особенно на первый взгляд. Кроме того, это единственный SOLID-принцип, который имеет двухчастное определение:

A. Модули высокого уровня не должны ничего импортировать из модулей низкого уровня. И те, и другие должны зависеть от абстракций (например, интерфейсов).

B. Абстракции не должны зависеть от деталей. Детали (конкретные реализации) должны зависеть от абстракций.

Если вам кажется, что звучит тяжеловесно, то вам не кажется  —  так оно и есть. Принцип инверсии зависимостей, как вы могли заметить, вполне заслуживает отдельной статьи. Если говорить о React, у меня есть для вас две новости  —  хорошая и плохая.

Хорошая: вы используете принцип инверсии зависимостей по умолчанию, даже не замечая этого.

Плохая: вы используете принцип инверсии зависимостей по умолчанию, даже не замечая его.

Итак, давайте разбираться с этим, безусловно, самым мощным принципом SOLID, лежащим в основе всего React. Начнем с названия: что такое зависимость? Это внешний код (модуль, пакет, функция, хук, компонент и т. д.), который я использую в компоненте. Есть ровно два способа появления этой зависимости “на моей территории”:

  • импорт: я импортирую ее из соответствующего модуля;
  • внедрение: она передается мне в качестве параметра (или свойства).

Импорт  —  это прямая зависимость, которую я практически не контролирую. Если эта зависимость изменит свое поведение, у меня будут проблемы.

Внедрение  —  это обратная зависимость: хотя я и завишу от того, что мне передается, я контролирую то, что может быть передано, с помощью абстракции  —  интерфейса свойств.

Обратите внимание на то, как тесно переплетается принцип инверсии зависимостей с принципом единой ответственности и принципом разделения интерфейсов. Помните, в нашем примере UserAvatar мы говорили о том, что компонент не должен быть в курсе бизнес-логики (быть Работником), а также о том, что мы не должны передавать свойства/информацию, которая не нужна компоненту. Принцип инверсии зависимостей добавляет третью причину, почему этот компонент плох,  —  мы создаем прямую зависимость, или, другими словами, абстракция (AvatarProps) зависит от деталей (User).

Второе и самое важное, что необходимо отметить,  —  это то, что инверсия зависимостей не ограничивается только передачей функций в компонентах. Буквально все примеры, посвященные этому принципу в React, касаются исключительно обработчиков событий, обходя вниманием самую главную задачу принципа инверсии зависимостей.

Еще раз перепишем компонент Button и посмотрим, сможете ли вы это заметить:

interface ButtonProps
extends React.PropsWithChildren,
React.HTMLAttributes<HTMLButtonElement> {
variant?: 'solid' | 'outlined' | 'blank';
onClick: React.MouseEventHandler<HTMLButtonElement>;
}

export const Button: React.FunctionComponent<ButtonProps> = ({
children,
variant = 'blank',
onClick,
...props
}) => {
const className = resolveClassName(variant);
return (
<button className={className} onClick={onClick} {...props}>
{children}
</button>
);
};

Итак, onClick  —  самый очевидный пример: он не может быть встроенным, иначе нарушится принцип единой ответственности, и его нельзя импортировать напрямую, так как нарушится принцип инверсии зависимостей. Видите ли вы здесь другие примеры внедрения зависимостей? А как насчет children? Передаются ли они в качестве свойств? Да. Я контролирую их тип? Да. И вот кульминация этой главы:

Всякий раз, когда вы говорите о паттерне композиции в React, вы говорите о принципе инверсии зависимостей. Это одно и то же.

Именно такое положение вещей позволяет Работникам из первой главы находиться выше в дереве компонентов по сравнению с Менеджерами  —  их зависимость инвертируется, и они фактически становятся контейнерами***, не зная, что именно они содержат, и, следовательно, не нарушая принцип единой ответственности.

Теперь вернемся к моему обещанию из главы L показать, что все в React является композицией. То, как мы передаем children в React,  —  не что иное, как синтаксический сахар. Посмотрите внимательно на эти компоненты и на то, как они используются:

interface AProps extends React.PropsWithChildren {}

interface BProps {
childNodes?: React.ReactNode | undefined;
}

export const A = ({ children }: AProps) => {
return <section>{children}</section>;
};

export const B = ({ childNodes }: BProps) => {
return <section>{childNodes}</section>;
};

export const Sections = () => {
return (
<>
{/** Когда вы пишете это: */}
<A>Children as a nested text node</A>
{/** "за кадром" происходит следующее: */}
<A children="Children as a prop instead" />
{/** А при желании можно даже переименовать свойство: */}
<B childNodes="Let's rename the prop" />
</>
);
};

Компоненты A и B идентичны, но есть одно отличие: я могу передавать данные компоненту более естественным (с точки зрения JSX) способом, используя припасенное свойство children.

Теперь поговорим о дискриминации типов. Тип ReactNode | undefined  —  это не закон как таковой, это просто наиболее общий тип, с которым React может работать без сбоев. Но мы вольны управлять типом, помните? Итак, сузим круг типов и перепишем приведенные выше разделы:

interface ANarrowProps {
children: string;
}

interface BNarrowProps {
childNodes: string;
}

export const ANarrow = ({ children }: ANarrowProps) => {
return <section>{children}</section>;
};

export const BNarrow = ({ childNodes }: BNarrowProps) => {
return <section>{childNodes}</section>;
};

export const Sections = () => {
return (
<>
<ANarrow>Children as a nested text node</ANarrow>
<ANarrow children="Children as prop instead" />
<BNarrow childNodes="Let's rename the prop" />
</>
);
};

Как видите, композиция в React не ограничивается только свойством children. Вы можете компоновать приложение с любым именем свойства; процесс не ограничивается только типом ReactNode, вы вольны компоновать приложение с любым типом свойства. Все в React является композицией.

С точки зрения React принцип инверсии зависимостей  —  это не что иное, как перенос ответственности на родителя. Кажется вполне логичным, если вспомнить паттерн Менеджеры/Работники, который мы обсуждали в первой главе. Но, как и в случае с принципом единой ответственности, попробуем опять переключиться из режима потребления в режим мышления. И мы снова видим довольно большую проблему. Вы ведь не можете просто поднимать ответственность до бесконечности. Другими словами, приложению потребуется как минимум один компонент Менеджера, а если он всего один, то представьте себе уровень и объем ответственности этого бедняги. Совсем как в реальной жизни! Это приведет к катастрофе с точки зрения читабельности и логики.

Несмотря на невероятные преимущества и стабильность, которые дает принцип инверсии зависимостей, развязывая компоненты, композицию следует останавливать на определенном уровне, и компоненты Менеджеров представляются отличным местом для этого. Однако, как правило, хорошей практикой считается максимально возможное следование принципу инверсии зависимостей, или, другими словами, уменьшение количества компонентов Менеджеров в приложении до минимума, и нарушение функциональности только в том случае, когда Менеджеры перегружаются. Например, если вы начинаете новый проект Next.js, то у вас будет два Менеджера на маршрут  —  layout.tsx и page.tsx. Позже, если у страницы окажется много функций, вы можете “разгрузить” page.tsx, введя компонент Менеджера для каждой функции, и так далее.


На этом завершим наш разговор. Общий вывод заключается в том, что, несмотря на свою укорененность в объектно-ориентированном программировании, принципы SOLID находят довольно широкое применение в разработке приложений на React. Поэтому знать их определенно полезно, особенно если учесть, что большое количество кода, который мы пишем по умолчанию, не задумываясь, на самом деле основано на этих фундаментальных принципах.

Сноски

* Эволюция компонента не всегда должна учитывать интересы потребителя. Общий подход заключается в том, чтобы объединить компоненты в библиотеку с контролем версий, что, в свою очередь, позволяет вносить в нее существенные изменения.

** Это намеренное упрощение, сделанное исключительно в демонстрационных целях. Правильным решением было бы направить ref и прекратить переопределение className.

*** По этой причине я предпочитаю именование Менеджер/Работник, а не пару “презентационный/контейнерный (содержательный)”, которую популяризировал Дэн Абрамов. “Содержать” не обязательно означает “управлять”.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Igor Snitkin: SOLID in React: the good, the bad, and the awesome

Предыдущая статьяВолшебство веб-разработки: создаем цифровую страну чудес
Следующая статьяGo: точечная вставка значения в структуру