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:
parent
131b99e5df
commit
e3ca790b8f
@ -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:
|
||||
```
|
||||
|
@ -1,6 +1,6 @@
|
||||
### [Декомпозиция UI-компонентов][sdk-decomposing]
|
||||
|
||||
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента (внешнего вида, бизнес-логики или поведения) приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента `SearchBox` из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:
|
||||
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента `SearchBox` из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:
|
||||
* объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;
|
||||
* появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);
|
||||
* неоднозначность иерархий наследования свойств и опций компонентов.
|
||||
@ -11,238 +11,469 @@
|
||||
|
||||
[]()
|
||||
|
||||
2. Добавление кнопки быстрого заказа в каждое предложение в списке:
|
||||
2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)
|
||||
* иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
|
||||
|
||||
[]()
|
||||
|
||||
3. Брендирование предложения и кнопки заказа иконкой сети кофеен:
|
||||
* иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
|
||||
3. Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации:
|
||||
* кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
|
||||
* кнопка «позвонить» показывается динамически, если номер кофейни известен, и, таким образом, требует возможности определения списка показываемых кнопок динамически, отдельно для каждого конкретного предложения.
|
||||
|
||||
Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов;
|
||||
|
||||
[]()
|
||||
|
||||
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, которые можно будет кастомизировать под конкретную задачу. Например, мы можем реализовать связь между ними следующим образом:
|
||||
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их 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/).
|
Loading…
x
Reference in New Issue
Block a user