При разработке любого, даже простого веб-приложения возникает необходимость повторного использования кода. В разных местах сайта рано или поздно обнаруживаются схожие участки разметки и логики, которые совсем не хочется дублировать. Однако, в решении этой задачи очень легко наступить на грабли и сделать все очень плохо.
Эта статья во многом вдохновлена докладом Павла Силина на РИТ 2017, однако здесь много моего собственного опыта и размышлений. Примеры будут на React + TypeScript, однако подход не привязан к какой-либо технологии.
Как не надо делать
Когда встречаешься с ситуацией дублирования кода, естественным желанием становится вынести этот код в отдельный компонент и использовать везде, где нужно. Возьмем для примера модальное окно. Казалось бы, что может быть проще — взяли и сделали:
ShowModalWindow(header: string, content: JSX.Element): Promise<ModalWindowResult>;
Все отлично, дублирование кода устранено, мы счастливы. Но вот мы продолжаем разработку, и в каком-то случае оказалось недостаточно одной кнопки "ОК", нужна еще и "Отмена". Укоряем себя, что сразу не подумали, и добавляем параметр:
ShowModalWindow(header: string, content: JSX.Element, buttons?: string[]): Promise<ModalWindowResult>;
Проблема решилась, разработка идет дальше. В один прекрасный момент тестировщики находят багу — если открыть два модальных окна подряд, то затемнение фона накладывается и становится слишком темным. ОК, тут уже трудно себя укорить — разве можно было это предусмотреть? Ну да ладно, добавляем еще параметр:
ShowModalWindow(header: string, content: JSX.Element, buttons?: string[], showOverlay?: boolean): Promise<ModalWindowResult>;
Нетрудно догадаться, что на этом история не закончилась. Позже понадобилось показывать окна без "крестика" в правом верхнем углу, без заголовка либо с другим заголовком, с другими отступами от краев окна, какие-то окна нужно было закрывать по щелчку во вне, а какие-то нет…
Неизбежно разрастающееся количество опций у компонента — это "попахивает", но с этим еще как-то можно мириться. Вот что действительно ужасно, так это то, что каждый нетипичный случай использования компонента заставлял нас изменять компонент, который используется во многих других местах. При добавлении каждой опции мы правили код, правили верстку и этим теоретически могли сломать логику где-то еще, где используется то же модальное окно. То есть, добавление новых фич грозит появлением регрессий в самых неожиданных местах.
В моем примере была функция, но это может быть что угодно — реакт-компонент с огромными props, jquery-плагин со множеством опций, базовый класс с кучей наследников и переопределяемых методов, ASP.NET Razor хелпер, со множеством параметров, scss mixin и т.д. Наступить на эти грабли можно в любой технологии и в самых разных видах.
Заменяй и властвуй
Решение этой проблемы придумали еще римляне — разделяй и властвуй, а Роберт Мартин еще в 2000-х сформулировал принципы SOLID. И несмотря на то, что SOLID больше об объектно-ориентированной архитектуре, а react больше о функциональной парадигме — все эти принципы можно и нужно применять при проектировании повторно используемых react-компонентов.
Однако важно соблюдать их все сразу, а не по отдельности. Допустим, недостаточно просто делать "маленькие компоненты". Это только первая буква S, и без всех остальных это ничего не даст. Давайте пробежимся по всем буквам:
- S (single responsibility) — делать повторно используемые компоненты очень маленькими и простыми, с минимумом ответственности;
- O (open-closed) — никогда, ни при каких обстоятельствах не модифицируем код компонентов, которые часто используются;
- L (Liskov substitution) — любой компонент может быть заменен другим так, что все остальные компоненты не должны заметить подмены;
- I (interface segregation) — вместо написания "обобщенных" компонентов на все случаи жизни пишем простые конкретные реализации;
- D (dependency inversion) — решение о том, какой из компонентов будет использован в каждом случае, должен принимать вызывающий код.
На практике это выглядит следующим образом. Мы пишем простые (до безобразия простые) компоненты, которые сочленяются между собой как детальки LEGO. Ни одна деталька ничего не знает о других. Когда нужно сделать конкретную вещь — мы берем эту коробку конструктора и составляем именно то, что нам нужно. Если какая-то деталь нам не подходит, мы запросто можем ее выкинуть, и взять другую (например, сделать свою). Это очень просто, потому что каждая из деталей сама по себе тривиальная, и ничего не стоит сделать другую, похожую, но подходящую под данный конкретный случай. Так что, вместо того чтобы изменять существующие компоненты, мы просто заменяем их, благодаря чему мы не можем даже теоретически что-то сломать в другом месте приложения.
Ключевой момент здесь в том, что мы вместо изменения компонента, который используется во многих местах, просто заменять его на другой. Это важно для компонентов в веб, потому что любое изменение в стилях может повлечь нарушение верстки в каких-то обстоятельствах использования компонента. Единственный надежный способ обезопасить себя от этого — не изменять однажды написанные компоненты (если они многократно используются).
Давайте отрефакторим наше модальное окно в соответствии с этими принципами. Нам нужно сделать модальное окно примерно следующего вида:
Как научил нас горький опыт, измениться в этом окне может все, что угодно. Начиная с кнопок и заканчивая отступами у содержимого. Это обусловлено тем, что модальное окно очень много где используется. К проектированию таких часто используемых компонентов нужно подходить особенно тщательно. Вот, какой набор компонентов у меня получился:
- Позиционирование окна — располагает что-либо по центру экрана;
- Затемнение фона — создает полупрозрачный div на весь экран;
- Коробка окна — определяет размеры и заливку внутри окна;
- Коробка заголовка — добавляет отступы для заголовка и рисует разделительную линию;
- Заголовок — опередяет стилизацию текста заголовка (в основном размер шрифта);
- Кнопка закрытия (крестик);
- Коробка содержимого — добавляет отступы для содержимого окна;
- Коробка кнопок диалога — добавляет отступы и позиционирует кнопки в правую часть;
- Кнопка — просто обычная кнопка, никак не связанная с диалогом.
Получается что-то вроде этого:
<ModalBackdrop onClick={() => this.setState({ dialogOpen: false })} />
<ModalDialog open={this.state.dialogOpen} >
<ModalDialogBox>
<ModalDialogHeaderBox>
<ModalDialogCloseButton onClick={() => this.setState({ dialogOpen: false })} />
<ModalDialogHeader>Dialog header</ModalDialogHeader>
</ModalDialogHeaderBox>
<ModalDialogContent>Some content</ModalDialogContent>
<ModalDialogButtonPanel>
<Button onClick={() => this.setState({ dialogOpen: false })} key="cancel">
{resources.Navigator_ButtonClose}
</Button>
<Button disabled={!this.state.directoryDialogSelectedValue}
onClick={this.onDirectoryDialogSelectButtonClick} key="ok">
{resources.Navigator_ButtonSelect}
</Button>
</ModalDialogButtonPanel>
</ModalDialogBox>
</ModalDialog>
</ModalBackdrop>
Каждый из этих компонентов, как правило, добавляет один div и несколько css-правил. Например, ModalDialogContent выглядит так:
// JS
export const ModalDialogContent = (props: IModalDialogContentProps) => {
return (
<div className="modal-dialog-content-helper">{props.children}</div>
);
}
// CSS
.modal-dialog-content-helper {
padding: 0 15px 20px 15px;
}
Если в будущем мне понадобится сделать модальное окно с другими отступами, то я просто заменю ModalDialogContent на обычный div, и задам свои собственные отступы. Если мне понадобится убрать затемнение, я просто уберу ModalBackdrop. Такая гибкость достигается за счет соблюдения всех принципов SOLID: компоненты простые и конкретные (S, I), ничего друг о друге не знают (D), поэтому проще их заменить (L), чем добавлять какие-то опции (O).
Стоит заметить, что идеал, конечно, недостижим. Например, ModalDialogBox определяет размеры и заливку. То есть, у него вроде две ответственности. На такие компромиссы приходится идти, чтобы избежать совсем уж сильной многословности. Однако, это не так страшно, так как в будущем мы всегда сможем заменить этот компонент на два других — отдельные компоненты для размеров и заливки, если в этом появится такая необходимость. Прелесть данного подхода именно в том, что он прощает ошибки проектирования. Вы всегда сможете их исправить после, добавить дополнительную гибкость, не ломая уже написанный ранее код.
Если такой уровень гибкости нужен редко, то для простоты использования в стандартных случаях можно сделать компоненты-обертки, которые просто будут объединять в себе некоторые из этих маленьких компонентов. Они будут иметь много опций, но это допустимо — мы всегда можем заменить эти обертки другими, либо использовать напрямую исходные компоненты. Например, мы можем сделать следующую обертку:
<CommonModalDialog header="Header text"
isOpen={this.state.open} onClose={this.onClose}>
Modal content
</CommonModalDialog>
Важно понимать, что составные повторно используемые компоненты не должны меняться, также как и простые. Любое изменение может повлечь поломку верстки в каком-то конкретном случае, поэтому если в компоненте что-то не подходит, то нужно просто заменить его на другой. Код обертки будет представлять простую композицию существующих компонентов (все то, что выше), поэтому заменить их не составит труда.
Назад в реальность
В идеальном мире мы бы везде писали все по SOLID, ходили в белых одеждах и хлопали от счастья крылышками. К сожалению, в реальности этот подход применять не везде оправдано, а местами даже вредно. Вот его основные недостатки:
- Дороговизна. Проектирование и разработка всех этих маленьких компонентов требует много времени и сил. Мало того, что просто приходится много писать служебного кода, документации и тестов, так нужно еще и спроектировать эти компоненты таким образом, чтобы они ничего друг о друге не знали, но при этом корректно между собой взаимодействовали. Это очень сложно, и с точки зрения бизнеса — стоит много денег (время разработчика — деньги).
- Дробление сущностей. В примере выше, вместо одного модального окна, у нас получилось крошечных 9 компонентов. Соответственно, логика работы окна оказалась размазана по всем этим составляющим. В данном случае это не критично т.к. особой логики у окна нет, но для компонентов приложения это может иметь серьезные последствия.
Рассмотрим подробнее на примере меню пользователя вконтакте.
Можно начать разбивать его на кучу независимых маленьких компонентов, отдельно будет иконка пользователя, отдельно имя, отдельно менюшка… Мы потратим кучу сил на то, чтобы организовать взаимодействие между этими независимыми компонентами. Но что в итоге мы получим? Это меню существует в единственном числе, и только в таком виде. У этого меню есть некоторая логика — своя единая модель данных (информация о пользователе), определенный состав меню (набор действий), поведение (по щелчку открывается меню). Все это логика конкретного приложения, которая определяется бизнес-задачами и диктуется предметной областью. Размазывая эту логику по многим местам, не только создаем себе лишние трудности, но и усложняем поддержку и сопровождение нашего сайта. Другому программисту будет трудно найти место, где вешается обработчик на событие клика, который открывает менюшку, потому что он будет (безусловно по SOLID) запрятан где-нибудь в глубинах нашей архитектуры.
Отсюда следует, что нужно четко разделять повторно используемые компоненты и компоненты приложения. Первые являются максимально абстрактными, простыми и гибкими, вторые же используют первые, но при этом являются максимально цельными и понятными. Размер компонентов приложения должен ограничиваться исходя из концептуальной декомпозиции сайта на логические блоки и очевидных соображений, чтобы размер файлов не был слишком огромным. Чтобы бизнес-компоненты при этом не превращались в монстров, из них нужно максимально извлекать все, что может стать повторно используемым компонентом. То есть бизнес-компонент, в идеале, должен работать в основном с детальками LEGO, составляя из них конкретный вид сайта.
Допустим, в нашем примере мы можем создать группу повторно используемых компонентов для описания меню, для отрисовки иконки с закруглением, кнопки раскрытия меню и т.д. Все эти компоненты никак не были бы связаны именно с пользовательским меню. Их можно было бы даже вынести в отдельный npm-пакет и использовать в других проектах. Они были бы простыми и гибкими, следовали всем принципам SOLID. Однако сам компонент, который рисует меню пользователя, остался бы хоть и не совсем маленьким, но зато цельным и понятным. В одном файле была бы описана вся связанная с этим логика приложения, и разобраться в ней было бы очень просто.
Заключение
При разработке повторно используемых компонентов важно соблюдать все принципы SOLID. Это позволит беспрепятственно использовать эти компоненты в самых разных контекстах, адаптировать под самые невообразимые ситуации и при этом не бояться, что добавление новой функциональности на сайт сломает уже существующий. Правильное применение этих принципов нивелирует ошибки проектирования и позволяет принимать компромиссные решения между гибкостью и многословностью без ущерба для расширяемости.
Важно также разделять компоненты на повторно используемые и компоненты приложения. Последние лучше писать относительно большими и без лишней гибкости, но цельными и удобными в сопровождении. Повторное использование таких компонентов должно быть минимальным, и, если возникает такая необходимость, то нужно перевести его в категорию повторно используемых со всеми вытекающими. Такой подход позволяет с одной стороны следовать DRY, но, с другой стороны, держать код понятным и легким в поддержке и сопровождении.