diff --git a/docs/API.en.epub b/docs/API.en.epub index 828269f..748d86a 100644 Binary files a/docs/API.en.epub and b/docs/API.en.epub differ diff --git a/docs/API.en.html b/docs/API.en.html index 501106f..7d4f3b3 100644 --- a/docs/API.en.html +++ b/docs/API.en.html @@ -5848,31 +5848,31 @@ api.subscribe(

This functionality would appear more maintainable if no such customization opportunity was provided at all. Developers will be unhappy as they would need to implement their own search control from scratch just to replace an icon, but this implementation would be at least logical with icons defined somewhere in the rendering function.

NB: There are many other possibilities to allow developers to customize a button nested deeply within a component, such as exposing dependency injection or sub-component class factories, giving direct access to a rendered view, allowing to provide custom button layouts, etc. All of them are inherently subject to the same problem: it is a very complicated task to consistently define the order and the priority of injections / rendering callbacks / custom layouts.

Consistently solving all the problems listed above is unfortunately a very complex task. In the following chapters, we will discuss design patterns that allow for splitting responsibility areas between the component's sub-entities. However, it is important to understand one thing: full separation of concerns, meaning developing a functional SDK+UI that allows developers to independently overwrite the look, business logic, and UX of the components, is extremely expensive. In the best-case scenario, the nomenclature of entities will be tripled. So the universal advice is: think thrice before exposing the functionality of customizing UI components. Though the price of design mistakes in UI library APIs is typically not very high (customers rarely request a refund if button press animation is broken), a badly structured, unreadable and buggy SDK could hardly be viewed as a competitive advantage of your API.

Chapter 44. Decomposing UI Components 

-

Let's transit to a more substantive conversation and try to understand, why the requirement to allow replacing component's subsystems with alternative implementations leads to dramatic interface inflation. We continue studying the SearchBox component from the previous chapter. Let us remind the reader the factors that complicate designing APIs for visual components:

+

Let's transition to a more substantive conversation and try to understand why the requirement to allow the replacement of a component's subsystems with alternative implementations leads to a dramatic increase in interface complexity. We will continue studying the SearchBox component from the previous chapter. Allow us to remind the reader of the factors that complicate the design of APIs for visual components:

-

We will make a task more specific. Imagine we need to develop a SearchBox that allows for the following modifications:

+

Let's make the task more specific. Imagine that we need to develop a SearchBox that allows for the following modifications:

  1. Replacing the textual paragraphs representing an offer with a map with markers that could be highlighted:

    -
    Search results on a map.
    Search results on a map
    +
  2. Combining short and full descriptions of an offer in a single UI (a list item could be expanded, and the order can be created in-place):

    -
    A list of offers with short descriptions.
    A list of offers with short descriptions
    A list of offers with some of them expanded.
    A list of offers with some of them expanded
    +
  3. Manipulating the data presented to the user and the available actions for an offer through adding new buttons, such as “Previous offer,” “Next offer,” and “Make a call.”

    @@ -5881,7 +5881,7 @@ api.subscribe(

    In this scenario, we're evaluating different chains of propagating data and options down to the offer panel and building dynamic UIs on top of it:

-

The obvious approach to tackle these scenarios appears to be creating two additional subcomponents responsible for presenting a list of offers and the details of the specific offer. Let's name them OfferList and OfferPanel respectively.

+

The obvious approach to tackling these scenarios appears to be creating two additional subcomponents responsible for presenting a list of offers and the details of the specific offer. Let's name them OfferList and OfferPanel respectively.

The subcomponents of a `SearchBox`.
The subcomponents of a `SearchBox`

If we had no customization requirements, the pseudo-code implementing interactions between all three components would look rather trivial:

class SearchBox implements ISearchBox {
   // The responsibility of `SearchBox` is:
   // 1. Creating a container for rendering
-  // an offer list, prepare option values
-  // and create the `OfferList` instance
+  // an offer list, preparing option values
+  // and creating the `OfferList` instance
   constructor(container, options) {
     …
     this.offerList = new OfferList(
@@ -5909,9 +5909,9 @@ api.subscribe(
       offerListOptions
     );
   }
-  // 2. Making an offer search when a user
-  // presses the corresponding button and
-  // to provide analogous programmable
+  // 2. Triggering an offer search when 
+  // a user presses the corresponding button
+  // and providing an analogous programmable
   // interface for developers
   onSearchButtonClick() {
     this.search(this.searchInput.value);
@@ -5925,14 +5925,14 @@ api.subscribe(
     …
     this.offerList.setOfferList(searchResults)
   }
-  // 4. Creating orders (and manipulate sub-
-  // components if needed)
+  // 4. Creating orders (and manipulating 
+  // subcomponents if needed)
   createOrder(offer) {
-    this.offerListDestroy();
+    this.offerList.destroy();
     ourCoffeeSdk.createOrder(offer);
     …
   }
-  // 5. Self-destructing when requested to
+  // 5. Self-destructing if requested
   destroy() {
     this.offerList.destroy();
     …
@@ -5942,8 +5942,8 @@ api.subscribe(
 
class OfferList implements IOfferList {
   // The responsibility of `OfferList` is:
   // 1. Creating a container for rendering
-  // an offer panel, prepare option values
-  // and create the `OfferPanel` instance
+  // an offer panel, preparing option values
+  // and creating the `OfferPanel` instance
   constructor(searchBox, container, options) {
     …
     this.offerPanel = new OfferPanel(
@@ -5961,7 +5961,7 @@ api.subscribe(
   onOfferClick(offer) {
     this.offerPanel.show(offer)
   }
-  // 4. Self-destructing if requested to
+  // 4. Self-destructing if requested
   destroy() {
     this.offerPanel.destroy();
     …
@@ -5988,7 +5988,7 @@ api.subscribe(
   onCancelButtonClick() {
     // …
   }
-  // 4. Self-destructing if requested to
+  // 4. Self-destructing if requested
   destroy() { … }
 }
 
@@ -6004,10 +6004,10 @@ api.subscribe( show(offer); }
-

If we aren't making an SDK and have not had the task of making these components customizable, the approach would be perfectly viable. However, let's discuss how would we solve the three sample tasks described above.

+

If we aren't making an SDK and have not had the task of making these components customizable, the approach would be perfectly viable. However, let's discuss how we would solve the three sample tasks described above.

  1. -

    Displaying an offer list on the map: at first glance, we can develop an alternative component for displaying offers that implement the IOfferList interface (let's call it OfferMap) that will reuse the standard offer panel. However, we have a problem: OfferList only sends commands to OfferPanel while OfferMap also needs to receive feedback: an event of panel closure to deselect a marker. API of our components does not encompass this functionality, and implementing it is not that simple:

    +

    Displaying an offer list on the map: at first glance, we can develop an alternative component for displaying offers that implements the IOfferList interface (let's call it OfferMap) and reuses the standard offer panel. However, we have a problem: OfferList only sends commands to OfferPanel while OfferMap also needs to receive feedback — an event of panel closure to deselect a marker. The API of our components does not encompass this functionality, and implementing it is not that simple:

    class CustomOfferPanel extends OfferPanel {
       constructor(
         searchBox, offerMap, container, options
    @@ -6037,10 +6037,10 @@ api.subscribe(
     

    We have to create a CustomOfferPanel class, and this implementation, unlike its parent class, now only works with OfferMap, not with any IOfferList-compatible component.

  2. -

    The case of making full offer details and action controls in place in the offer list is pretty obvious: we can achieve this only by writing a new IOfferList-compatible component from scratch as whatever overrides we apply to standard OfferList, it will continue creating an OfferPanel and open it upon offer selection.

    +

    The case of making full offer details and action controls in place in the offer list is pretty obvious: we can achieve this only by writing a new IOfferList-compatible component from scratch because whatever overrides we apply to the standard OfferList, it will continue creating an OfferPanel and open it upon offer selection.

  3. -

    To implement new buttons, we can only propose developers creating a custom offer list component (to provide methods for selecting previous and next offers) and a custom offer panel that will call these methods. If we find a simple solution for customizing, let's say, the “Place an order” button text, this solution needs to be supported in the OfferList code:

    +

    To implement new buttons, we can only propose to developers to create a custom offer list component (to provide methods for selecting previous and next offers) and a custom offer panel that will call these methods. If we find a simple solution for customizing, let's say, the “Place an order” button text, this solution needs to be supported in the OfferList code:

    const searchBox = new SearchBox(…, {
       offerPanelCreateOrderButtonText:
         'Drink overpriced coffee!'
    @@ -6051,7 +6051,7 @@ api.subscribe(
         …
         // It is `OfferList`'s responsibility
         // to isolate the injection point and
    -    // to propagate the overriden value
    +    // to propagate the overridden value
         // to the `OfferPanel` instance
         this.offerPanel = new OfferPanel(…, {
           createOrderButtonText: options
    @@ -6063,7 +6063,7 @@ api.subscribe(
     
-

The solutions we discuss are also poorly extendable. For example, in #1, if we decide to make the offer list reaction to closing an offer panel a part of a standard interface for developers to use it, we will need to add a new method to the IOfferList interface and make it optional to maintain backward compatibility:

+

The solutions we discuss are also poorly extendable. For example, in #1, if we decide to make the offer list react to the closing of an offer panel as a part of the standard interface for developers to use, we will need to add a new method to the IOfferList interface and make it optional to maintain backward compatibility:

interface IOfferList {
   …
   onOfferPanelClose?();
@@ -6075,7 +6075,273 @@ api.subscribe(
     this.offerList.onOfferPanelClose();
   }
 
-

For sure, this will not make our code any nicer. Additionally, OfferList and OfferPanel will become even more tightly coupled.

Chapter 45. The MV* Frameworks

Chapter 46. The Backend-Driven UI

Chapter 47. Shared Resources and Asynchronous Locks

Chapter 48. Computed Properties

Chapter 49. Conclusion

Section VI. The API Product

Chapter 50. The API as a Product 

+

Certainly, this will not make our code any cleaner. Additionally, OfferList and OfferPanel will become even more tightly coupled.

+

As we discussed in the “Weak Coupling” chapter, to solve such problems we need to reduce the strong coupling of the components in favor of weak coupling, for example, by generating events instead of calling methods directly. An IOfferPanel could have emitted a 'close' event, so that an OfferList could have listened to it:

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

This code looks more sensible but doesn't eliminate the mutual dependencies of the components: an OfferList still cannot be used without an OfferPanel as required in Case #2.

+

Let us note that all the code samples above are a full chaos of abstraction levels: an OfferList instantiates an OfferPanel and manages it directly, and an OfferPanel has to jump over levels to create an order. We can try to unlink them if we route all calls through the SearchBox itself, for example, like this:

+
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();
+      }
+    );
+  }
+}
+
+

Now OfferList and OfferPanel are independent, but we have another issue: to replace them with alternative implementations we have to change the SearchBox itself. We can go even further and make it like this:

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

So a SearchBox just translates events, maybe with some data alterations. We can even force the SearchBox to transmit any events of child components, which will allow us to extend the functionality by adding new events. However, this is definitely not the responsibility of a high-level component, being mostly a proxy for translating events. Also, using these event chains is error prone. For example, how should the functionality of selecting a next offer in the offer panel (Case #3) be implemented? We need an OfferList to both generate an 'offerSelect' event and react when the parent context emits it. One can easily create an infinite loop of it:

+
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
+        );
+      }
+    );
+  }
+}
+
+

To avoid infinite loops, we could split the events:

+
class SearchBox {
+  constructor() {
+    …
+    // An `OfferList` notifies about 
+    // low-level events, while a `SearchBox`,
+    // about high-level ones
+    this.offerList.events.on(
+      'click', function (target) {
+        …
+        this.events.emit(
+          'offerSelect',
+          target.dataset.offer
+        );
+      }
+    );
+  }
+}
+
+

Then the code will become ultimately unmaintainable: to open an OfferPanel, developers will need to generate a 'click' event on an OfferList instance.

+

In the end, we have already examined five different options for decomposing a UI component employing very different approaches, but found no acceptable solution. Obviously, we can conclude that the problem is not about specific interfaces. What is it about, then?

+

Let us formulate what the responsibility of each of the components is:

+
    +
  1. +

    SearchBox presents the general interface. It is an entry point both for users and developers. If we ask ourselves what a maximum abstract component still constitutes a SearchBox, the response will obviously be “the one that allows for entering a search phrase and presenting the results in the UI with the ability to place an order.”

    +
  2. +
  3. +

    OfferList serves the purpose of showing offers to users. The user can interact with a list — iterate over offers and “activate” them (i.e., perform some actions on a list item).

    +
  4. +
  5. +

    OfferPanel displays a specific offer and renders all the information that is meaningful for the user. There is always exactly one OfferPanel. The user can work with the panel, performing actions related to this specific offer (including placing an order).

    +
  6. +
+

Does the SearchBox description entail the necessity of OfferList's existence? Obviously, not: we can imagine quite different variants of UI for presenting offers to the users. An OfferList is a specific case of organizing the SearchBox's functionality for presenting search results. Conversely, the idea of “selecting an offer” and the concepts of OfferList and OfferPanel performing different actions and having different options are equally inconsequential to the SearchBox definition. At the SearchBox level, it doesn't matter how the search results are presented and what states the corresponding UI could have.

+

This leads to a simple conclusion: we cannot decompose SearchBox just because we lack a sufficient number of abstraction levels and try to jump over them. We need a “bridge” between an abstract SearchBox that does not depend on specific UI and the OfferList / OfferPanel components that present a specific case of such a UI. Let us artificially introduce an additional abstraction level (let us call it a “Composer”) to control the data flow:

+
class SearchBoxComposer 
+  implements ISearchBoxComposer {
+  // The responsibility of a “Composer” comprises:
+  // 1. Creating a context for nested subcomponents
+  constructor(searchBox, container, options) {
+    …
+    // The context consists of the list of offers 
+    // and the current selected offer
+    // (both could be empty)
+    this.offerList = null;
+    this.currentOffer = null;
+    // 2. Creating subcomponents and translating
+    // their options
+    this.offerList = this.buildOfferList();
+    this.offerPanel = this.buildOfferPanel();
+    // 3. Managing own state and notifying
+    // about state changes
+    this.searchBox.events.on(
+      'offerListChange', this.onOfferListChange
+    );
+    // 4. Listening
+    this.offerListComponent.events.on(
+      'offerSelect', this.selectOffer
+    );
+    this.offerPanelComponent.events.on(
+        'action', this.performAction
+    );
+  }
+}
+
+

The builder methods to create subcomponents, manage their options and potentially their position on the screen would look like this:

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

We can put the burden of translating contexts on SearchBoxComposer. In particular, the following tasks could be handled by the composer:

+
    +
  1. +

    Preparing and translating the data. At this level we can stipulate that an OfferList shows short information (a “preview”) about the offer, while an OfferPanel presents full information, and provide potentially overridable methods of generating the required data facets:

    +
    class SearchBoxComposer {
    +  …
    +  onContextOfferListChange(offerList) {
    +    …
    +    // A `SearchBoxComposer` translates
    +    // an `offerListChange` event as 
    +    // an `offerPreviewListChange` for the
    +    // `OfferList` subcomponent, thus preventing
    +    // an infinite loop in the code, and prepares
    +    // the data
    +    this.events.emit('offerPreviewListChange', {
    +      offerList: this.generateOfferPreviews(
    +        this.offerList,
    +        this.contextOptions
    +      )
    +    });
    +  }
    +}
    +
    +
  2. +
  3. +

    Managing the composer's own state (the currentOffer field in our case):

    +
    class SearchBoxComposer {
    +  …
    +  onContextOfferListChange(offerList) {
    +    // If an offer is shown when the user
    +    // enters a new search phrase, 
    +    // it should be hidden
    +    if (this.currentOffer !== null) {
    +      this.currentOffer = null;
    +      // This is an event specifically
    +      // for the `OfferPanel` to listen to
    +      this.events.emit(
    +        'offerFullViewToggle', 
    +        { offer: null }
    +      );
    +    }
    +    …
    +  }
    +}
    +
    +
  4. +
  5. +

    Transforming user's actions on a subcomponent into events or actions on the other components or the parent context:

    +
    class SearchBoxComposer {
    +  …
    +  public performAction({
    +    action, offerId
    +  }) {
    +    switch (action) {
    +      case 'createOrder':
    +        // The “place an order” action is
    +        // to be handled by the `SearchBox`
    +        this.createOrder(offerId);
    +        break;
    +      case 'close':
    +        // The closing of the offer panel 
    +        // event is to be exposed publicly
    +        if (this.currentOffer != null) {
    +          this.currentOffer = null;
    +          this.events.emit(
    +            'offerFullViewToggle', 
    +            { offer: null }
    +          );
    +        }
    +        break;
    +      …
    +    }
    +  }
    +}
    +
    +
  6. +
+

If we revisit the cases we began this chapter with, we can now outline solutions for each of them:

+
    +
  1. +

    Presenting search results on a map doesn't change the concept of the list-and-panel UI. We need to implement a custom IOfferList and override the buildOfferList method in the composer.

    +
  2. +
  3. +

    Combining the list and the panel functionality contradicts the UI concept, so we will need to create a custom ISearchBoxComposer. However, we can reuse the standard OfferList as the composer manages both the data for it and the reactions to the user's actions.

    +
  4. +
  5. +

    Enriching the data is compatible with the UI concept, so we continue using standard components. What we need is overriding the functionality of preparing OfferPanel's data and options, and implementing additional events and actions for the composer to translate.

    +
  6. +
+

The price of this flexibility is the overwhelming complexity of component communications. Each event and data field must be propagated through the chains of such “composers” that elongate the abstraction hierarchy. Every transformation in this chain (for example, generating options for subcomponents or reacting to context events) is to be implemented in an extendable and parametrizable way. We can only offer reasonable helpers to ease using such customization. However, in the SDK code, the complexity will always be present. This is the way.

+

The reference implementation of all the components with the interfaces we discussed and all three customization cases can be found in this book's repository:

+

Chapter 45. The MV* Frameworks

Chapter 46. The Backend-Driven UI

Chapter 47. Shared Resources and Asynchronous Locks

Chapter 48. Computed Properties

Chapter 49. Conclusion

Section VI. The API Product

Chapter 50. The API as a Product 

There are two important statements regarding APIs viewed as products.

  1. diff --git a/docs/API.en.pdf b/docs/API.en.pdf index 7ba5f46..c4f6c2d 100644 Binary files a/docs/API.en.pdf and b/docs/API.en.pdf differ diff --git a/docs/API.ru.epub b/docs/API.ru.epub index c5a3dc6..2c49640 100644 Binary files a/docs/API.ru.epub and b/docs/API.ru.epub differ diff --git a/docs/API.ru.html b/docs/API.ru.html index 8b4fed2..0556ffa 100644 --- a/docs/API.ru.html +++ b/docs/API.ru.html @@ -5940,7 +5940,7 @@ api.subscribe( // 4. Создавать заказы (и выполнять нужные // операции над компонентами) createOrder(offer) { - this.offerListDestroy(); + this.offerList.destroy(); ourCoffeeSdk.createOrder(offer); … } @@ -6091,8 +6091,8 @@ api.subscribe(
    class OfferList {
       setup() {
         …
    -    this.offerPanel.events.on(
    -      'close',
    +    this.offerPanel.events.on(
    +      'close',
           function () {
             this.resetCurrentOffer();
           }
    @@ -6101,8 +6101,8 @@ api.subscribe(
       …
     }
     
    -

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

    -

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

    +

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

    +

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

    class SearchBox() {
       constructor() {
         this.offerList = new OfferList(…);
    @@ -6115,14 +6115,14 @@ api.subscribe(
         this.offerPanel.events.on(
           'close', function () {
             this.offerList
    -          .resetSelectedOffer()
    +          .resetSelectedOffer();
           }
    -    )
    +    );
       }
     }
     

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

    -
    class SearchBox() {
    +
    class SearchBox {
       constructor() {
         …
         this.offerList.events.on(
    @@ -6133,27 +6133,28 @@ api.subscribe(
           }
         );
       }
    +  …
     }
     
    -

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

    +

    То есть заставить 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 {
    +
    +
    class SearchBox {
       constructor() {
         …
         this.offerList.events.on(
    @@ -6161,9 +6162,9 @@ api.subscribe(
             …
             this.events.emit(
               'offerSelect', offer
    -        )
    +        );
           }
    -    )
    +    );
       }
     }
     
    @@ -6185,7 +6186,7 @@ api.subscribe( } }
    -

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

    +

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

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

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

      @@ -6200,9 +6201,10 @@ api.subscribe(

    Следует ли из определения SearchBox необходимость наличия суб-компонента OfferList? Никоим образом: мы можем придумать самые разные способы показа пользователю предложений. OfferListчастный случай, каким образом мы могли бы организовать работу SearchBox-а по предоставлению UI к результатами поиска.

    -

    Следует ли из определения SearchBox и OfferList необходимость наличия суб-компонента OfferPanel? Вновь нет: даже сама концепция существования какой-то краткой и полной информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что OfferList и OfferPanel выполняют разные действия и имеют разные настройки. На уровне SearchBox вообще не важно, как результаты поисква представлены пользователю и в каких состояниях может находиться соответствующий UI.

    -

    Всё это приводит нас к простому выводу: мы не можем декомпозировать SearchBox просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между SearchBox, который не зависит от конкретной имплементации UI работы с предложениями и OfferList/OfferPanel, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных.

    -
    class SearchBoxComposer implements ISearchBoxComposer {
    +

    Следует ли из определения SearchBox и OfferList необходимость наличия суб-компонента OfferPanel? Вновь нет: даже сама концепция существования какой-то краткой и полной информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что OfferList и OfferPanel выполняют разные действия и имеют разные настройки. На уровне SearchBox вообще не важно, как результаты поиска представлены пользователю и в каких состояниях может находиться соответствующий UI.

    +

    Всё это приводит нас к простому выводу: мы не можем декомпозировать SearchBox просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между SearchBox, который не зависит от конкретной имплементации UI работы с предложениями и OfferList/OfferPanel, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных:

    +
    class SearchBoxComposer 
    +  implements ISearchBoxComposer {
       // Ответственность `composer`-а состоит в:
       // 1. Создании собственного контекста
       // для дочерних компонентов
    @@ -6217,12 +6219,12 @@ api.subscribe(
         // и трансляции опций для них
         this.offerList = this.buildOfferList();
         this.offerPanel = this.buildOfferPanel();
    -    // 2. Управлении состоянием и оповещении
    +    // 3. Управлении состоянием и оповещении
         // суб-компонентов о его изменении
         this.searchBox.events.on(
           'offerListChange', this.onOfferListChange
         );
    -    // 3. Прослушивании событий дочерних
    +    // 4. Прослушивании событий дочерних
         // компонентов и вызове нужных действий
         this.offerListComponent.events.on(
           'offerSelect', this.selectOffer
    @@ -6231,6 +6233,12 @@ api.subscribe(
             'action', this.performAction
         );
       }
    +  …
    +}
    +
    +

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

    +
    class SearchBoxComposer {
    +  …
     
       buildOfferList() {
         return new OfferList(
    @@ -6252,12 +6260,12 @@ api.subscribe(
     

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

    1. -

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

      +

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

      class SearchBoxComposer {
         …
         onContextOfferListChange(offerList) {
           …
      -    // `SearchBox` транслирует событие
      +    // `SearchBoxComposer` транслирует событие
           // `offerListChange` как `offerPreviewListChange`
           // специально для компонента `OfferList`,
           // таким образом, исключая возможность 
      @@ -6309,7 +6317,7 @@ api.subscribe(
               break;
             case 'close':
               // Действие «закрытие панели предложения»
      -        // нужно оттранслировать `OfferList`-у
      +        // нужно распространить для всех
               if (this.currentOffer != null) {
                 this.currentOffer = null;
                 this.events.emit(
      @@ -6320,16 +6328,23 @@ api.subscribe(
             …
           }
         }
      +}
       

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

      -
    1. Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного OfferList-а нам нужно переопределить метод buildOfferList так, чтобы он создавал наш кастомный компонент с картой.
    2. -
    3. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный ISearchBoxComposer. Но мы при этом сможем использовать стандартный OfferList, поскольку Composer управляет и подготовкой данных для него, и реакцией на действия пользователей.
    4. -
    5. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный SearchBoxComposer и OfferList), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые SearchBoxComposer транслирует с панели предложения.
    6. +
    7. +

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

      +
    8. +
    9. +

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

      +
    10. +
    11. +

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

      +
    -

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

    +

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

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

    • исходный код доступен на www.github.com/twirl/The-API-Book/docs/examples diff --git a/docs/API.ru.pdf b/docs/API.ru.pdf index 33b45d5..f170603 100644 Binary files a/docs/API.ru.pdf and b/docs/API.ru.pdf differ diff --git a/src/en/clean-copy/06-[Work in Progress] Section V. SDKs & UI Libraries/04.md b/src/en/clean-copy/06-[Work in Progress] Section V. SDKs & UI Libraries/04.md index a5e95a5..b2954cb 100644 --- a/src/en/clean-copy/06-[Work in Progress] Section V. SDKs & UI Libraries/04.md +++ b/src/en/clean-copy/06-[Work in Progress] Section V. SDKs & UI Libraries/04.md @@ -1,36 +1,38 @@ ### [Decomposing UI Components][sdk-decomposing] -Let's transit to a more substantive conversation and try to understand, why the requirement to allow replacing component's subsystems with alternative implementations leads to dramatic interface inflation. We continue studying the `SearchBox` component from the previous chapter. Let us remind the reader the factors that complicate designing APIs for visual components: - * Coupling heterogeneous functionality (such as business logic, appearance styling, and behavior) in one entity - * Introducing shared resources, i.e. an object state that could be simultaneously modified by different actors (including the end-user) - * Emerging of ambivalent hierarchies of inheritance of entity's properties and options. +Let's transition to a more substantive conversation and try to understand why the requirement to allow the replacement of a component's subsystems with alternative implementations leads to a dramatic increase in interface complexity. We will continue studying the `SearchBox` component from the previous chapter. Allow us to remind the reader of the factors that complicate the design of APIs for visual components: + * Coupling heterogeneous functionality (such as business logic, appearance styling, and behavior) into a single entity + * Introducing shared resources, i.e. an object state that could be simultaneously modified by different actors, including the end user + * The emergence of ambivalent hierarchies in the inheritance of entity properties and options. -We will make a task more specific. Imagine we need to develop a `SearchBox` that allows for the following modifications: +Let's make the task more specific. Imagine that we need to develop a `SearchBox` that allows for the following modifications: 1. Replacing the textual paragraphs representing an offer with a map with markers that could be highlighted: - * Illustrates the problem of replacing a subcomponent (the offer list) while preserving behavior and design of other parts of the system; also, the complexity of implementing shared states. [![APP](/img/mockups/05.png "Search results on a map")]() + * This illustrates the problem of replacing a subcomponent (the offer list) while preserving the behavior and design of other parts of the system as well as the complexity of implementing shared states. + 2. Combining short and full descriptions of an offer in a single UI (a list item could be expanded, and the order can be created in-place): - * Illustrates the problem of fully removing a subcomponent and transferring its business logic to other parts of the system. [![APP](/img/mockups/06.png "A list of offers with short descriptions")]() [![APP](/img/mockups/07.png "A list of offers with some of them expanded")]() + * This illustrates the problem of fully removing a subcomponent and transferring its business logic to other parts of the system. + 3. Manipulating the data presented to the user and the available actions for an offer through adding new buttons, such as “Previous offer,” “Next offer,” and “Make a call.” [![APP](/img/mockups/08.png "An offer panel with additional icons and buttons")]() In this scenario, we're evaluating different chains of propagating data and options down to the offer panel and building dynamic UIs on top of it: - * Some data fields (such as logo and phone number) are properties of a real object received in the search API response. + * Some data fields (such as the logo and phone number) are properties of a real object received in the search API response. * Some data fields make sense only in the context of this specific UI and reflect its design principles (for instance, the “Previous” and “Next” buttons). * Some data fields (such as the icons of the “Not now” and “Make a call” buttons) are bound to the button type (i.e., the business logic it provides). -The obvious approach to tackle these scenarios appears to be creating two additional subcomponents responsible for presenting a list of offers and the details of the specific offer. Let's name them `OfferList` and `OfferPanel` respectively. +The obvious approach to tackling these scenarios appears to be creating two additional subcomponents responsible for presenting a list of offers and the details of the specific offer. Let's name them `OfferList` and `OfferPanel` respectively. [![PLOT](/img/mockups/09.png "The subcomponents of a `SearchBox`")]() @@ -40,8 +42,8 @@ If we had no customization requirements, the pseudo-code implementing interactio class SearchBox implements ISearchBox { // The responsibility of `SearchBox` is: // 1. Creating a container for rendering - // an offer list, prepare option values - // and create the `OfferList` instance + // an offer list, preparing option values + // and creating the `OfferList` instance constructor(container, options) { … this.offerList = new OfferList( @@ -50,9 +52,9 @@ class SearchBox implements ISearchBox { offerListOptions ); } - // 2. Making an offer search when a user - // presses the corresponding button and - // to provide analogous programmable + // 2. Triggering an offer search when + // a user presses the corresponding button + // and providing an analogous programmable // interface for developers onSearchButtonClick() { this.search(this.searchInput.value); @@ -66,14 +68,14 @@ class SearchBox implements ISearchBox { … this.offerList.setOfferList(searchResults) } - // 4. Creating orders (and manipulate sub- - // components if needed) + // 4. Creating orders (and manipulating + // subcomponents if needed) createOrder(offer) { - this.offerListDestroy(); + this.offerList.destroy(); ourCoffeeSdk.createOrder(offer); … } - // 5. Self-destructing when requested to + // 5. Self-destructing if requested destroy() { this.offerList.destroy(); … @@ -85,8 +87,8 @@ class SearchBox implements ISearchBox { class OfferList implements IOfferList { // The responsibility of `OfferList` is: // 1. Creating a container for rendering - // an offer panel, prepare option values - // and create the `OfferPanel` instance + // an offer panel, preparing option values + // and creating the `OfferPanel` instance constructor(searchBox, container, options) { … this.offerPanel = new OfferPanel( @@ -104,7 +106,7 @@ class OfferList implements IOfferList { onOfferClick(offer) { this.offerPanel.show(offer) } - // 4. Self-destructing if requested to + // 4. Self-destructing if requested destroy() { this.offerPanel.destroy(); … @@ -133,7 +135,7 @@ class OfferPanel implements IOfferPanel { onCancelButtonClick() { // … } - // 4. Self-destructing if requested to + // 4. Self-destructing if requested destroy() { … } } ``` @@ -153,9 +155,9 @@ interface IOfferPanel { } ``` -If we aren't making an SDK and have not had the task of making these components customizable, the approach would be perfectly viable. However, let's discuss how would we solve the three sample tasks described above. +If we aren't making an SDK and have not had the task of making these components customizable, the approach would be perfectly viable. However, let's discuss how we would solve the three sample tasks described above. - 1. Displaying an offer list on the map: at first glance, we can develop an alternative component for displaying offers that implement the `IOfferList` interface (let's call it `OfferMap`) that will reuse the standard offer panel. However, we have a problem: `OfferList` only sends commands to `OfferPanel` while `OfferMap` also needs to receive feedback: an event of panel closure to deselect a marker. API of our components does not encompass this functionality, and implementing it is not that simple: + 1. Displaying an offer list on the map: at first glance, we can develop an alternative component for displaying offers that implements the `IOfferList` interface (let's call it `OfferMap`) and reuses the standard offer panel. However, we have a problem: `OfferList` only sends commands to `OfferPanel` while `OfferMap` also needs to receive feedback — an event of panel closure to deselect a marker. The API of our components does not encompass this functionality, and implementing it is not that simple: ```typescript class CustomOfferPanel extends OfferPanel { @@ -187,9 +189,9 @@ If we aren't making an SDK and have not had the task of making these components We have to create a `CustomOfferPanel` class, and this implementation, unlike its parent class, now only works with `OfferMap`, not with any `IOfferList`-compatible component. - 2. The case of making full offer details and action controls in place in the offer list is pretty obvious: we can achieve this only by writing a new `IOfferList`-compatible component from scratch as whatever overrides we apply to standard `OfferList`, it will continue creating an `OfferPanel` and open it upon offer selection. + 2. The case of making full offer details and action controls in place in the offer list is pretty obvious: we can achieve this only by writing a new `IOfferList`-compatible component from scratch because whatever overrides we apply to the standard `OfferList`, it will continue creating an `OfferPanel` and open it upon offer selection. - 3. To implement new buttons, we can only propose developers creating a custom offer list component (to provide methods for selecting previous and next offers) and a custom offer panel that will call these methods. If we find a simple solution for customizing, let's say, the “Place an order” button text, this solution needs to be supported in the `OfferList` code: + 3. To implement new buttons, we can only propose to developers to create a custom offer list component (to provide methods for selecting previous and next offers) and a custom offer panel that will call these methods. If we find a simple solution for customizing, let's say, the “Place an order” button text, this solution needs to be supported in the `OfferList` code: ```typescript const searchBox = new SearchBox(…, { @@ -202,7 +204,7 @@ If we aren't making an SDK and have not had the task of making these components … // It is `OfferList`'s responsibility // to isolate the injection point and - // to propagate the overriden value + // to propagate the overridden value // to the `OfferPanel` instance this.offerPanel = new OfferPanel(…, { /* */createOrderButtonText: options @@ -213,7 +215,7 @@ If we aren't making an SDK and have not had the task of making these components } ``` -The solutions we discuss are also poorly extendable. For example, in \#1, if we decide to make the offer list reaction to closing an offer panel a part of a standard interface for developers to use it, we will need to add a new method to the `IOfferList` interface and make it optional to maintain backward compatibility: +The solutions we discuss are also poorly extendable. For example, in \#1, if we decide to make the offer list react to the closing of an offer panel as a part of the standard interface for developers to use, we will need to add a new method to the `IOfferList` interface and make it optional to maintain backward compatibility: ```typescript interface IOfferList { @@ -231,4 +233,288 @@ if (Type(this.offerList.onOfferPanelClose) } ``` -For sure, this will not make our code any nicer. Additionally, `OfferList` and `OfferPanel` will become even more tightly coupled. \ No newline at end of file +Certainly, this will not make our code any cleaner. Additionally, `OfferList` and `OfferPanel` will become even more tightly coupled. + +As we discussed in the “[Weak Coupling](#back-compat-weak-coupling)” chapter, to solve such problems we need to reduce the strong coupling of the components in favor of weak coupling, for example, by generating events instead of calling methods directly. An `IOfferPanel` could have emitted a `'close'` event, so that an `OfferList` could have listened to it: + +```typescript +class OfferList { + setup() { + … + /* */this.offerPanel.events.on(/* */ + /* */'close'/* */, + function () { + this.resetCurrentOffer(); + } + ) + } + … +} +``` + +This code looks more sensible but doesn't eliminate the mutual dependencies of the components: an `OfferList` still cannot be used without an `OfferPanel` as required in Case \#2. + +Let us note that all the code samples above are a full chaos of abstraction levels: an `OfferList` instantiates an `OfferPanel` and manages it directly, and an `OfferPanel` has to jump over levels to create an order. We can try to unlink them if we route all calls through the `SearchBox` itself, for example, like this: + +```typescript +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(); + } + ); + } +} +``` + +Now `OfferList` and `OfferPanel` are independent, but we have another issue: to replace them with alternative implementations we have to change the `SearchBox` itself. We can go even further and make it like this: + +```typescript +class SearchBox { + constructor() { + … + this.offerList.events.on( + 'offerSelect', function (event) { + this.events.emit('offerSelect', { + offer: event.selectedOffer + }); + } + ); + } + … +} +``` + +So a `SearchBox` just translates events, maybe with some data alterations. We can even force the `SearchBox` to transmit *any* events of child components, which will allow us to extend the functionality by adding new events. However, this is definitely not the responsibility of a high-level component, being mostly a proxy for translating events. Also, using these event chains is error prone. For example, how should the functionality of selecting a next offer in the offer panel (Case \#3) be implemented? We need an `OfferList` to both generate an `'offerSelect'` event *and* react when the parent context emits it. One can easily create an infinite loop of it: + +```typescript +class OfferList { + constructor(searchBox, …) { + … + searchBox.events.on( + 'offerSelect', + this.selectOffer + ); + } + + selectOffer(offer) { + … + this.events.emit( + 'offerSelect', offer + ); + } +} +``` + +```typescript +class SearchBox { + constructor() { + … + this.offerList.events.on( + 'offerSelect', function (offer) { + … + this.events.emit( + 'offerSelect', offer + ); + } + ); + } +} +``` + +To avoid infinite loops, we could split the events: + +```typescript +class SearchBox { + constructor() { + … + // An `OfferList` notifies about + // low-level events, while a `SearchBox`, + // about high-level ones + this.offerList.events.on( + /* */'click'/* */, function (target) { + … + this.events.emit( + /* */'offerSelect'/* */, + target.dataset.offer + ); + } + ); + } +} +``` + +Then the code will become ultimately unmaintainable: to open an `OfferPanel`, developers will need to generate a `'click'` event on an `OfferList` instance. + +In the end, we have already examined five different options for decomposing a UI component employing very different approaches, but found no acceptable solution. Obviously, we can conclude that the problem is not about specific interfaces. What is it about, then? + +Let us formulate what the responsibility of each of the components is: + + 1. `SearchBox` presents the general interface. It is an entry point both for users and developers. If we ask ourselves what a maximum abstract component still constitutes a `SearchBox`, the response will obviously be “the one that allows for entering a search phrase and presenting the results in the UI with the ability to place an order.” + + 2. `OfferList` serves the purpose of showing offers to users. The user can interact with a list — iterate over offers and “activate” them (i.e., perform some actions on a list item). + + 3. `OfferPanel` displays a specific offer and renders *all* the information that is meaningful for the user. There is always exactly one `OfferPanel`. The user can work with the panel, performing actions related to this specific offer (including placing an order). + +Does the `SearchBox` description entail the necessity of `OfferList`'s existence? Obviously, not: we can imagine quite different variants of UI for presenting offers to the users. An `OfferList` is a *specific case* of organizing the `SearchBox`'s functionality for presenting search results. Conversely, the idea of “selecting an offer” and the concepts of `OfferList` and `OfferPanel` performing *different* actions and having *different* options are equally inconsequential to the `SearchBox` definition. At the `SearchBox` level, it doesn't matter *how* the search results are presented and *what states* the corresponding UI could have. + +This leads to a simple conclusion: we cannot decompose `SearchBox` just because we lack a sufficient number of abstraction levels and try to jump over them. We need a “bridge” between an abstract `SearchBox` that does not depend on specific UI and the `OfferList` / `OfferPanel` components that present a specific case of such a UI. Let us artificially introduce an additional abstraction level (let us call it a “Composer”) to control the data flow: + +```typescript +class SearchBoxComposer + implements ISearchBoxComposer { + // The responsibility of a “Composer” comprises: + // 1. Creating a context for nested subcomponents + constructor(searchBox, container, options) { + … + // The context consists of the list of offers + // and the current selected offer + // (both could be empty) + this.offerList = null; + this.currentOffer = null; + // 2. Creating subcomponents and translating + // their options + this.offerList = this.buildOfferList(); + this.offerPanel = this.buildOfferPanel(); + // 3. Managing own state and notifying + // about state changes + this.searchBox.events.on( + 'offerListChange', this.onOfferListChange + ); + // 4. Listening + this.offerListComponent.events.on( + 'offerSelect', this.selectOffer + ); + this.offerPanelComponent.events.on( + 'action', this.performAction + ); + } +} +``` + +The builder methods to create subcomponents, manage their options and potentially their position on the screen would look like this: + +```typescript +class SearchBoxComposer { + … + + buildOfferList() { + return new OfferList( + this, + this.offerListContainer, + this.generateOfferListOptions() + ); + } + + buildOfferPanel() { + return new OfferPanel( + this, + this.offerPanelContainer, + this.generateOfferPanelOptions() + ); + } +} +``` + +We can put the burden of translating contexts on `SearchBoxComposer`. In particular, the following tasks could be handled by the composer: + + 1. Preparing and translating the data. At this level we can stipulate that an `OfferList` shows short information (a “preview”) about the offer, while an `OfferPanel` presents full information, and provide potentially overridable methods of generating the required data facets: + + ```typescript + class SearchBoxComposer { + … + onContextOfferListChange(offerList) { + … + // A `SearchBoxComposer` translates + // an `offerListChange` event as + // an `offerPreviewListChange` for the + // `OfferList` subcomponent, thus preventing + // an infinite loop in the code, and prepares + // the data + this.events.emit('offerPreviewListChange', { + offerList: this.generateOfferPreviews( + this.offerList, + this.contextOptions + ) + }); + } + } + ``` + + 2. Managing the composer's own state (the `currentOffer` field in our case): + + ```typescript + class SearchBoxComposer { + … + onContextOfferListChange(offerList) { + // If an offer is shown when the user + // enters a new search phrase, + // it should be hidden + if (this.currentOffer !== null) { + this.currentOffer = null; + // This is an event specifically + // for the `OfferPanel` to listen to + this.events.emit( + 'offerFullViewToggle', + { offer: null } + ); + } + … + } + } + ``` + + 3. Transforming user's actions on a subcomponent into events or actions on the other components or the parent context: + + ```typescript + class SearchBoxComposer { + … + public performAction({ + action, offerId + }) { + switch (action) { + case 'createOrder': + // The “place an order” action is + // to be handled by the `SearchBox` + this.createOrder(offerId); + break; + case 'close': + // The closing of the offer panel + // event is to be exposed publicly + if (this.currentOffer != null) { + this.currentOffer = null; + this.events.emit( + 'offerFullViewToggle', + { offer: null } + ); + } + break; + … + } + } + } + ``` + +If we revisit the cases we began this chapter with, we can now outline solutions for each of them: + + 1. Presenting search results on a map doesn't change the concept of the list-and-panel UI. We need to implement a custom `IOfferList` and override the `buildOfferList` method in the composer. + + 2. Combining the list and the panel functionality contradicts the UI concept, so we will need to create a custom `ISearchBoxComposer`. However, we can reuse the standard `OfferList` as the composer manages both the data for it and the reactions to the user's actions. + + 3. Enriching the data is compatible with the UI concept, so we continue using standard components. What we need is overriding the functionality of preparing `OfferPanel`'s data and options, and implementing additional events and actions for the composer to translate. + +The price of this flexibility is the overwhelming complexity of component communications. Each event and data field must be propagated through the chains of such “composers” that elongate the abstraction hierarchy. Every transformation in this chain (for example, generating options for subcomponents or reacting to context events) is to be implemented in an extendable and parametrizable way. We can only offer reasonable helpers to ease using such customization. However, in the SDK code, the complexity will always be present. This is the way. + +The reference implementation of all the components with the interfaces we discussed and all three customization cases can be found in this book's repository: + * The source code is available on [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) + * There are also additional tasks for self-study + * The sandbox with “live” examples is available on [twirl.github.io/The-API-Book](https://twirl.github.io/The-API-Book/examples/01.%20Decomposing%20UI%20Components/). \ No newline at end of file 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 a08b77d..d305bda 100644 --- a/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md +++ b/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md @@ -70,7 +70,7 @@ class SearchBox implements ISearchBox { // 4. Создавать заказы (и выполнять нужные // операции над компонентами) createOrder(offer) { - this.offerListDestroy(); + this.offerList.destroy(); ourCoffeeSdk.createOrder(offer); … } @@ -239,8 +239,8 @@ if (Type(this.offerList.onOfferPanelClose) class OfferList { setup() { … - this.offerPanel.events.on( - 'close', + /* */this.offerPanel.events.on(/* */ + /* */'close'/* */, function () { this.resetCurrentOffer(); } @@ -250,9 +250,9 @@ class OfferList { } ``` -Код выглядит более разумно написанным, но никак не уменьшает связность: использовать `OfferList` без `OfferPanel`, как этого требует сценарий \#2, мы всё ещё не можем. +Код выглядит более разумно написанным, но никак не уменьшает взаимозавимость компонентов: использовать `OfferList` без `OfferPanel`, как этого требует сценарий \#2, мы всё ещё не можем. -Во всех вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: `OfferList` инстанцирует `OfferPanel` и управляет ей напрямую. При этом `OfferPanel` приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам `SearchBox`, например, так: +Заметим, что в вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: `OfferList` инстанцирует `OfferPanel` и управляет ей напрямую. При этом `OfferPanel` приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам `SearchBox`, например, так: ```typescript class SearchBox() { @@ -267,9 +267,9 @@ class SearchBox() { this.offerPanel.events.on( 'close', function () { this.offerList - .resetSelectedOffer() + .resetSelectedOffer(); } - ) + ); } } ``` @@ -277,7 +277,7 @@ class SearchBox() { Теперь `OfferList` и `OfferPanel` стали независимы друг от друга, но мы получили другую проблему: для их замены на альтернативные имплементации нам придётся переписать сам `SearchBox`. Мы можем абстрагироваться ещё дальше, поступив вот так: ```typescript -class SearchBox() { +class SearchBox { constructor() { … this.offerList.events.on( @@ -288,10 +288,11 @@ class SearchBox() { } ); } + … } ``` -То есть заставить `SearchBox` транслировать события, возможно, с преобразованием данных. Мы даже можем заставить `SearchBox` транслировать *любые* события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в `offerPanel` (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы `OfferList` не только генерировал сам событие `offerSelect`, но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл: +То есть заставить `SearchBox` транслировать события, возможно, с преобразованием данных. Мы даже можем заставить `SearchBox` транслировать *любые* события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в `offerPanel` (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы `OfferList` не только генерировал сам событие `'offerSelect'`, но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл: ```typescript class OfferList { @@ -300,17 +301,19 @@ class OfferList { searchBox.events.on( 'offerSelect', this.selectOffer - ) + ); } selectOffer(offer) { … this.events.emit( 'offerSelect', offer - ) + ); } } +``` +```typescript class SearchBox { constructor() { … @@ -319,9 +322,9 @@ class SearchBox { … this.events.emit( 'offerSelect', offer - ) + ); } - ) + ); } } ``` @@ -347,7 +350,7 @@ class SearchBox { } ``` -Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать `click` на инстанции класса `offerList`. +Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать `'click'` на инстанции класса `OfferList`. Итого, мы перебрали уже как минимум пять разных вариантов организации декомпозиции UI-компонента в самых различных парадигмах, но так и не получили ни одного приемлемого решения. Вывод, который мы должны сделать, следующий: проблема не в конкретных интерфейсах и не в подходе к решению. В чём же она тогда? @@ -361,12 +364,13 @@ class SearchBox { Следует ли из определения `SearchBox` необходимость наличия суб-компонента `OfferList`? Никоим образом: мы можем придумать самые разные способы показа пользователю предложений. `OfferList` — *частный случай*, каким образом мы могли бы организовать работу `SearchBox`-а по предоставлению UI к результатами поиска. -Следует ли из определения `SearchBox` и `OfferList` необходимость наличия суб-компонента `OfferPanel`? Вновь нет: даже сама концепция существования какой-то *краткой* и *полной* информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что `OfferList` и `OfferPanel` выполняют *разные* действия и имеют *разные* настройки. На уровне `SearchBox` вообще не важно, *как* результаты поисква представлены пользователю и в каких *состояниях* может находиться соответствующий UI. +Следует ли из определения `SearchBox` и `OfferList` необходимость наличия суб-компонента `OfferPanel`? Вновь нет: даже сама концепция существования какой-то *краткой* и *полной* информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что `OfferList` и `OfferPanel` выполняют *разные* действия и имеют *разные* настройки. На уровне `SearchBox` вообще не важно, *как* результаты поиска представлены пользователю и в каких *состояниях* может находиться соответствующий UI. -Всё это приводит нас к простому выводу: мы не можем декомпозировать `SearchBox` просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между `SearchBox`, который не зависит от конкретной имплементации UI работы с предложениями и `OfferList`/`OfferPanel`, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных. +Всё это приводит нас к простому выводу: мы не можем декомпозировать `SearchBox` просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между `SearchBox`, который не зависит от конкретной имплементации UI работы с предложениями и `OfferList`/`OfferPanel`, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных: ```typescript -class SearchBoxComposer implements ISearchBoxComposer { +class SearchBoxComposer + implements ISearchBoxComposer { // Ответственность `composer`-а состоит в: // 1. Создании собственного контекста // для дочерних компонентов @@ -381,12 +385,12 @@ class SearchBoxComposer implements ISearchBoxComposer { // и трансляции опций для них this.offerList = this.buildOfferList(); this.offerPanel = this.buildOfferPanel(); - // 2. Управлении состоянием и оповещении + // 3. Управлении состоянием и оповещении // суб-компонентов о его изменении this.searchBox.events.on( 'offerListChange', this.onOfferListChange ); - // 3. Прослушивании событий дочерних + // 4. Прослушивании событий дочерних // компонентов и вызове нужных действий this.offerListComponent.events.on( 'offerSelect', this.selectOffer @@ -395,6 +399,15 @@ class SearchBoxComposer implements ISearchBoxComposer { 'action', this.performAction ); } + … +} +``` + +Здесь методы-строители подчинённых компонентов, позволяющие переопределять опции компонентов (и, потенциально, их расположение на экране) выглядят как: + +```typescript +class SearchBoxComposer { + … buildOfferList() { return new OfferList( @@ -416,14 +429,14 @@ class SearchBoxComposer implements ISearchBoxComposer { Мы можем придать `SearchBoxComposer`-у функциональность трансляции любых контекстов. В частности: - 1. Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что `offerList` показывает краткую информацию о предложений, а `offerPanel` — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных: + 1. Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что `OfferList` показывает краткую информацию о предложений, а `OfferPanel` — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных: ```typescript class SearchBoxComposer { … onContextOfferListChange(offerList) { … - // `SearchBox` транслирует событие + // `SearchBoxComposer` транслирует событие // `offerListChange` как `offerPreviewListChange` // специально для компонента `OfferList`, // таким образом, исключая возможность @@ -477,7 +490,7 @@ class SearchBoxComposer implements ISearchBoxComposer { break; case 'close': // Действие «закрытие панели предложения» - // нужно оттранслировать `OfferList`-у + // нужно распространить для всех if (this.currentOffer != null) { this.currentOffer = null; this.events.emit( @@ -488,14 +501,18 @@ class SearchBoxComposer implements ISearchBoxComposer { … } } + } ``` Если теперь мы посмотрим на кейсы, описанные в начале главы, то мы можем наметить стратегию имплементации каждого из них: - 1. Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного `OfferList`-а нам нужно переопределить метод `buildOfferList` так, чтобы он создавал наш кастомный компонент с картой. + + 1. Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного `IOfferList`-а нам нужно переопределить метод `buildOfferList` так, чтобы он создавал наш кастомный компонент с картой. + 2. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный `ISearchBoxComposer`. Но мы при этом сможем использовать стандартный `OfferList`, поскольку `Composer` управляет и подготовкой данных для него, и реакцией на действия пользователей. + 3. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный `SearchBoxComposer` и `OfferList`), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые `SearchBoxComposer` транслирует с панели предложения. -Ценой этой гибкости является чрезвычайное усложнение взаимодейсвтия. Все события и потоки данных должны проходить через цепочку таких `Composer`-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь. +Ценой этой гибкости является чрезвычайное усложнение взаимодействия. Все события и потоки данных должны проходить через цепочку таких `Composer`-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь. Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги: * исходный код доступен на [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)