diff --git a/docs/examples/01. Decomposing UI Components/README.md b/docs/examples/01. Decomposing UI Components/README.md index 3cf7837..0170ba4 100644 --- a/docs/examples/01. Decomposing UI Components/README.md +++ b/docs/examples/01. Decomposing UI Components/README.md @@ -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: ``` diff --git a/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md b/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md index 458e615..36ae773 100644 --- a/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md +++ b/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md @@ -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/). \ No newline at end of file