You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-07-12 22:50:21 +02:00
Decomposing UI Components finally delivered
This commit is contained in:
BIN
docs/API.en.epub
BIN
docs/API.en.epub
Binary file not shown.
332
docs/API.en.html
332
docs/API.en.html
File diff suppressed because one or more lines are too long
BIN
docs/API.en.pdf
BIN
docs/API.en.pdf
Binary file not shown.
BIN
docs/API.ru.epub
BIN
docs/API.ru.epub
Binary file not shown.
@ -5940,7 +5940,7 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
<span class="hljs-comment">// 4. Создавать заказы (и выполнять нужные</span>
|
||||
<span class="hljs-comment">// операции над компонентами)</span>
|
||||
<span class="hljs-title function_">createOrder</span>(<span class="hljs-params">offer</span>) {
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-title function_">offerListDestroy</span>();
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerList</span>.<span class="hljs-title function_">destroy</span>();
|
||||
ourCoffeeSdk.<span class="hljs-title function_">createOrder</span>(offer);
|
||||
…
|
||||
}
|
||||
@ -6091,8 +6091,8 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">OfferList</span> {
|
||||
<span class="hljs-title function_">setup</span>(<span class="hljs-params"></span>) {
|
||||
…
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerPanel</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(
|
||||
<span class="hljs-string">'close'</span>,
|
||||
<em><span class="hljs-variable language_">this</span>.<span class="hljs-property">offerPanel</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(</em>
|
||||
<em><span class="hljs-string">'close'</span></em>,
|
||||
<span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) {
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-title function_">resetCurrentOffer</span>();
|
||||
}
|
||||
@ -6101,8 +6101,8 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>Код выглядит более разумно написанным, но никак не уменьшает связность: использовать <code>OfferList</code> без <code>OfferPanel</code>, как этого требует сценарий #2, мы всё ещё не можем.</p>
|
||||
<p>Во всех вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: <code>OfferList</code> инстанцирует <code>OfferPanel</code> и управляет ей напрямую. При этом <code>OfferPanel</code> приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам <code>SearchBox</code>, например, так:</p>
|
||||
<p>Код выглядит более разумно написанным, но никак не уменьшает взаимозавимость компонентов: использовать <code>OfferList</code> без <code>OfferPanel</code>, как этого требует сценарий #2, мы всё ещё не можем.</p>
|
||||
<p>Заметим, что в вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: <code>OfferList</code> инстанцирует <code>OfferPanel</code> и управляет ей напрямую. При этом <code>OfferPanel</code> приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам <code>SearchBox</code>, например, так:</p>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBox</span>() {
|
||||
<span class="hljs-title function_">constructor</span>(<span class="hljs-params"></span>) {
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerList</span> = <span class="hljs-keyword">new</span> <span class="hljs-title class_">OfferList</span>(…);
|
||||
@ -6115,14 +6115,14 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerPanel</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(
|
||||
<span class="hljs-string">'close'</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) {
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerList</span>
|
||||
.<span class="hljs-title function_">resetSelectedOffer</span>()
|
||||
.<span class="hljs-title function_">resetSelectedOffer</span>();
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Теперь <code>OfferList</code> и <code>OfferPanel</code> стали независимы друг от друга, но мы получили другую проблему: для их замены на альтернативные имплементации нам придётся переписать сам <code>SearchBox</code>. Мы можем абстрагироваться ещё дальше, поступив вот так:</p>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBox</span>() {
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBox</span> {
|
||||
<span class="hljs-title function_">constructor</span>(<span class="hljs-params"></span>) {
|
||||
…
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerList</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(
|
||||
@ -6133,27 +6133,28 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
}
|
||||
);
|
||||
}
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>То есть заставить <code>SearchBox</code> транслировать события, возможно, с преобразованием данных. Мы даже можем заставить <code>SearchBox</code> транслировать <em>любые</em> события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в <code>offerPanel</code> (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы <code>OfferList</code> не только генерировал сам событие <code>offerSelect</code>, но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл:</p>
|
||||
<p>То есть заставить <code>SearchBox</code> транслировать события, возможно, с преобразованием данных. Мы даже можем заставить <code>SearchBox</code> транслировать <em>любые</em> события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в <code>offerPanel</code> (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы <code>OfferList</code> не только генерировал сам событие <code>'offerSelect'</code>, но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл:</p>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">OfferList</span> {
|
||||
<span class="hljs-title function_">constructor</span>(<span class="hljs-params">searchBox, …</span>) {
|
||||
…
|
||||
searchBox.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(
|
||||
<span class="hljs-string">'offerSelect'</span>,
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">selectOffer</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
<span class="hljs-title function_">selectOffer</span>(<span class="hljs-params">offer</span>) {
|
||||
…
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">emit</span>(
|
||||
<span class="hljs-string">'offerSelect'</span>, offer
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
<span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBox</span> {
|
||||
</code></pre>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBox</span> {
|
||||
<span class="hljs-title function_">constructor</span>(<span class="hljs-params"></span>) {
|
||||
…
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerList</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(
|
||||
@ -6161,9 +6162,9 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
…
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">emit</span>(
|
||||
<span class="hljs-string">'offerSelect'</span>, offer
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
@ -6185,7 +6186,7 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
<p>Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать <code>click</code> на инстанции класса <code>offerList</code>.</p>
|
||||
<p>Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать <code>'click'</code> на инстанции класса <code>OfferList</code>.</p>
|
||||
<p>Итого, мы перебрали уже как минимум пять разных вариантов организации декомпозиции UI-компонента в самых различных парадигмах, но так и не получили ни одного приемлемого решения. Вывод, который мы должны сделать, следующий: проблема не в конкретных интерфейсах и не в подходе к решению. В чём же она тогда?</p>
|
||||
<p>Давайте сформулируем, в чём состоит область ответственности каждого из наших компонентов:</p>
|
||||
<ol>
|
||||
@ -6200,9 +6201,10 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
</li>
|
||||
</ol>
|
||||
<p>Следует ли из определения <code>SearchBox</code> необходимость наличия суб-компонента <code>OfferList</code>? Никоим образом: мы можем придумать самые разные способы показа пользователю предложений. <code>OfferList</code> — <em>частный случай</em>, каким образом мы могли бы организовать работу <code>SearchBox</code>-а по предоставлению UI к результатами поиска.</p>
|
||||
<p>Следует ли из определения <code>SearchBox</code> и <code>OfferList</code> необходимость наличия суб-компонента <code>OfferPanel</code>? Вновь нет: даже сама концепция существования какой-то <em>краткой</em> и <em>полной</em> информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что <code>OfferList</code> и <code>OfferPanel</code> выполняют <em>разные</em> действия и имеют <em>разные</em> настройки. На уровне <code>SearchBox</code> вообще не важно, <em>как</em> результаты поисква представлены пользователю и в каких <em>состояниях</em> может находиться соответствующий UI.</p>
|
||||
<p>Всё это приводит нас к простому выводу: мы не можем декомпозировать <code>SearchBox</code> просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между <code>SearchBox</code>, который не зависит от конкретной имплементации UI работы с предложениями и <code>OfferList</code>/<code>OfferPanel</code>, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных.</p>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBoxComposer</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">ISearchBoxComposer</span> {
|
||||
<p>Следует ли из определения <code>SearchBox</code> и <code>OfferList</code> необходимость наличия суб-компонента <code>OfferPanel</code>? Вновь нет: даже сама концепция существования какой-то <em>краткой</em> и <em>полной</em> информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что <code>OfferList</code> и <code>OfferPanel</code> выполняют <em>разные</em> действия и имеют <em>разные</em> настройки. На уровне <code>SearchBox</code> вообще не важно, <em>как</em> результаты поиска представлены пользователю и в каких <em>состояниях</em> может находиться соответствующий UI.</p>
|
||||
<p>Всё это приводит нас к простому выводу: мы не можем декомпозировать <code>SearchBox</code> просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между <code>SearchBox</code>, который не зависит от конкретной имплементации UI работы с предложениями и <code>OfferList</code>/<code>OfferPanel</code>, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных:</p>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBoxComposer</span>
|
||||
<span class="hljs-keyword">implements</span> <span class="hljs-title class_">ISearchBoxComposer</span> {
|
||||
<span class="hljs-comment">// Ответственность `composer`-а состоит в:</span>
|
||||
<span class="hljs-comment">// 1. Создании собственного контекста</span>
|
||||
<span class="hljs-comment">// для дочерних компонентов</span>
|
||||
@ -6217,12 +6219,12 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
<span class="hljs-comment">// и трансляции опций для них</span>
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerList</span> = <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">buildOfferList</span>();
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerPanel</span> = <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">buildOfferPanel</span>();
|
||||
<span class="hljs-comment">// 2. Управлении состоянием и оповещении</span>
|
||||
<span class="hljs-comment">// 3. Управлении состоянием и оповещении</span>
|
||||
<span class="hljs-comment">// суб-компонентов о его изменении</span>
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">searchBox</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(
|
||||
<span class="hljs-string">'offerListChange'</span>, <span class="hljs-variable language_">this</span>.<span class="hljs-property">onOfferListChange</span>
|
||||
);
|
||||
<span class="hljs-comment">// 3. Прослушивании событий дочерних</span>
|
||||
<span class="hljs-comment">// 4. Прослушивании событий дочерних</span>
|
||||
<span class="hljs-comment">// компонентов и вызове нужных действий</span>
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">offerListComponent</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">on</span>(
|
||||
<span class="hljs-string">'offerSelect'</span>, <span class="hljs-variable language_">this</span>.<span class="hljs-property">selectOffer</span>
|
||||
@ -6231,6 +6233,12 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
<span class="hljs-string">'action'</span>, <span class="hljs-variable language_">this</span>.<span class="hljs-property">performAction</span>
|
||||
);
|
||||
}
|
||||
…
|
||||
}
|
||||
</code></pre>
|
||||
<p>Здесь методы-строители подчинённых компонентов, позволяющие переопределять опции компонентов (и, потенциально, их расположение на экране) выглядят как:</p>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBoxComposer</span> {
|
||||
…
|
||||
|
||||
<span class="hljs-title function_">buildOfferList</span>(<span class="hljs-params"></span>) {
|
||||
<span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">OfferList</span>(
|
||||
@ -6252,12 +6260,12 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
<p>Мы можем придать <code>SearchBoxComposer</code>-у функциональность трансляции любых контекстов. В частности:</p>
|
||||
<ol>
|
||||
<li>
|
||||
<p>Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что <code>offerList</code> показывает краткую информацию о предложений, а <code>offerPanel</code> — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных:</p>
|
||||
<p>Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что <code>OfferList</code> показывает краткую информацию о предложений, а <code>OfferPanel</code> — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных:</p>
|
||||
<pre><code class="language-typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">SearchBoxComposer</span> {
|
||||
…
|
||||
<span class="hljs-title function_">onContextOfferListChange</span>(<span class="hljs-params">offerList</span>) {
|
||||
…
|
||||
<span class="hljs-comment">// `SearchBox` транслирует событие</span>
|
||||
<span class="hljs-comment">// `SearchBoxComposer` транслирует событие</span>
|
||||
<span class="hljs-comment">// `offerListChange` как `offerPreviewListChange`</span>
|
||||
<span class="hljs-comment">// специально для компонента `OfferList`,</span>
|
||||
<span class="hljs-comment">// таким образом, исключая возможность </span>
|
||||
@ -6309,7 +6317,7 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
<span class="hljs-keyword">break</span>;
|
||||
<span class="hljs-keyword">case</span> <span class="hljs-string">'close'</span>:
|
||||
<span class="hljs-comment">// Действие «закрытие панели предложения»</span>
|
||||
<span class="hljs-comment">// нужно оттранслировать `OfferList`-у</span>
|
||||
<span class="hljs-comment">// нужно распространить для всех</span>
|
||||
<span class="hljs-keyword">if</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">currentOffer</span> != <span class="hljs-literal">null</span>) {
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">currentOffer</span> = <span class="hljs-literal">null</span>;
|
||||
<span class="hljs-variable language_">this</span>.<span class="hljs-property">events</span>.<span class="hljs-title function_">emit</span>(
|
||||
@ -6320,16 +6328,23 @@ api.<span class="hljs-title function_">subscribe</span>(
|
||||
…
|
||||
}
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Если теперь мы посмотрим на кейсы, описанные в начале главы, то мы можем наметить стратегию имплементации каждого из них:</p>
|
||||
<ol>
|
||||
<li>Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного <code>OfferList</code>-а нам нужно переопределить метод <code>buildOfferList</code> так, чтобы он создавал наш кастомный компонент с картой.</li>
|
||||
<li>Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный <code>ISearchBoxComposer</code>. Но мы при этом сможем использовать стандартный <code>OfferList</code>, поскольку <code>Composer</code> управляет и подготовкой данных для него, и реакцией на действия пользователей.</li>
|
||||
<li>Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный <code>SearchBoxComposer</code> и <code>OfferList</code>), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые <code>SearchBoxComposer</code> транслирует с панели предложения.</li>
|
||||
<li>
|
||||
<p>Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного <code>IOfferList</code>-а нам нужно переопределить метод <code>buildOfferList</code> так, чтобы он создавал наш кастомный компонент с картой.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный <code>ISearchBoxComposer</code>. Но мы при этом сможем использовать стандартный <code>OfferList</code>, поскольку <code>Composer</code> управляет и подготовкой данных для него, и реакцией на действия пользователей.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный <code>SearchBoxComposer</code> и <code>OfferList</code>), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые <code>SearchBoxComposer</code> транслирует с панели предложения.</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p>Ценой этой гибкости является чрезвычайное усложнение взаимодейсвтия. Все события и потоки данных должны проходить через цепочку таких <code>Composer</code>-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.</p>
|
||||
<p>Ценой этой гибкости является чрезвычайное усложнение взаимодействия. Все события и потоки данных должны проходить через цепочку таких <code>Composer</code>-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.</p>
|
||||
<p>Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:</p>
|
||||
<ul>
|
||||
<li>исходный код доступен на <a href="https://github.com/twirl/The-API-Book/tree/gh-pages/docs/examples/01.%20Decomposing%20UI%20Components">www.github.com/twirl/The-API-Book/docs/examples</a>
|
||||
|
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
@ -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.
|
||||
|
||||
[]()
|
||||
|
||||
* 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.
|
||||
|
||||
[]()
|
||||
|
||||
[]()
|
||||
|
||||
* 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.”
|
||||
|
||||
[]()
|
||||
|
||||
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.
|
||||
|
||||
[]()
|
||||
|
||||
@ -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(…, {
|
||||
/* <em> */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.
|
||||
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() {
|
||||
…
|
||||
/* <em> */this.offerPanel.events.on(/* </em> */
|
||||
/* <em> */'close'/* </em> */,
|
||||
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(
|
||||
/* <em> */'click'/* </em> */, function (target) {
|
||||
…
|
||||
this.events.emit(
|
||||
/* <em> */'offerSelect'/* </em> */,
|
||||
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/).
|
@ -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',
|
||||
/* <em> */this.offerPanel.events.on(/* </em> */
|
||||
/* <em> */'close'/* </em> */,
|
||||
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)
|
||||
|
Reference in New Issue
Block a user