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

SOLID — это аббревиатура, каждая буква которой представляет один из пяти принципов проектирования, а именно:

  • Единыйпринцип единой ответственности (SRP)
  • Oпринцип закрытого пера (OCP)
  • Принцип замены Лескова (LSP)
  • Принцип разделения интерфейсов (ISP)
  • Dпринцип инверсии зависимостей (DIP)

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

Прежде чем мы начнем, сделаем большое предостережение. Принципы SOLID были задуманы и изложены с учетом объектно-ориентированного языка программирования. Эти принципы и их объяснение сильно зависят от концепций классов и интерфейсов, тогда как в JS их нет. То, что мы часто называем «классами» в JS, — это просто двойники классов, смоделированные с использованием его системы прототипов, а интерфейсы вообще не являются частью языка (хотя добавление TypeScript немного помогает). Более того, способ, которым мы пишем современный код React, далек от объектно-ориентированного — во всяком случае, он кажется более функциональным.

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

Итак, давайте возьмем на себя некоторые вольности.

Принцип единой ответственности (SRP)

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

Начнем с осмотра изнутри. Чтобы наши компоненты выполняли одну внутреннюю функцию, мы можем:

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

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

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

Прежде всего, всякий раз, когда мы соединяем хуки useState и useEffect, это хорошая возможность извлечь их в пользовательский хук:

Теперь наш хук useUsers занимается только одним — получением пользователей из API. Это также сделало наш основной компонент более читабельным не только потому, что он стал короче, но и потому, что мы заменили структурные хуки, необходимые для расшифровки назначения, на доменный хук, назначение которого сразу видно из названия.

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

Как и в случае с предыдущим изменением, мы сделали наш основной компонент меньше и читабельнее, выделив логику рендеринга пользовательских элементов в отдельный компонент.

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

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

Здесь мы создали хук useActiveUsers, чтобы позаботиться о логике выборки и фильтрации (мы также запомнили отфильтрованные данные для хороших показателей), в то время как нашему основному компоненту остается сделать самый минимум — отобразить данные, которые он получает от хука.

Теперь, в зависимости от нашей интерпретации «одной вещи», мы можем утверждать, что компонент по-прежнему сначала получает данные, а затем отрисовывает их, что не является «одной вещью». Мы могли бы разделить его еще дальше, вызывая хук в одном компоненте, а затем передавая результат другому в качестве реквизита, но я нашел очень мало случаев, когда это действительно полезно в реальных приложениях, поэтому давайте будем проще с определением и примем «рендеринг данных, которые получает компонент» как «одно».

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

Чтобы лучше понять это, давайте рассмотрим следующий пример. Представьте себе приложение для обмена сообщениями (например, Telegram или FB Messenger) и компонент, который отображает одно сообщение. Это может быть так просто:

Если мы хотим отправлять изображения вместе с текстом, компонент становится немного сложнее:

Идя дальше, мы можем добавить поддержку голосовых сообщений, что еще больше усложнит компонент:

Нетрудно представить, как со временем, когда мы добавим поддержку видео, стикеров и т. д., этот компонент будет расти и превращаться в гигантскую кашу. Давайте подытожим, что здесь происходит.

На старте наш компонент соответствует SRP и делает ровно одну вещь — отрисовывает сообщение. Однако по мере развития приложения мы постепенно добавляем в него все больше и больше функций. Мы начинаем с небольших условных изменений в логике рендеринга, затем переходим к более агрессивной полной замене дерева рендеринга, и где-то по пути исходное определение «одной вещи» для этого компонента становится слишком широким, слишком общим. Мы начинаем с одноцелевого компонента и заканчиваем многоцелевым универсальным компонентом.

Способ решить эту проблему — избавиться от универсального компонента Message в пользу более специализированных, одноцелевых компонентов:

Логика внутри этих компонентов сильно отличается друг от друга, поэтому естественно, что они развиваются по отдельности.

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

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

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

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

Принцип «открыто-закрыто» (OCP)

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

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

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

Чтобы решить эту проблему, мы можем использовать компонентную композицию. Нашему компоненту Header не нужно заботиться о том, что он будет отображать внутри, и вместо этого он может делегировать эту ответственность компонентам, которые будут его использовать, используя children prop:

При таком подходе мы полностью удаляем переменную логику, которая была внутри Header, и теперь можем использовать композицию, чтобы поместить туда буквально все, что захотим, не изменяя сам компонент. Хороший способ думать об этом состоит в том, что мы предоставляем заполнитель в компоненте, к которому мы можем подключиться. И мы также не ограничены одним заполнителем для каждого компонента — если нам нужно иметь несколько точек расширения (или если свойство children уже используется для другой цели), мы можем вместо этого использовать любое количество свойств. Если нам нужно передать некоторый контекст из Header компонентам, которые его используют, мы можем использовать шаблон рендеринга реквизита. Как видите, композиция может быть очень мощной.

Следуя принципу открытого-закрытого, мы можем уменьшить связь между компонентами и сделать их более расширяемыми и пригодными для повторного использования.

Принцип замещения Лисков (LSP)

LSP рекомендует проектировать объекты таким образом, чтобы «объекты подтипа могли быть заменены объектами супертипа». В своем первоначальном определении отношения подтип/супертип достигаются через наследование классов, но это не обязательно должно быть так. В более широком смысле наследование — это просто базирование одного объекта на другом объекте при сохранении схожей реализации, и это то, что мы делаем в React довольно часто.

Очень простой пример отношения подтип/супертип можно продемонстрировать с помощью компонента, созданного с помощью библиотеки styled-components (или любой другой библиотеки CSS-in-JS, использующей аналогичный синтаксис):

В приведенном выше коде мы создаем StyledButton на основе компонента Button. Этот новый компонент StyledButton добавляет несколько классов CSS, но сохраняет реализацию исходного Button, поэтому в этом контексте мы можем думать о наших Button и StyledButton как о компонентах супертипа и подтипа.

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

Вот более интересный пример базирования одного компонента на другом:

В приведенном выше коде мы используем базовый компонент Input для создания его расширенной версии, которая также может отображать количество символов во входных данных. Хотя мы добавляем к нему новую логику, CharCountInput по-прежнему сохраняет функциональность исходного компонента Input. Интерфейс компонента также остается неизменным (оба входа принимают одни и те же свойства), поэтому LSP снова наблюдается.

Принцип замещения Лисков особенно полезен в контексте компонентов, имеющих общие черты, таких как значки или элементы ввода — один компонент значка должен быть заменен другим значком, более конкретные компоненты DatePickerInput и AutocompleteInput должны быть заменены более общим компонентом Input и т. д. . Однако следует признать, что этот принцип не может и не всегда должен соблюдаться. Чаще всего мы создаем подкомпоненты с целью добавления новых функций, которых нет в их суперкомпонентах, и которые часто нарушают интерфейс суперкомпонента. Это вполне допустимый вариант использования, и мы не должны пытаться втиснуть LSP повсюду.

Что касается компонентов, в которых LSP действительно имеет смысл, нам нужно убедиться, что мы не нарушаем принцип без необходимости. Давайте рассмотрим два распространенных способа, которыми это может произойти.

Первый предполагает отрезание части реквизита без причины:

Здесь мы переопределяем реквизиты для CustomInput вместо использования реквизитов, которые ожидает <input />. В результате мы теряем большое количество свойств, которые может использовать <input />, тем самым нарушая его интерфейс. Чтобы исправить это, мы должны использовать реквизиты, которые ожидает исходный <input />, и передать их все с помощью оператора распространения:

Другой способ нарушить LSP — использовать псевдонимы для некоторых свойств. Это может произойти, когда свойство, которое мы хотим использовать, имеет конфликт имен с локальной переменной:

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

Принцип разделения интерфейсов (ISP)

Согласно ISP, «клиенты не должны зависеть от интерфейсов, которые они не используют». Ради React-приложений переведем это на «компоненты не должны зависеть от пропсов, которые они не используют».

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

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

Наш компонент Thumbnail, который он использует для каждого элемента, может выглядеть примерно так:

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

Чтобы понять, почему это проблематично, представьте, что в дополнение к видео мы также решили отображать миниатюры для прямых трансляций, причем оба типа медиаресурсов смешаны в одном списке.

Мы представим новый тип, определяющий объект прямой трансляции:

А это наш обновленный компонент VideoList:

Как видите, здесь у нас есть проблема. Мы можем легко отличить видео от объекта прямой трансляции, но мы не можем передать последний в компонент Thumbnail, потому что Video и LiveStream несовместимы. Во-первых, у них разные типы, так что TypeScript сразу бы пожаловался. Во-вторых, они содержат URL-адрес эскиза в разных свойствах — объект видео называет его coverUrl, объект прямой трансляции называет его previewUrl. В этом суть проблемы, когда компоненты зависят от большего количества реквизитов, чем им на самом деле нужно — они становятся менее пригодными для повторного использования. Итак, давайте исправим это.

Мы проведем рефакторинг нашего компонента Thumbnail, чтобы убедиться, что он полагается только на те реквизиты, которые ему необходимы:

С этим изменением теперь мы можем использовать его для рендеринга миниатюр как видео, так и прямых трансляций:

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

Принцип инверсии зависимостей (DIP)

Принцип инверсии зависимости гласит, что «следует полагаться на абстракции, а не на конкреции». Другими словами, один компонент не должен напрямую зависеть от другого компонента, а скорее они оба должны зависеть от некоторой общей абстракции. Здесь «компонент» относится к любой части нашего приложения, будь то компонент React, служебная функция, модуль или сторонняя библиотека. Этот принцип может быть трудно понять абстрактно, поэтому давайте сразу перейдем к примеру.

Ниже у нас есть компонент LoginForm, который отправляет учетные данные пользователя в некоторый API при отправке формы:

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

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

С этим изменением наш компонент LoginForm больше не зависит от модуля api. Логика отправки учетных данных в API абстрагируется с помощью обратного вызова onSubmit, и теперь ответственность за конкретную реализацию этой логики лежит на родительском компоненте.

Для этого мы создадим подключенную версию LoginForm, которая делегирует логику отправки формы модулю api:

Компонент ConnectedLoginForm служит связующим звеном между api и LoginForm, при этом сами они остаются полностью независимыми друг от друга. Мы можем повторять их и тестировать изолированно, не беспокоясь о поломке зависимых движущихся частей, поскольку их нет. И пока оба LoginForm и api придерживаются согласованной общей абстракции, приложение в целом будет продолжать работать, как и ожидалось.

В прошлом этот подход создания «тупых» презентационных компонентов с последующим внедрением в них логики также использовался многими сторонними библиотеками. Наиболее известным примером этого является Redux, который связывает свойства обратного вызова в компонентах с dispatch функциями, используя connect компонент более высокого порядка (HOC). С введением хуков этот подход стал несколько менее актуальным, но внедрение логики через HOC по-прежнему полезно в приложениях React.

В заключение, принцип инверсии зависимостей направлен на минимизацию связи между различными компонентами приложения. Как вы, наверное, заметили, минимизация является повторяющейся темой во всех принципах SOLID — от минимизации объема ответственности за отдельные компоненты до минимизации межкомпонентной осведомленности и зависимостей между ними.

Заключение

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

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

Первоначально опубликовано на https://konstantinlebedev.com.