Это глава 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.