Это глава 44 раздела «SDK и UI-библиотеки» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента SearchBox из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:

  • объединение в одном объекте разнородной функциональности, а именно — бизнес-логики, настройки внешнего вида и поведения компонента;

  • появление разделяемых ресурсов, т.е. состояния объекта, которое могут одновременно читать и модифицировать разные акторы (включая конечного пользователя);

  • неоднозначность иерархий наследования свойств и опций компонентов.

Сделаем задачу более конкретной, и попробуем разработать наш SearchBox так, чтобы он допускал следующие модификации:

  1. Замена списочного представления предложений, например, на представление в виде карты с подсвечиваемыми метками:

    • иллюстрирует проблему полной замены одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы, а также сложности имплементации разделяемого состояния;

    Результаты поиска на карте.
    Результаты поиска на карте.

    Результаты поиска на карте

  2. Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ):

    • иллюстрирует проблему полного удаления одного из субкомпонентов с передачей его бизнес-логики другим частям системы;

    Список результатов поиска с короткими описаниями предложений.
    Список результатов поиска с короткими описаниями предложений.
    Список результатов поиска, в котором некоторые предложения развёрнуты.
    Список результатов поиска, в котором некоторые предложения развёрнуты.
  3. Манипуляция доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым.

    Панель предложения с дополнительными кнопками и иконками.
    Панель предложения с дополнительными кнопками и иконками.

    В этом сценарии мы рассматриваем различные цепочки пропагирования информации и настроек до панели предложения и динамическое построение UI на их основе:

    • часть данных является свойствами реального объекта (логотип, номер телефона), полученными из API поиска предложений;

    • часть данных имеет смысл только в рамках конкретного UI и отражает механику его построения (кнопки «Вперёд» и «Назад»);

    • часть данных (иконки отмены и звонка) связаны с типом кнопки (бизнес-логикой, которую она несёт в себе).

Решение, которое напрашивается в данной ситуации — это выделение двух дополнительных компонентов, отвечающих за представление списка предложений и за панель показа конкретного предложения, назовём их OfferList и OfferPanel.

`SearchBox` и его суб-компоненты.
`SearchBox` и его суб-компоненты.

В случае отсутствия требований кастомизации, псевдокод, имплементирующий взаимодействие всех трёх компонентов, выглядел бы достаточно просто:

class SearchBox implements ISearchBox {
  // Ответственность `SearchBox`:
  // 1. Создать контейнер для визуального
  // отображения списка предложений,
  // сгенерировать опции и создать
  // сам компонент `OfferList`
  constructor(container, options) {
    …
    this.offerList = new OfferList(
      this,
      offerListContainer,
      offerListOptions
    );
  }
  // 2. Выполнять поиск предложений
  // при нажатии пользователем на кнопку поиска
  // и предоставлять аналогичный программный
  // интерфейс для разработчика
  onSearchButtonClick() {
    this.search(this.searchInput.value);
  }
  search(query) {
    …
  }
  // 3. При получении новых результатов поиска
  // оповестить об этом
  onSearchResultsReceived(searchResults) {
    …
    this.offerList.setOfferList(searchResults)
  }
  // 4. Создавать заказы (и выполнять нужные
  // операции над компонентами)
  createOrder(offer) {
    this.offerList.destroy();
    ourCoffeeSdk.createOrder(offer);
    …
  }
  // 5. Самоуничтожаться
  destroy() {
    this.offerList.destroy();
    …
  }
}
class OfferList implements IOfferList {
  // Ответственность OfferList:
  // 1. Создать контейнер для визуального
  // отображения панели предложений,
  // сгенерировать опции и создать
  // сам компонент `OfferPanel`
  constructor(searchBox, container, options) {
    …
    this.offerPanel = new OfferPanel(
      searchBox,
      offerPanelContainer,
      offerPanelOptions
    );
    …
  }
  // 2. Предоставлять метод для изменения
  // списка предложений
  setOfferList(offerList) { … }
  // 3. При выборе предложения, вызывать метод
  // его показа в панели предложений
  onOfferClick(offer) {
    this.offerPanel.show(offer)
  }
  // 4. Самоуничтожаться
  destroy() {
    this.offerPanel.destroy();
    …
  }
}
class OfferPanel implements IOfferPanel {
  constructor(searchBox, container, options) { … }
  // Ответственность панели показа предложения:
  // 1. Собственно, показывать предложение
  show(offer) { 
    this.offer = offer;
    …
  }
  // 2. Создавать заказ по нажатию на кнопку
  // создания заказа
  onCreateOrderButtonClick() {
    this.searchBox.createOrder(this.offer);
  }
  // 3. Закрываться по нажатию на кнопку
  // отмены
  onCancelButtonClick() {
    // …
  }
  // 4. Самоуничтожаться
  destroy() { … }
}

Интерфейсы ISearchBox / IOfferPanel / IOfferView также очень просты (конструкторы и деструкторы опущены):

interface ISearchBox {
  search(query);
  createOrder(offer);
}
interface IOfferList {
  setOfferList(offerList);
}
interface IOfferPanel {
  show(offer);
}

Если бы мы не разрабатывали SDK и у нас не было бы задачи разрешать кастомизацию этих компонентов, подобная реализация была бы стопроцентно уместной. Попробуем, однако, представить, как мы будем решать описанные выше задачи:

  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, а только с IOfferMap.

  2. Полные описания и кнопки действий в самом списке заказов — в этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента OfferList, он всё равно продолжит создавать OfferPanel и открывать его по выбору предложения.

  3. Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы придумаем какой-нибудь простой способ кастомизировать, например, текст кнопки «Сделать заказ», его поддержка всё равно будет ответственностью компонента OfferList:

    let searchBox = new SearchBox(…, {
      offerPanelCreateOrderButtonText:
        'Drink overpriced coffee!'
    });
    class OfferList {
      constructor(…, options) {
      …
        // Вычленять из опций SearchBox
        // настройки панели предложений
        // вынужен конструктор класса 
        // OfferList
        this.offerPanel = new OfferPanel(…, {
          createOrderButtonText: options
            .offerPanelCreateOrderButtonText
          …
        })
      }
    }

Неприятная особенность всех вышеперечисленных решений — их очень плохая расширяемость. Вернёмся к п.1: допустим, мы решили сделать функциональность реакции списка предложений на закрытие панели предложений частью интерфейса, чтобы программист мог ей воспользоваться. Для этого нам придётся объявить новый метод, который в целях обратной совместимости будет необязательным:

interface IOfferList {
  …
  onOfferPanelClose?();
}

и писать в коде OfferPanel что-то типа:

if (Type(this.offerList.onOfferPanelClose)
  == 'function') {
    this.offerList.onOfferPanelClose();
  }

Что, во-первых, совершенно не красит наш код и, во-вторых, делает связность OfferPanel и OfferList ещё более сильной.

Как мы описывали ранее в главе «Слабая связность», избавиться от такого рода проблем мы можем, если перейдём от сильной связности к слабой, например, через генерацию событий вместо вызова методов. Компонент IOfferPanel мог бы бросать событие 'close', и тогда OfferList мог бы на него подписаться:

class OfferList {
  setup() {
    …
    this.offerPanel.events.on(
      'close',
      function () {
        this.resetCurrentOffer();
      }
    )
  }
  …
}

Код выглядит более разумно написанным, но никак не уменьшает взаимозавимость компонентов: использовать OfferList без OfferPanel, как этого требует сценарий #2, мы всё ещё не можем.

Заметим, что в вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: OfferList инстанцирует OfferPanel и управляет ей напрямую. При этом OfferPanel приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам SearchBox, например, так:

class SearchBox() {
  constructor() {
    this.offerList = new OfferList(…);
    this.offerPanel = new OfferPanel(…);
    this.offerList.events.on(
      'offerSelect', function (offer) {
        this.offerPanel.show(offer);
      }
    );
    this.offerPanel.events.on(
      'close', function () {
        this.offerList
          .resetSelectedOffer();
      }
    );
  }
}

Теперь OfferList и OfferPanel стали независимы друг от друга, но мы получили другую проблему: для их замены на альтернативные имплементации нам придётся переписать сам SearchBox. Мы можем абстрагироваться ещё дальше, поступив вот так:

class SearchBox {
  constructor() {
    …
    this.offerList.events.on(
      'offerSelect', function (event) {
        this.events.emit('offerSelect', {
          offer: event.selectedOffer
        });
      }
    );
  }
  …
}

То есть заставить SearchBox транслировать события, возможно, с преобразованием данных. Мы даже можем заставить SearchBox транслировать любые события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в offerPanel (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы OfferList не только генерировал сам событие 'offerSelect', но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл:

class OfferList {
  constructor(searchBox, …) {
    …
    searchBox.events.on(
      'offerSelect',
      this.selectOffer
    );
  }
  selectOffer(offer) {
    …
    this.events.emit(
      'offerSelect', offer
    );
  }
}
class SearchBox {
  constructor() {
    …
    this.offerList.events.on(
      'offerSelect', function (offer) {
        …
        this.events.emit(
          'offerSelect', offer
        );
      }
    );
  }
}

Во избежание таких циклов мы можем разделить события:

class SearchBox {
  constructor() {
    …
    // `OfferList` сообщает о низкоуровневых
    // событиях, а `SearchBox` — о высокоуровневых
    this.offerList.events.on(
      'click', function (target) {
        …
        this.events.emit(
          'offerSelect',
          target.dataset.offer
        )
      }
    )
  }
}

Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать 'click' на инстанции класса OfferList.

Итого, мы перебрали уже как минимум пять разных вариантов организации декомпозиции UI-компонента в самых различных парадигмах, но так и не получили ни одного приемлемого решения. Вывод, который мы должны сделать, следующий: проблема не в конкретных интерфейсах и не в подходе к решению. В чём же она тогда?

Давайте сформулируем, в чём состоит область ответственности каждого из наших компонентов:

  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();
    // 3. Управлении состоянием и оповещении
    // суб-компонентов о его изменении
    this.searchBox.events.on(
      'offerListChange', this.onOfferListChange
    );
    // 4. Прослушивании событий дочерних
    // компонентов и вызове нужных действий
    this.offerListComponent.events.on(
      'offerSelect', this.selectOffer
    );
    this.offerPanelComponent.events.on(
      'action', this.performAction
    );
  }
  …
}

Здесь методы-строители подчинённых компонентов, позволяющие переопределять опции компонентов (и, потенциально, их расположение на экране) выглядят как:

class SearchBoxComposer {
  …
  buildOfferList() {
    return new OfferList(
      this,
      this.offerListContainer,
      this.generateOfferListOptions()
    );
  }
  buildOfferPanel() {
    return new OfferPanel(
      this,
      this.offerPanelContainer,
      this.generateOfferPanelOptions()
    );
  }
}

Мы можем придать SearchBoxComposer-у функциональность трансляции любых контекстов. В частности:

  1. Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что OfferList показывает краткую информацию о предложений, а OfferPanel — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных:

    class SearchBoxComposer {
      …
      onContextOfferListChange(offerList) {
        …
        // `SearchBoxComposer` транслирует событие
        // `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':
            // Действие «закрытие панели предложения»
            // нужно распространить для всех
            if (this.currentOffer != null) {
              this.currentOffer = null;
              this.events.emit(
                'offerFullViewToggle', { offer: null }
              );
            }
            break;
          …
        }
      }
    }

Если теперь мы посмотрим на кейсы, описанные в начале главы, то мы можем наметить стратегию имплементации каждого из них:

  1. Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного IOfferList-а нам нужно переопределить метод buildOfferList так, чтобы он создавал наш кастомный компонент с картой.

  2. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный ISearchBoxComposer. Но мы при этом сможем использовать стандартный OfferList, поскольку Composer управляет и подготовкой данных для него, и реакцией на действия пользователей.

  3. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный SearchBoxComposer и OfferList), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые SearchBoxComposer транслирует с панели предложения.

Ценой этой гибкости является чрезвычайное усложнение взаимодействия. Все события и потоки данных должны проходить через цепочку таких Composer-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.

Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:

Комментарии (0)