Это глава 44 раздела «SDK и UI-библиотеки» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента SearchBox
из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:
объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;
появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);
неоднозначность иерархий наследования свойств и опций компонентов.
Сделаем задачу более конкретной, и попробуем разработать наш SearchBox
так, чтобы он допускал следующие модификации:
-
Замена списочного представления предложений, например, на представление в виде карты с подсвечиваемыми метками:
иллюстрирует проблему полной замены одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы, а также сложности имплементации разделяемого состояния;
Результаты поиска на карте
-
Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ):
иллюстрирует проблему полного удаления одного из субкомпонентов с передачей его бизнес-логики другим частям системы;
-
Манипуляция доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым.
В этом сценарии мы рассматриваем различные цепочки пропагирования информации и настроек до панели предложения и динамическое построение UI на их основе:
часть данных является свойствами реального объекта (логотип, номер телефона), полученными из API поиска предложений;
часть данных имеет смысл только в рамках конкретного UI и отражает механику его построения (кнопки «Вперёд» и «Назад»);
часть данных (иконки отмены и звонка) связаны с типом кнопки (бизнес-логикой, которую она несёт в себе).
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их OfferList
и OfferPanel
.
В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:
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 и у нас не было бы задачи разрешать кастомизацию этих компонентов, подобная реализация была бы стопроцентно уместной. Попробуем, однако, представить, как мы будем решать описанные выше задачи:
-
Показ списка предложений на карте: на первый взгляд, мы можем разработать альтернативный компонент показа списка предложений, скажем,
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
. Полные описания и кнопки действий в самом списке заказов — в этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента
OfferList
, он всё равно продолжит создаватьOfferPanel
и открывать его по выбору предложения.-
Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы придумаем какой-нибудь простой способ кастомизировать, например, текст кнопки «Сделать заказ», его поддержка всё равно будет ответственностью компонента
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-компонента в самых различных парадигмах, но так и не получили ни одного приемлемого решения. Вывод, который мы должны сделать, следующий: проблема не в конкретных интерфейсах и не в подходе к решению. В чём же она тогда?
Давайте сформулируем, в чём состоит область ответственности каждого из наших компонентов:
SearchBox
отвечает за предоставление общего интерфейса. Он является точкой входа и для пользователя, и для разработчика. Если мы спросим себя: какой максимально абстрактный компонент мы всё ещё готовы называтьSearchBox
-ом? Очевидно, некоторый UI для ввода поискового запроса и его отображения, а также какое-то абстрактное создание заказа по предложениям.OfferList
выполняет функцию показа пользователю какого-то списка предложений кофе. Пользователь может взаимодействовать со списком — просматривать его и «активировать» предложения (т.е. выполнять какие-то операции с конкретным элементом списка).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
-у функциональность трансляции любых контекстов. В частности:
-
Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что
OfferList
показывает краткую информацию о предложений, аOfferPanel
— полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных:class SearchBoxComposer { … onContextOfferListChange(offerList) { … // `SearchBoxComposer` транслирует событие // `offerListChange` как `offerPreviewListChange` // специально для компонента `OfferList`, // таким образом, исключая возможность // зацикливания, и подготавливает данные this.events.emit('offerPreviewListChange', { offerList: this.generateOfferPreviews( this.offerList, this.contextOptions ) }); } }
-
Логику управления собственным состоянием (в нашем случае полем
currentOffer
):class SearchBoxComposer { … onContextOfferListChange(offerList) { // Если в момент ввода нового поискового // запроса пользователем показано какое-то // предложение, его необходимо скрыть if (this.currentOffer !== null) { this.currentOffer = null; // Специальное событие для // компонента `offerPanel` this.events.emit( 'offerFullViewToggle', { offer: null } ); } … } }
-
Логику преобразования действий пользователя на одном из субкомпонентов в события или действия над другими компонентами или родительским контекстом:
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; … } } }
Если теперь мы посмотрим на кейсы, описанные в начале главы, то мы можем наметить стратегию имплементации каждого из них:
Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного
IOfferList
-а нам нужно переопределить методbuildOfferList
так, чтобы он создавал наш кастомный компонент с картой.Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный
ISearchBoxComposer
. Но мы при этом сможем использовать стандартныйOfferList
, посколькуComposer
управляет и подготовкой данных для него, и реакцией на действия пользователей.Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный
SearchBoxComposer
иOfferList
), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которыеSearchBoxComposer
транслирует с панели предложения.
Ценой этой гибкости является чрезвычайное усложнение взаимодействия. Все события и потоки данных должны проходить через цепочку таких Composer
-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.
Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:
-
исходный код доступен на www.github.com/twirl/The-API-Book/docs/examples
там же предложены несколько задач для самостоятельного изучения;
песочница с «живыми» примерами доступна на twirl.github.io/The-API-Book.