mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-06-12 22:17:33 +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:
|
The following improvements to the code are left as an exercise for the reader:
|
||||||
* Make all builder functions configurable through options
|
* 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
|
* Create a separate composer to close the gap between `OfferPanelComponent` and its buttons
|
||||||
* Add returning an operation status from the `SearchBox.search` method:
|
* Add returning an operation status from the `SearchBox.search` method:
|
||||||
```
|
```
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
### [Декомпозиция UI-компонентов][sdk-decomposing]
|
### [Декомпозиция UI-компонентов][sdk-decomposing]
|
||||||
|
|
||||||
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента (внешнего вида, бизнес-логики или поведения) приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента `SearchBox` из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:
|
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента `SearchBox` из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:
|
||||||
* объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;
|
* объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;
|
||||||
* появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);
|
* появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);
|
||||||
* неоднозначность иерархий наследования свойств и опций компонентов.
|
* неоднозначность иерархий наследования свойств и опций компонентов.
|
||||||
@ -11,238 +11,469 @@
|
|||||||
|
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
2. Добавление кнопки быстрого заказа в каждое предложение в списке:
|
2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)
|
||||||
* иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
|
* иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
3. Брендирование предложения и кнопки заказа иконкой сети кофеен:
|
3. Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации:
|
||||||
* иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
|
* кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
|
||||||
|
* кнопка «позвонить» показывается динамически, если номер кофейни известен, и, таким образом, требует возможности определения списка показываемых кнопок динамически, отдельно для каждого конкретного предложения.
|
||||||
|
|
||||||
|
Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов;
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, которые можно будет кастомизировать под конкретную задачу. Например, мы можем реализовать связь между ними следующим образом:
|
Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их OfferList и OfferPanel. В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:
|
||||||
|
|
||||||
```
|
```
|
||||||
class SearchBox {
|
class SearchBox implements ISearchBox {
|
||||||
// Компонент списка предложений
|
// Ответственность `SearchBox`:
|
||||||
public offerList: OfferList;
|
// 1. Создать контейнер для визуального
|
||||||
// Панель отображения
|
// отображения списка предложений,
|
||||||
// выбранного предложения
|
// сгенерировать опции и создать
|
||||||
public offerPanel: OfferPanel;
|
// сам компонент `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() { … }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
// Инициализация
|
Интерфейсы `ISearchBox` / `IOfferPanel` / `IOfferView` также очень просты (контрукторы и деструкторы опущены):
|
||||||
setupComponents() {
|
```
|
||||||
// Подписываемся на клик по
|
interface ISearchBox {
|
||||||
// предложению
|
search(query);
|
||||||
this.offerList.events.on(
|
createOrder(offer);
|
||||||
'click',
|
}
|
||||||
(event) => {
|
interface IOfferList {
|
||||||
this.selectedOffer =
|
setOfferList(offerList);
|
||||||
event.target.offer;
|
}
|
||||||
this.offerPanel.show(
|
interface IOfferPanel {
|
||||||
this.selectedOffer
|
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', () => {
|
class OfferList {
|
||||||
this.createOrder();
|
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` мы всё ещё не можем.
|
||||||
* единственный способ выбрать предложение — клик по элементу списка;
|
|
||||||
* единственный способ сделать заказ — клик внутри элемента «панель предложения»;
|
|
||||||
* заказ не может быть сделан, если предложение не было предварительно выбрано.
|
|
||||||
|
|
||||||
Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой и четвёртой проблемы (настройка кнопок в панели) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом:
|
Во всех вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: `OfferList` инстанцирует `OfferPanel` и управляет ей напрямую. При этом `OfferPanel` приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам `SearchBox`, например, так:
|
||||||
* сделать панель заказа невидимой / перенести её за границы экрана;
|
|
||||||
* после события `"click"` на кнопке создания заказа дождаться окончания отрисовки невидимой панели и сгенерировать на ней фиктивное событие `"click"`.
|
|
||||||
|
|
||||||
Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым?
|
|
||||||
|
|
||||||
Первая очевидная проблема заключается в том, что `SearchBox` должен реагировать на низкоуровневые события типа `click`. Согласно рекомендациям, данным нами в главе «[Слабая связность](#back-compat-weak-coupling)», мы должны сделать его контекстом для нижележащих сущностей, а для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
|
|
||||||
|
|
||||||
Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из трёх состояний:
|
|
||||||
1. Пуст (ожидает запроса пользователя и получения списка предложений).
|
|
||||||
2. Показан список предложений по запросу.
|
|
||||||
3. Показано конкретное предложение пользователю.
|
|
||||||
4. Создаётся заказ.
|
|
||||||
|
|
||||||
Соответствующие интерфейсы должны быть и предъявлены субкомпонентам: они должны нотифицировать об одном из переходов. Соответственно, `SearchBox` должен ждать не события `click`, а событий типа `selectOffer` и `createOrder`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
this.offerList.on(
|
class SearchBox() {
|
||||||
'selectOffer',
|
constructor() {
|
||||||
(event) => {
|
this.offerList = new OfferList(…);
|
||||||
this.selectedOffer =
|
this.offerPanel = new OfferPanel(…);
|
||||||
event.offer;
|
this.offerList.events.on(
|
||||||
this.offerPanel.show(
|
'offerSelect', function (offer) {
|
||||||
this.selectedOffer
|
this.offerPanel.show(offer);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
this.offerPanel.events.on(
|
||||||
this.offerPanel.on(
|
'close', function () {
|
||||||
'createOrder', () => {
|
this.offerList
|
||||||
this.createOrder();
|
.resetSelectedOffer()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Возможности по кастомизации субкомпонентов расширились: теперь нет нужды эмулировать `'click'` для выбора предложения, есть семантический способ сделать это через событие `selectOffer`; аналогично, какие события обрабатывает панель предложения для бросания события `createOrder` — больше не забота самого `SearchBox`-а.
|
Теперь `OfferList` и `OfferPanel` стали независимы друг от друга, но мы получили другую проблему: для их замены на альтернативные имплементации нам придётся переписать сам `SearchBox`. Мы можем абстрагироваться ещё дальше, поступив вот так:
|
||||||
|
|
||||||
Однако описанный выше пример — с заказом свайпом по элементу списка — всё ещё реализуется «костыльно» через открытие невидимой панели, поскольку вызов `offerPanel.show` всё ещё жёстко вшит в сам `SearchBox`. Мы можем сделать ещё один шаг, и сделать связность ещё более слабой: пусть `SearchBox` не вызывает напрямую методы субкомпонентов, а только извещает об изменении собственного состояния:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
this.offerList.on(
|
class SearchBox() {
|
||||||
'selectOffer',
|
constructor() {
|
||||||
(event) => {
|
…
|
||||||
this.selectedOffer =
|
this.offerList.events.on(
|
||||||
event.offer;
|
'offerSelect', function (event) {
|
||||||
this.emit('stateChange', {
|
this.events.emit('offerSelect', {
|
||||||
selectedOffer: this.
|
offer: event.selectedOffer
|
||||||
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
|
|
||||||
});
|
});
|
||||||
break;
|
}
|
||||||
…
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
…
|
}
|
||||||
};
|
```
|
||||||
// Имплементация OfferList
|
|
||||||
|
То есть заставить `SearchBox` транслировать события, возможно, с преобразованием данных. Мы даже можем заставить `SearchBox` транслировать *любые* события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в `offerPanel` (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы `OfferList` не только генерировал сам событие `offerSelect`, но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл:
|
||||||
|
|
||||||
|
```
|
||||||
class OfferList {
|
class OfferList {
|
||||||
public context: SearchBox;
|
constructor(searchBox, …) {
|
||||||
onOfferClick(offer) {
|
…
|
||||||
// Компонент-список предложений
|
searchBox.events.on(
|
||||||
// инициирует выбор конкретного
|
'offerSelect',
|
||||||
// предложения через нотификацию
|
this.selectOffer
|
||||||
// родительского контекста
|
)
|
||||||
this.context.onMessage({
|
}
|
||||||
type: 'selectOffer',
|
|
||||||
offer
|
selectOffer(offer) {
|
||||||
});
|
…
|
||||||
|
this.events.emit(
|
||||||
|
'offerSelect', offer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
…
|
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
Это решение выглядит достаточно общим и в своём роде идеальным (`SearchBox` сведён к своей чистой функциональности — получению списка предложений по запросу пользователя), но при этом является, увы, очень ограниченно применимым:
|
class SearchBox {
|
||||||
* он содержит очень мало функциональности, которая реально помогала бы программисту в его работе;
|
constructor() {
|
||||||
* он включает функциональность трансляции событий, которые ничего не значат для самого `SearchBox` и являются очевидно излишними на этом уровне;
|
…
|
||||||
* он заставляет программиста досконально разобраться в механике работы каждого субкомпонента и имплементировать её полностью, если необходима альтернативная реализация.
|
this.offerList.events.on(
|
||||||
|
'offerSelect', function (offer) {
|
||||||
Или, если сформулировать другими словами, наш `SearchBox` не «перекидывает мостик», не сближает два программных контекста (высокоуровневый `SearchBox` и низкоуровневую имплементацию, скажем, `offerPanel`-а). Пусть, например, разработчик хочет сделать не сложную замену UX, а очень простую вещь: сменить дизайн кнопки «Заказать» на какой-то другой. Проблема заключается в том, что альтернативная кнопка не бросает никаких событий `'createOrder'` — она генерирует самый обычный `'click'`. А значит, разработчику придётся написать эту логику самостоятельно.
|
…
|
||||||
|
this.events.emit(
|
||||||
```
|
'offerSelect', offer
|
||||||
class MyOfferPanel implements IOfferPanel {
|
)
|
||||||
protected parentSearchBox;
|
}
|
||||||
|
)
|
||||||
render() {
|
|
||||||
this.button = new CustomButton();
|
|
||||||
this.button.on('click', () => {
|
|
||||||
this.parentSearchBox.notify(
|
|
||||||
'createOrder'
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
В нашем примере это не выглядит чем-то сложным (но это только потому, что наш конечный автомат очень прост и содержит очень мало данных), но трудно не согласиться с тем, что необходимость писать подобный код совершенно неоправдана: почти любая альтернативная реализация кнопки генерирует именно событие `'click'`.
|
Во избежание таких циклов мы можем разделить события:
|
||||||
|
|
||||||
Другая очень большая проблема состоит в том, что с подобным «плоским» интерфейсом (любой актор может отправить события `selectOffer` / `createOrder`) мы фактически просто перевернули дырявую изоляцию абстракций с ног на голову: раньше `SearchBox` должен был знать о низкоуровневых объектах и их поведении — теперь низкоуровневые объекты должны знать о логике работы `SearchBox`. Такая перевёрнутая пирамида лучше прямой (нам хотя бы не приходится эмулировать клики на скрытых объектах), но далеко не идеальна с точки зрения архитектуры конкретного приложения. Написанный в этой парадигме код практически невозможно использовать повторно (приведённый выше пример `MyOfferPanel` нельзя использовать для каких-либо других целей, потому что действие по клику на кнопку всегда одно и то же — создание заказа), что приводит к необходимости копипастинга кода со всеми вытекающими проблемами.
|
|
||||||
|
|
||||||
Мы можем решить и эту проблему, если искусственным образом «перекинем мостик» — введём дополнительный уровень абстракции (назовём его, скажем, «арбитром»), который позволяет транслировать контексты:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
class Arbiter implements IArbiter {
|
class SearchBox {
|
||||||
protected currentSelectedOffer;
|
constructor() {
|
||||||
|
…
|
||||||
constructor(
|
// `OfferList` сообщает о низкоуровневых
|
||||||
searchBox: ISearchBox,
|
// событиях, а `SearchBox` — о высокоуровневых
|
||||||
offerPanel: IOfferPanel
|
this.offerList.events.on(
|
||||||
) {
|
'click', function (target) {
|
||||||
// Панель показа предложений
|
…
|
||||||
// должна быть каким-то образом
|
this.events.emit(
|
||||||
// привязана к арбитру
|
'offerSelect',
|
||||||
offerPanel.setArbiter(this);
|
target.dataset.offer
|
||||||
|
)
|
||||||
searchBox.on('stateChange', (event) => {
|
|
||||||
// Арбитр переформулирует события
|
|
||||||
// `searchBox` в требования к
|
|
||||||
// панели предложений.
|
|
||||||
|
|
||||||
// Если выбрано новое предложение
|
|
||||||
if (this.currentSelectedOffer !=
|
|
||||||
event.offer) {
|
|
||||||
// Запоминаем предложение
|
|
||||||
this.currentSelectedOffer =
|
|
||||||
event.offer;
|
|
||||||
// Даём команду на открытие панели
|
|
||||||
this.emit('showPanel', {
|
|
||||||
content: this.generateOfferContent(
|
|
||||||
this.currentSelectedOffer
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
// Если же от кнопки создания заказа
|
Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать `click` на инстанции класса `offerList`.
|
||||||
// пришло событие 'click'
|
|
||||||
this.offerPanel.createOrderButton.on(
|
Итого, мы перебрали уже как минимум пять разных вариантов организации декомпозиции UI-компонента в самых различных парадигмах, но так и не получили ни одного приемлемого решения. Вывод, который мы должны сделать, следующий: проблема не в конкретных интерфейсах и не в подходе к решению. В чём же она тогда?
|
||||||
'click',
|
|
||||||
() => {
|
Давайте сформулируем, в чём состоит область ответственности каждого из наших компонентов:
|
||||||
this.searchBox.notify('createOrder');
|
|
||||||
}
|
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