[SDK и UI-библиотеки] Декомпозиция UI-компонентов

—

от автора

Это глава 44 раздела «SDK и UI-библиотеки» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

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

  • объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;

  • появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);

  • неоднозначность иерархий наследования свойств и опций компонентов.

Сделаем задачу более конкретной, и попробуем разработать наш SearchBox так, чтобы он допускал следующие модификации:

  1. Замена списочного представления предложений, например, на представление в виде карты с подсвечиваемыми метками:

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

    Результаты поиска на карте.

    Результаты поиска на карте.

    Результаты поиска на карте

  2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ):

    • иллюстрирует проблему полного удаления одного из субкомпонентов с передачей его бизнес-логики другим частям системы;

    Список результатов поиска с короткими описаниями предложений.

    Список результатов поиска с короткими описаниями предложений.
    Список результатов поиска, в котором некоторые предложения развёрнуты.

    Список результатов поиска, в котором некоторые предложения развёрнуты.
  3. Манипуляция доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым.

    Панель предложения с дополнительными кнопками и иконками.

    Панель предложения с дополнительными кнопками и иконками.

    В этом сценарии мы рассматриваем различные цепочки пропагирования информации и настроек до панели предложения и динамическое построение UI на их основе:

    • часть данных является свойствами реального объекта (логотип, номер телефона), полученными из API поиска предложений;

    • часть данных имеет смысл только в рамках конкретного UI и отражает механику его построения (кнопки «Вперёд» и «Назад»);

    • часть данных (иконки отмены и звонка) связаны с типом кнопки (бизнес-логикой, которую она несёт в себе).

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

`SearchBox` и его суб-компоненты.

`SearchBox` и его суб-компоненты.

В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:

class SearchBox implements ISearchBox {   // Ответственность `SearchBox`:   // 1. Создать контейнер для визуального   // отображения списка предложений,   // сгенерировать опции и создать   // сам компонент `OfferList`   constructor(container, options) {     …     this.offerList = new OfferList(       this,       offerListContainer,       offerListOptions     );   }   // 2. Выполнять поиск предложений   // при нажатии пользователем на кнопку поиска   // и предоставлять аналогичный программный   // интерфейс для разработчика   onSearchButtonClick() {     this.search(this.searchInput.value);   }   search(query) {     …   }   // 3. При получении новых результатов поиска   // оповестить об этом   onSearchResultsReceived(searchResults) {     …     this.offerList.setOfferList(searchResults)   }   // 4. Создавать заказы (и выполнять нужные   // операции над компонентами)   createOrder(offer) {     this.offerList.destroy();     ourCoffeeSdk.createOrder(offer);     …   }   // 5. Самоуничтожаться   destroy() {     this.offerList.destroy();     …   } }
class OfferList implements IOfferList {   // Ответственность OfferList:   // 1. Создать контейнер для визуального   // отображения панели предложений,   // сгенерировать опции и создать   // сам компонент `OfferPanel`   constructor(searchBox, container, options) {     …     this.offerPanel = new OfferPanel(       searchBox,       offerPanelContainer,       offerPanelOptions     );     …   }   // 2. Предоставлять метод для изменения   // списка предложений   setOfferList(offerList) { … }   // 3. При выборе предложения, вызывать метод   // его показа в панели предложений   onOfferClick(offer) {     this.offerPanel.show(offer)   }   // 4. Самоуничтожаться   destroy() {     this.offerPanel.destroy();     …   } }
class OfferPanel implements IOfferPanel {   constructor(searchBox, container, options) { … }   // Ответственность панели показа предложения:   // 1. Собственно, показывать предложение   show(offer) {      this.offer = offer;     …   }   // 2. Создавать заказ по нажатию на кнопку   // создания заказа   onCreateOrderButtonClick() {     this.searchBox.createOrder(this.offer);   }   // 3. Закрываться по нажатию на кнопку   // отмены   onCancelButtonClick() {     // …   }   // 4. Самоуничтожаться   destroy() { … } }

Интерфейсы ISearchBox / IOfferPanel / IOfferView также очень просты (конструкторы и деструкторы опущены):

interface ISearchBox {   search(query);   createOrder(offer); } interface IOfferList {   setOfferList(offerList); } interface IOfferPanel {   show(offer); }

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

  1. Показ списка предложений на карте: на первый взгляд, мы можем разработать альтернативный компонент показа списка предложений, скажем, OfferMap, который сможет использовать стандартную панель предложений. Но у нас есть одна проблема: если OfferList только отправляет команды для OfferPanel, то OfferMap должен ещё и получать обратную связь — событие закрытия панели, чтобы убрать выделение с метки. Наш интерфейс подобного не предусматривает. Имплементация этой функциональности не так и проста:

    class CustomOfferPanel extends OfferPanel {   constructor(     searchBox, offerMap, container, options   ) {     this.offerMap = offerMap;     super(searchBox, container, options);   }   onCancelButtonClick() {     offerMap.resetCurrentOffer();     super.onCancelButtonClick();   } } class OfferMap implements IOfferList {   constructor(searchBox, container, options) {     …     this.offerPanel = new CustomOfferPanel(       this,       searchBox,       offerPanelContainer,       offerPanelOptions     )   }   resetCurrentOffer() { … }   … }

    Нам пришлось создать новый класс CustomOfferPanel, который, в отличие от своего родителя, теперь работает не с любой имплементацией интерфейса IOfferList, а только с IOfferMap.

  2. Полные описания и кнопки действий в самом списке заказов — в этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента OfferList, он всё равно продолжит создавать OfferPanel и открывать его по выбору предложения.

  3. Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы придумаем какой-нибудь простой способ кастомизировать, например, текст кнопки «Сделать заказ», его поддержка всё равно будет ответственностью компонента OfferList:

    let searchBox = new SearchBox(…, {   offerPanelCreateOrderButtonText:     'Drink overpriced coffee!' }); class OfferList {   constructor(…, options) {   …     // Вычленять из опций SearchBox     // настройки панели предложений     // вынужен конструктор класса      // OfferList     this.offerPanel = new OfferPanel(…, {       createOrderButtonText: options         .offerPanelCreateOrderButtonText       …     })   } }

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

interface IOfferList {   …   onOfferPanelClose?(); }

и писать в коде OfferPanel что-то типа:

if (Type(this.offerList.onOfferPanelClose)   == 'function') {     this.offerList.onOfferPanelClose();   }

Что, во-первых, совершенно не красит наш код и, во-вторых, делает связность OfferPanel и OfferList ещё более сильной.

Как мы описывали ранее в главе «Слабая связность», избавиться от такого рода проблем мы можем, если перейдём от сильной связности к слабой, например, через генерацию событий вместо вызова методов. Компонент IOfferPanel мог бы бросать событие 'close', и тогда OfferList мог бы на него подписаться:

class OfferList {   setup() {     …     this.offerPanel.events.on(       'close',       function () {         this.resetCurrentOffer();       }     )   }   … }

Код выглядит более разумно написанным, но никак не уменьшает взаимозавимость компонентов: использовать OfferList без OfferPanel, как этого требует сценарий #2, мы всё ещё не можем.

Заметим, что в вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: OfferList инстанцирует OfferPanel и управляет ей напрямую. При этом OfferPanel приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам SearchBox, например, так:

class SearchBox() {   constructor() {     this.offerList = new OfferList(…);     this.offerPanel = new OfferPanel(…);     this.offerList.events.on(       'offerSelect', function (offer) {         this.offerPanel.show(offer);       }     );     this.offerPanel.events.on(       'close', function () {         this.offerList           .resetSelectedOffer();       }     );   } }

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

class SearchBox {   constructor() {     …     this.offerList.events.on(       'offerSelect', function (event) {         this.events.emit('offerSelect', {           offer: event.selectedOffer         });       }     );   }   … }

То есть заставить SearchBox транслировать события, возможно, с преобразованием данных. Мы даже можем заставить SearchBox транслировать любые события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в offerPanel (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы OfferList не только генерировал сам событие 'offerSelect', но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл:

class OfferList {   constructor(searchBox, …) {     …     searchBox.events.on(       'offerSelect',       this.selectOffer     );   }   selectOffer(offer) {     …     this.events.emit(       'offerSelect', offer     );   } }
class SearchBox {   constructor() {     …     this.offerList.events.on(       'offerSelect', function (offer) {         …         this.events.emit(           'offerSelect', offer         );       }     );   } }

Во избежание таких циклов мы можем разделить события:

class SearchBox {   constructor() {     …     // `OfferList` сообщает о низкоуровневых     // событиях, а `SearchBox` — о высокоуровневых     this.offerList.events.on(       'click', function (target) {         …         this.events.emit(           'offerSelect',           target.dataset.offer         )       }     )   } }

Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать 'click' на инстанции класса OfferList.

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

Давайте сформулируем, в чём состоит область ответственности каждого из наших компонентов:

  1. SearchBox отвечает за предоставление общего интерфейса. Он является точкой входа и для пользователя, и для разработчика. Если мы спросим себя: какой максимально абстрактный компонент мы всё ещё готовы называть SearchBox-ом? Очевидно, некоторый UI для ввода поискового запроса и его отображения, а также какое-то абстрактное создание заказа по предложениям.

  2. OfferList выполняет функцию показа пользователю какого-то списка предложений кофе. Пользователь может взаимодействовать со списком — просматривать его и «активировать» предложения (т.е. выполнять какие-то операции с конкретным элементом списка).

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

Следует ли из определения SearchBox необходимость наличия суб-компонента OfferList? Никоим образом: мы можем придумать самые разные способы показа пользователю предложений. OfferList — частный случай, каким образом мы могли бы организовать работу SearchBox-а по предоставлению UI к результатами поиска.

Следует ли из определения SearchBox и OfferList необходимость наличия суб-компонента OfferPanel? Вновь нет: даже сама концепция существования какой-то краткой и полной информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что OfferList и OfferPanel выполняют разные действия и имеют разные настройки. На уровне SearchBox вообще не важно, как результаты поиска представлены пользователю и в каких состояниях может находиться соответствующий UI.

Всё это приводит нас к простому выводу: мы не можем декомпозировать SearchBox просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между SearchBox, который не зависит от конкретной имплементации UI работы с предложениями и OfferList/OfferPanel, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных:

class SearchBoxComposer    implements ISearchBoxComposer {   // Ответственность `composer`-а состоит в:   // 1. Создании собственного контекста   // для дочерних компонентов   constructor(searchBox, container, options) {     …     // Контекст состоит из показанного списка     // предложений (возможно, пустого) и     // выбранного предложения (возможно, пустого)     this.offerList = null;     this.currentOffer = null;     // 2. Создании конкретных суб-компонентов     // и трансляции опций для них     this.offerList = this.buildOfferList();     this.offerPanel = this.buildOfferPanel();     // 3. Управлении состоянием и оповещении     // суб-компонентов о его изменении     this.searchBox.events.on(       'offerListChange', this.onOfferListChange     );     // 4. Прослушивании событий дочерних     // компонентов и вызове нужных действий     this.offerListComponent.events.on(       'offerSelect', this.selectOffer     );     this.offerPanelComponent.events.on(       'action', this.performAction     );   }   … }

Здесь методы-строители подчинённых компонентов, позволяющие переопределять опции компонентов (и, потенциально, их расположение на экране) выглядят как:

class SearchBoxComposer {   …   buildOfferList() {     return new OfferList(       this,       this.offerListContainer,       this.generateOfferListOptions()     );   }   buildOfferPanel() {     return new OfferPanel(       this,       this.offerPanelContainer,       this.generateOfferPanelOptions()     );   } }

Мы можем придать SearchBoxComposer-у функциональность трансляции любых контекстов. В частности:

  1. Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что OfferList показывает краткую информацию о предложений, а OfferPanel — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных:

    class SearchBoxComposer {   …   onContextOfferListChange(offerList) {     …     // `SearchBoxComposer` транслирует событие     // `offerListChange` как `offerPreviewListChange`     // специально для компонента `OfferList`,     // таким образом, исключая возможность      // зацикливания, и подготавливает данные     this.events.emit('offerPreviewListChange', {       offerList: this.generateOfferPreviews(         this.offerList,         this.contextOptions       )     });   } }
  2. Логику управления собственным состоянием (в нашем случае полем currentOffer):

    class SearchBoxComposer {   …   onContextOfferListChange(offerList) {     // Если в момент ввода нового поискового     // запроса пользователем показано какое-то     // предложение, его необходимо скрыть     if (this.currentOffer !== null) {       this.currentOffer = null;       // Специальное событие для       // компонента `offerPanel`       this.events.emit(         'offerFullViewToggle',          { offer: null }       );     }     …   } }
  3. Логику преобразования действий пользователя на одном из субкомпонентов в события или действия над другими компонентами или родительским контекстом:

    class SearchBoxComposer {   …   public performAction({     action, offerId   }) {     switch (action) {       case 'createOrder':         // Действие «создать заказ»         // нужно оттранслировать `SearchBox`-у         this.createOrder(offerId);         break;       case 'close':         // Действие «закрытие панели предложения»         // нужно распространить для всех         if (this.currentOffer != null) {           this.currentOffer = null;           this.events.emit(             'offerFullViewToggle', { offer: null }           );         }         break;       …     }   } }

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

  1. Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного IOfferList-а нам нужно переопределить метод buildOfferList так, чтобы он создавал наш кастомный компонент с картой.

  2. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный ISearchBoxComposer. Но мы при этом сможем использовать стандартный OfferList, поскольку Composer управляет и подготовкой данных для него, и реакцией на действия пользователей.

  3. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный SearchBoxComposer и OfferList), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые SearchBoxComposer транслирует с панели предложения.

Ценой этой гибкости является чрезвычайное усложнение взаимодействия. Все события и потоки данных должны проходить через цепочку таких Composer-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.

Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:


ссылка на оригинал статьи https://habr.com/ru/articles/758730/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *