1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-31 22:09:37 +02:00

Major Chapter 44 Refactoring

This commit is contained in:
Sergey Konstantinov 2023-08-10 14:55:00 +03:00
parent 131b99e5df
commit e3ca790b8f
2 changed files with 419 additions and 187 deletions

View File

@ -11,6 +11,7 @@ The `index.html` page includes a living example for each of the discussed scenar
The following improvements to the code are left as an exercise for the reader:
* Make all builder functions configurable through options
* Make `ISearchBoxComposer` a composition of two interfaces: one facade to interact with a `SearchBox`, and another facade to communicate with child components.
* Create a separate composer to close the gap between `OfferPanelComponent` and its buttons
* Add returning an operation status from the `SearchBox.search` method:
```

View File

@ -1,6 +1,6 @@
### [Декомпозиция UI-компонентов][sdk-decomposing]
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента (внешнего вида, бизнес-логики или поведения) приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента `SearchBox` из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента `SearchBox` из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:
* объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;
* появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);
* неоднозначность иерархий наследования свойств и опций компонентов.
@ -11,238 +11,469 @@
[![APP](/img/mockups/05.png "Результаты поиска на карте")]()
2. Добавление кнопки быстрого заказа в каждое предложение в списке:
2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)
* иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
[![APP](/img/mockups/06.png "Список результатов поиска с кнопками быстрых действий")]()
3. Брендирование предложения и кнопки заказа иконкой сети кофеен:
* иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
3. Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации:
* кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
* кнопка «позвонить» показывается динамически, если номер кофейни известен, и, таким образом, требует возможности определения списка показываемых кнопок динамически, отдельно для каждого конкретного предложения.
Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов;
[![APP](/img/mockups/07.png "Дополнительная кнопка «Позвонить»")]()
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, которые можно будет кастомизировать под конкретную задачу. Например, мы можем реализовать связь между ними следующим образом:
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их OfferList и OfferPanel. В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:
```
class SearchBox {
// Компонент списка предложений
public offerList: OfferList;
// Панель отображения
// выбранного предложения
public offerPanel: 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.offerListDestroy();
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() { … }
}
```
// Инициализация
setupComponents() {
// Подписываемся на клик по
// предложению
this.offerList.events.on(
'click',
(event) => {
this.selectedOffer =
event.target.offer;
this.offerPanel.show(
this.selectedOffer
);
Интерфейсы `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.
2. Полные описания и заказ в самом списке заказов. В этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента `OfferList`, он всё равно продолжит создавать `OfferPanel` и открывать его по выбору предложения.
3. Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы задаимся какой нибудь простой кастомизацией, например, текста кнопки «Сделать заказ», то в данном коде она фактически является ответственностью класса `OfferList`:
```
const searchBox = new SearchBox(…, {
offerPanelCreateOrderButtonText:
'Drink overpriced coffee!'
});
this.offerPanel.on(
'click', () => {
this.createOrder();
class OfferList {
constructor(…, options) {
// Вычленять из опций `SearchBox`
// настройки панели предложений
// вынужен конструктор класса
// `OfferList`
this.offerPanel = new OfferPanel(…, {
createOrderButtonText: options
.offerPanelCreateOrderButtonText
})
}
}
```
Самая же неприятная особенность кода из п. 1 — его очень плохая расширяемость. Допустим, мы решили сделать функциональность реакции `OfferList` на закрытие панели предложений частью интерфейса, чтобы программист мог ей воспользоваться. Для этого нам придётся объявить новый необязательный метод:
```
interface IOfferList {
onOfferPanelClose?();
}
```
и писать в коде OfferPanel что-то типа:
```
if (Type(this.offerList.onOfferPanelClose)
== 'function') {
this.offerList.onOfferPanelClose();
}
```
Что, во-первых, совершенно не красит наш код и, во-вторых, делает связность `OfferPanel` и `OfferList` ещё более сильной.
Как мы описывали ранее в главе «[Слабая связность](#back-compat-weak-coupling)», избавиться от такого рода проблем мы можем, если перейдём от сильной связности к слабой, например, через генерацию событий вместо вызова методов:
```
class OfferList {
setup() {
this.offerPanel.events.on(
'close',
function () {
this.resetCurrentOffer();
}
)
}
// Ссылка на текущее выбранное предложение
private selectedOffer: Offer;
// Создаёт заказ
private createOrder() {
const order = await api
.createOrder(this.selectedOffer);
// Действия после создания заказа
}
}
```
В данном фрагменте кода налицо полный хаос с уровнями абстракции, и заодно сделано множество неявных предположений:
* единственный способ выбрать предложение — клик по элементу списка;
* единственный способ сделать заказ — клик внутри элемента «панель предложения»;
* заказ не может быть сделан, если предложение не было предварительно выбрано.
Код выглядит более разумно написанным, но никак не уменьшает связность: использовать `OfferList` без `OfferPanel` мы всё ещё не можем.
Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой и четвёртой проблемы (настройка кнопок в панели) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом:
* сделать панель заказа невидимой / перенести её за границы экрана;
* после события `"click"` на кнопке создания заказа дождаться окончания отрисовки невидимой панели и сгенерировать на ней фиктивное событие `"click"`.
Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым?
Первая очевидная проблема заключается в том, что `SearchBox` должен реагировать на низкоуровневые события типа `click`. Согласно рекомендациям, данным нами в главе «[Слабая связность](#back-compat-weak-coupling)», мы должны сделать его контекстом для нижележащих сущностей, а для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из трёх состояний:
1. Пуст (ожидает запроса пользователя и получения списка предложений).
2. Показан список предложений по запросу.
3. Показано конкретное предложение пользователю.
4. Создаётся заказ.
Соответствующие интерфейсы должны быть и предъявлены субкомпонентам: они должны нотифицировать об одном из переходов. Соответственно, `SearchBox` должен ждать не события `click`, а событий типа `selectOffer` и `createOrder`:
Во всех вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: `OfferList` инстанцирует `OfferPanel` и управляет ей напрямую. При этом `OfferPanel` приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам `SearchBox`, например, так:
```
this.offerList.on(
'selectOffer',
(event) => {
this.selectedOffer =
event.offer;
this.offerPanel.show(
this.selectedOffer
class SearchBox() {
constructor() {
this.offerList = new OfferList(…);
this.offerPanel = new OfferPanel(…);
this.offerList.events.on(
'offerSelect', function (offer) {
this.offerPanel.show(offer);
}
);
});
this.offerPanel.on(
'createOrder', () => {
this.createOrder();
this.offerPanel.events.on(
'close', function () {
this.offerList
.resetSelectedOffer()
}
)
}
)
}
```
Возможности по кастомизации субкомпонентов расширились: теперь нет нужды эмулировать `'click'` для выбора предложения, есть семантический способ сделать это через событие `selectOffer`; аналогично, какие события обрабатывает панель предложения для бросания события `createOrder` — больше не забота самого `SearchBox`-а.
Однако описанный выше пример — с заказом свайпом по элементу списка — всё ещё реализуется «костыльно» через открытие невидимой панели, поскольку вызов `offerPanel.show` всё ещё жёстко вшит в сам `SearchBox`. Мы можем сделать ещё один шаг, и сделать связность ещё более слабой: пусть `SearchBox` не вызывает напрямую методы субкомпонентов, а только извещает об изменении собственного состояния:
Теперь `OfferList` и `OfferPanel` стали независимы друг от друга, но мы получили другую проблему: для их замены на альтернативные имплементации нам придётся переписать сам `SearchBox`. Мы можем абстрагироваться ещё дальше, поступив вот так:
```
this.offerList.on(
'selectOffer',
(event) => {
this.selectedOffer =
event.offer;
this.emit('stateChange', {
selectedOffer: this.
selectedOffer
});
});
this.offerPanel.on(
'createOrder', () => {
const order = await api.createOrder();
this.state = 'orderCreated';
this.emit('stateChange', {
order
});
}
)
```
Тем самым мы даём имплементациям компонентов бо́льшую свободу действий. `offerPanel` не обязана «открываться», если такого состояния в ней нет и может просто проигнорировать изменения состояния на `offerSelected`. Наконец, мы могли бы полностью абстрагироваться от нижележащего UI, если сделаем `SearchBox` транслятором несущественного для него события `"selectOffer"`:
```
// Имплементация SearchBox
class SearchBox {
public onMessage(message) {
switch (message.type) {
case 'selectOffer':
this.emit('stateChange', {
selectedOffer: message.offer
class SearchBox() {
constructor() {
this.offerList.events.on(
'offerSelect', function (event) {
this.events.emit('offerSelect', {
offer: event.selectedOffer
});
break;
}
}
);
}
};
// Имплементация OfferList
}
```
То есть заставить `SearchBox` транслировать события, возможно, с преобразованием данных. Мы даже можем заставить `SearchBox` транслировать *любые* события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в `offerPanel` (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы `OfferList` не только генерировал сам событие `offerSelect`, но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл:
```
class OfferList {
public context: SearchBox;
onOfferClick(offer) {
// Компонент-список предложений
// инициирует выбор конкретного
// предложения через нотификацию
// родительского контекста
this.context.onMessage({
type: 'selectOffer',
offer
});
constructor(searchBox, …) {
searchBox.events.on(
'offerSelect',
this.selectOffer
)
}
selectOffer(offer) {
this.events.emit(
'offerSelect', offer
)
}
}
```
Это решение выглядит достаточно общим и в своём роде идеальным (`SearchBox` сведён к своей чистой функциональности — получению списка предложений по запросу пользователя), но при этом является, увы, очень ограниченно применимым:
* он содержит очень мало функциональности, которая реально помогала бы программисту в его работе;
* он включает функциональность трансляции событий, которые ничего не значат для самого `SearchBox` и являются очевидно излишними на этом уровне;
* он заставляет программиста досконально разобраться в механике работы каждого субкомпонента и имплементировать её полностью, если необходима альтернативная реализация.
Или, если сформулировать другими словами, наш `SearchBox` не «перекидывает мостик», не сближает два программных контекста (высокоуровневый `SearchBox` и низкоуровневую имплементацию, скажем, `offerPanel`-а). Пусть, например, разработчик хочет сделать не сложную замену UX, а очень простую вещь: сменить дизайн кнопки «Заказать» на какой-то другой. Проблема заключается в том, что альтернативная кнопка не бросает никаких событий `'createOrder'` — она генерирует самый обычный `'click'`. А значит, разработчику придётся написать эту логику самостоятельно.
```
class MyOfferPanel implements IOfferPanel {
protected parentSearchBox;
render() {
this.button = new CustomButton();
this.button.on('click', () => {
this.parentSearchBox.notify(
'createOrder'
)
});
class SearchBox {
constructor() {
this.offerList.events.on(
'offerSelect', function (offer) {
this.events.emit(
'offerSelect', offer
)
}
)
}
}
```
В нашем примере это не выглядит чем-то сложным (но это только потому, что наш конечный автомат очень прост и содержит очень мало данных), но трудно не согласиться с тем, что необходимость писать подобный код совершенно неоправдана: почти любая альтернативная реализация кнопки генерирует именно событие `'click'`.
Другая очень большая проблема состоит в том, что с подобным «плоским» интерфейсом (любой актор может отправить события `selectOffer` / `createOrder`) мы фактически просто перевернули дырявую изоляцию абстракций с ног на голову: раньше `SearchBox` должен был знать о низкоуровневых объектах и их поведении — теперь низкоуровневые объекты должны знать о логике работы `SearchBox`. Такая перевёрнутая пирамида лучше прямой (нам хотя бы не приходится эмулировать клики на скрытых объектах), но далеко не идеальна с точки зрения архитектуры конкретного приложения. Написанный в этой парадигме код практически невозможно использовать повторно (приведённый выше пример `MyOfferPanel` нельзя использовать для каких-либо других целей, потому что действие по клику на кнопку всегда одно и то же — создание заказа), что приводит к необходимости копипастинга кода со всеми вытекающими проблемами.
Мы можем решить и эту проблему, если искусственным образом «перекинем мостик» — введём дополнительный уровень абстракции (назовём его, скажем, «арбитром»), который позволяет транслировать контексты:
Во избежание таких циклов мы можем разделить события:
```
class Arbiter implements IArbiter {
protected currentSelectedOffer;
constructor(
searchBox: ISearchBox,
offerPanel: IOfferPanel
) {
// Панель показа предложений
// должна быть каким-то образом
// привязана к арбитру
offerPanel.setArbiter(this);
searchBox.on('stateChange', (event) => {
// Арбитр переформулирует события
// `searchBox` в требования к
// панели предложений.
// Если выбрано новое предложение
if (this.currentSelectedOffer !=
event.offer) {
// Запоминаем предложение
this.currentSelectedOffer =
event.offer;
// Даём команду на открытие панели
this.emit('showPanel', {
content: this.generateOfferContent(
this.currentSelectedOffer
)
});
class SearchBox {
constructor() {
// `OfferList` сообщает о низкоуровневых
// событиях, а `SearchBox` — о высокоуровневых
this.offerList.events.on(
'click', function (target) {
this.events.emit(
'offerSelect',
target.dataset.offer
)
}
});
)
}
}
```
// Если же от кнопки создания заказа
// пришло событие 'click'
this.offerPanel.createOrderButton.on(
'click',
() => {
this.searchBox.notify('createOrder');
}
Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать `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();
// 2. Управлении состоянием и оповещении
// суб-компонентов о его изменении
this.searchBox.events.on(
'offerListChange', this.onOfferListChange
);
// 3. Прослушивании событий дочерних
// компонентов и вызове нужных действий
this.offerListComponent.events.on(
'offerSelect', this.selectOffer
);
this.offerPanelComponent.events.on(
'action', this.performAction
);
}
protected generateOfferContent(offer) {
// Формирует контент панели
buildOfferList() {
return new OfferList(
this,
this.offerListContainer,
this.generateOfferListOptions()
);
}
buildOfferPanel() {
return new OfferPanel(
this,
this.offerPanelContainer,
this.generateOfferPanelOptions()
);
}
}
```
Таким образом, мы убрали сильную связность компонентов: можно отдельно переопределить класс кнопки создания заказа (достаточно, чтобы он генерировал событие `'click'`) и даже саму панель целиком. Вся *специфическая* логика, относящаяся к работе панели показа приложений, теперь собрана в арбитре — саму панель можно переиспользовать в других частях приложения.
Мы можем придать `SearchBoxComposer`-у функциональность трансляции любых контекстов. В частности:
Более того, мы можем пойти дальше и сделать два уровня арбитров — между `SearchBox` и панелью предложений и между панелью предложений и кнопкой создания заказа. Тогда у нас пропадёт требование к `IOfferPanel` иметь поле `createOrderButton`, и мы сможем свободно комбинировать разные варианты: альтернативный способ подтверждения заказа (не по кнопке), альтернативная реализация панели с сохранением той же кнопки и т.д.
1. Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что `offerList` показывает краткую информацию о предложений, а `offerPanel` — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных:
```
class SearchBoxComposer {
onContextOfferListChange(offerList) {
// `SearchBox` транслирует событие
// `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':
// Действие «закрытие панели предложения»
// нужно оттранслировать `OfferList`
if (this.currentOffer != null) {
this.currentOffer = null;
this.events.emit(
'offerFullViewToggle', { offer: null }
);
}
break;
}
}
```
Единственной проблемой остаётся потрясающая сложность и неочевидность имплементации такого решения со всеми слоями промежуточных арбитров. Таков путь.
Если теперь мы посмотрим на кейсы, описанные в начале главы, то мы можем наметить стратегию имплементации каждого из них:
1. Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного `OfferList`-а нам нужно переопределить метод `buildOfferList` так, чтобы он создавал наш кастомный компонент с картой.
2. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный `ISearchBoxComposer`. Но мы при этом сможем использовать стандартный `OfferList`, поскольку `Composer` управляет и подготовкой данных для него, и реакцией на действия пользователей.
3. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный `SearchBoxComposer` и `OfferList`), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые `SearchBoxComposer` транслирует с панели предложения.
Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:
* исходный код доступен на [www.github.com/twirl/The-API-Book/docs/examples](https://github.com/twirl/The-API-Book/tree/gh-pages/docs/examples/01.%20Decomposing%20UI%20Components)
* там же предложены несколько задач для самостоятельного изучения;
* песочница с «живыми» примерами достпна на [twirl.github.io/The-API-Book](https://twirl.github.io/The-API-Book/examples/01.%20Decomposing%20UI%20Components/).