MV* frameworks. Graphs rebuilt
BIN
docs/API.en.epub
111
docs/API.en.html
BIN
docs/API.en.pdf
BIN
docs/API.ru.epub
111
docs/API.ru.html
BIN
docs/API.ru.pdf
@ -129,7 +129,7 @@
|
||||
<li><a href="API.en.html#sdk-problems-solutions">Chapter 42. SDKs: Problems and Solutions</a></li>
|
||||
<li><a href="API.en.html#sdk-ui-components">Chapter 43. Problems of Introducing UI Components</a></li>
|
||||
<li><a href="API.en.html#sdk-decomposing">Chapter 44. Decomposing UI Components</a></li>
|
||||
<li><a href="API.en.html#chapter-45">Chapter 45. The MV* Frameworks</a></li>
|
||||
<li><a href="API.en.html#sdk-mv-frameworks">Chapter 45. The MV* Frameworks</a></li>
|
||||
<li><a href="API.en.html#chapter-46">Chapter 46. The Backend-Driven UI</a></li>
|
||||
<li><a href="API.en.html#chapter-47">Chapter 47. Shared Resources and Asynchronous Locks</a></li>
|
||||
<li><a href="API.en.html#chapter-48">Chapter 48. Computed Properties</a></li>
|
||||
|
@ -129,7 +129,7 @@
|
||||
<li><a href="API.ru.html#sdk-problems-solutions">Глава 42. SDK: проблемы и решения</a></li>
|
||||
<li><a href="API.ru.html#sdk-ui-components">Глава 43. Проблемы встраивания UI-компонентов</a></li>
|
||||
<li><a href="API.ru.html#sdk-decomposing">Глава 44. Декомпозиция UI-компонентов</a></li>
|
||||
<li><a href="API.ru.html#chapter-45">Глава 45. MV*-фреймворки</a></li>
|
||||
<li><a href="API.ru.html#sdk-mv-frameworks">Глава 45. MV*-фреймворки</a></li>
|
||||
<li><a href="API.ru.html#chapter-46">Глава 46. Backend-Driven UI</a></li>
|
||||
<li><a href="API.ru.html#chapter-47">Глава 47. Разделяемые ресурсы и асинхронные блокировки</a></li>
|
||||
<li><a href="API.ru.html#chapter-48">Глава 48. Вычисляемые свойства</a></li>
|
||||
|
@ -2,7 +2,7 @@ import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve, basename } from 'path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import templates from '../src/templates.js';
|
||||
import { templates } from '../src/templates.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const dir = process.cwd();
|
||||
@ -51,12 +51,12 @@ async function buildGraph(lang, target, dstDir, tmpDir) {
|
||||
await writeFile(tmpFileName, templates.graphHtmlTemplate(graph));
|
||||
console.log(`Tmp file ${tmpFileName} written`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
headless: 'new',
|
||||
product: 'chrome',
|
||||
defaultViewport: {
|
||||
deviceScaleFactor: 2,
|
||||
width: 1000,
|
||||
height: 1000
|
||||
width: 1200,
|
||||
height: 1200
|
||||
}
|
||||
});
|
||||
const outFile = resolve(
|
||||
@ -67,11 +67,14 @@ async function buildGraph(lang, target, dstDir, tmpDir) {
|
||||
await page.goto(tmpFileName, {
|
||||
waitUntil: 'networkidle0'
|
||||
});
|
||||
const $canvas = await page.$('svg');
|
||||
const bounds = await $canvas.boundingBox();
|
||||
const body = await page.$('body');
|
||||
await body.screenshot({
|
||||
path: outFile,
|
||||
type: 'png',
|
||||
captureBeyondViewport: true
|
||||
captureBeyondViewport: true,
|
||||
clip: bounds
|
||||
});
|
||||
await browser.close();
|
||||
}
|
||||
|
@ -236,6 +236,7 @@ ul.table-of-contents ul {
|
||||
|
||||
ul.table-of-contents ul li {
|
||||
text-transform: none;
|
||||
font-family: local-sans;
|
||||
}
|
||||
|
||||
a.anchor {
|
||||
|
@ -164,8 +164,8 @@ If we aren't making an SDK and have not had the task of making these components
|
||||
constructor(
|
||||
searchBox, offerMap, container, options
|
||||
) {
|
||||
this.offerMap = offerMap;
|
||||
super(searchBox, container, options);
|
||||
this.offerMap = offerMap;
|
||||
}
|
||||
onCancelButtonClick() {
|
||||
/* <em> */offerMap.resetCurrentOffer();/* </em> */
|
||||
|
@ -1 +1,100 @@
|
||||
### The MV* Frameworks
|
||||
### [The MV* Frameworks][sdk-mv-frameworks]
|
||||
|
||||
One obvious approach to reducing the complexity of implementing the multi-layered component hierarchies we described in the previous chapter is to restrict possible interaction directions. As we described in the “[Weak Coupling](#back-compat-weak-coupling)” chapter, we could simplify the implementation if we allow subcomponents to call the parent context's methods directly:
|
||||
|
||||
```typescript
|
||||
class SearchBoxComposer
|
||||
implements ISearchBoxComposer {
|
||||
…
|
||||
protected context: ISearchBox;
|
||||
…
|
||||
public createOrder(offerId: string) {
|
||||
const offer = this.findOfferById(offerId);
|
||||
if (offer !== null) {
|
||||
// Instead of generating an event
|
||||
// this.events.emit(
|
||||
// 'createOrder', { offer });
|
||||
/* <em> */this.context
|
||||
.createOrder(offer);/* </em> */
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Additionally, we may relieve `Composer` of data preparation duty and allow subcomponents to retrieve the data fields they need from `SearchBox` directly:
|
||||
|
||||
```typescript
|
||||
class OfferListComponent
|
||||
implements IOfferListComponent {
|
||||
…
|
||||
protected context: SearchBox;
|
||||
…
|
||||
constructor () {
|
||||
…
|
||||
// The offer list component
|
||||
// takes data from `SearchBox`
|
||||
// and listens to state changes
|
||||
/* <em> */this.context.events.on(
|
||||
'offerListChange',
|
||||
() => {
|
||||
this.show(
|
||||
this.context.getOfferList()
|
||||
);
|
||||
}
|
||||
);/* </em> */
|
||||
}
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
As we lose the ability to prepare data for subcomponents, we can no longer attach subcomponents to different parents through implementing a custom `Composer`. However, we can still replace them with alternative implementations, as the reactions to user's actions are still controlled by `Composer`. As a bonus, we now don't have two-way interactions between our entities:
|
||||
|
||||
* Subcomponents *read* `SearchBox`'s state but never modify it.
|
||||
|
||||
* `Composer` *gets notified* about the user's interaction with the UI but doesn't interfere
|
||||
|
||||
* Finally, `SearchBox` doesn't interact with either of them and only provides a context, methods to change it, and the corresponding notifications.
|
||||
|
||||
By making these reductions, in fact, we end up with a setup that follows the “Model-View-Controller” (MVC) methodology[ref MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller). `OfferList` and `OfferPanel` (also, the code that displays the input field) constitute a *view* that the user observes and interacts with. `Composer` is a *controller* that listens to the *view*'s events and modifies a *model* (`SearchBox` itself).
|
||||
|
||||
**NB**: to follow the letter of the paradigm, we must separate the *model*, which will be responsible only for the data, from `SearchBox` itself. We leave this exercise to the reader.
|
||||
|
||||
[]()
|
||||
|
||||
If we choose other options for reducing interaction directions, we will get other MV* frameworks (such as Model-View-Viewmodel, Model-View-Presenter, etc.). All of them are ultimately based on the “Model” pattern.
|
||||
|
||||
#### The “Model” Pattern
|
||||
|
||||
The common denominator of all MV* frameworks is the requirement for the “model” entity to *fully deterministically define* the look and state of a UI component. Changes in a model beget changes in views (or the hierarchy of views as in some approaches a model could be global and define the look of the entire application). Meanwhile, visual components cannot affect the model directly as they only interact with controllers.
|
||||
|
||||
SDKs that implement one of the MV* paradigms theoretically gain important advantages:
|
||||
|
||||
* Mandatory separation of data domains as it is prescribed (though not necessarily followed, see below) that a *model* contains *sematic high-level data*.
|
||||
|
||||
* The event loop cycles are almost impossible since controllers should only react to the user's or developer's interaction with views, not model changes.
|
||||
|
||||
* Additionally, model state change events are usually generated if and only if the state really changed (i.e., the new field value differs from the current one). To make a loop, the system needs to infinitely oscillate between two distinct states which is rather unlikely to happen accidentally.
|
||||
|
||||
* Controllers translate low-level events (user's actions in the UI) into high-level ones thus providing sufficient abstraction to allow changing the underlying UI while preserving business logic.
|
||||
|
||||
* As the model data fully defines the system state, it is very convenient for implementing such complex functionality as restoring after a crash, collaborative editing, undoing the last changes, etc.
|
||||
|
||||
* One of the use cases to utilize this property is serializing a model in the form of a URL (or an App Link in the case of mobile applications). Then the URL fully defines the application state, and all state changes are reflected as URL changes. This comes in handy as it allows generating links that open any specific screen in the application.
|
||||
|
||||
In conclusion, MV* frameworks establish a rigid pattern that helps in writing quality code and effectively controlling data flows.
|
||||
|
||||
This rigidity, however, bears disadvantages as well. If we try to *fully* define the component's state, we must include such technicalities as, let's say, all animations being executed (and even the current percentages of execution). Therefore, a model will include all data of all abstraction levels for both hierarchies (semantic and visual) and also the calculated option values. In our example, this means that the model will store, for example, the `currentSelectedOffer` field for `OfferPanel` to use, the list of buttons in the panel, and even the calculated icon URLs for those buttons.
|
||||
|
||||
Such a full model poses a problem not only semantically and theoretically (as it mixes up heterogeneous data in one entity) but also very practically. Serializing such models will be bound to a specific API or application version (as they store all the technical fields, including those not exposed publicly in the API). Changing subcomponent implementation will result in breaking backward compatibility as old links and cached state will be unrestorable (or we will have to maintain a compatibility level to interpret serialized models from past versions).
|
||||
|
||||
Another ideological problem is organizing nested controllers. If there are subordinate subcomponents in the system, all the problems that an MV* approach solved return at a higher level: we have to allow nested controllers either to modify a global model or to call parent controllers. Both solutions imply strong coupling and require exquisite interface design skill; otherwise reusing components will be very hard.
|
||||
|
||||
If we take a closer look at modern UI libraries that claim to employ MV* paradigms, we will learn they employ it quite loosely. Usually, only the main principle that a model defines UI and can only be modified through controllers is adopted. Nested components usually have their own models (in most cases, comprising a subset of the parent model enriched with the component's own state), and the global model contains only a limited number of fields. This approach is implemented in many modern UI frameworks, including those that claim they have nothing to do with MV* paradigms (React, for instance[ref Why did we build React?](https://legacy.reactjs.org/blog/2013/06/05/why-react.html) [ref Mattiazzi, R. How React and Redux brought back MVC and everyone loved it](https://rangle.io/blog/how-react-and-redux-brought-back-mvc-and-everyone-loved-it)).
|
||||
|
||||
All these problems of the MVC paradigm were highlighted by Martin Fowler in his “GUI Architectures” essay.[ref Fowler, M. GUI Architectures](https://www.martinfowler.com/eaaDev/uiArchs.html) The proposed solution is the “Model-View-*Presenter*” framework, in which the controller entity is replaced with a *presenter*. The responsibility of the presenter is not only translating events, but preparing data for views as well. This allows for full separation of abstraction levels (a model now stores only semantic data while a presenter transforms it into low-level parameters that define UI look; the set of these parameters is called the “Application Model” or “Presentation Model” in Fowler's text).
|
||||
|
||||
[]()
|
||||
|
||||
Fowler's paradigm closely resembles the `Composer` concept we discussed in the previous chapter with one notable deviation. In MVP, a presenter is stateless (with possible exceptions of caches and closures) and it only deduces the data needed by views from the model data. If some low-level property needs to be manipulated, such as text color, the model needs to be extended in a manner that allows the presenter to calculate text color based on some high-level model data field. This concept significantly narrows the capability to replace subcomponents with alternate implementations.
|
||||
|
||||
**NB**: let us clarify that the author of this book is not proposing `Composer` as an alternative MV* methodology. The message in the previous chapter is that complex scenarios of decomposing UI components are only solved with artificially-introduced “bridges” of additional abstraction layers. How this bridge is called and what rules it brings are not as important.
|
10
src/en/graphs/sdk-mvc.mermaid
Normal file
@ -0,0 +1,10 @@
|
||||
sequenceDiagram
|
||||
participant M as Model
|
||||
participant V as View
|
||||
participant U as User
|
||||
participant C as Controller
|
||||
M ->> V: Determines
|
||||
V ->> U: Presents
|
||||
U ->> C: Interacts
|
||||
C ->> M: Updates
|
||||
|
12
src/en/graphs/sdk-mvp.mermaid
Normal file
@ -0,0 +1,12 @@
|
||||
sequenceDiagram
|
||||
participant M as Model
|
||||
participant P as Presenter
|
||||
participant V as View
|
||||
participant U as User
|
||||
M ->> P: Data
|
||||
P ->> V: Computed<br/>application state
|
||||
V ->> U: Presents
|
||||
U ->> V: Interacts
|
||||
V ->> P: Interactions
|
||||
P ->> M: Updates
|
||||
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 195 KiB |
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 148 KiB |
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 167 KiB |
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 260 KiB |
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 244 KiB |
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 216 KiB |
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 224 KiB |
BIN
src/img/graphs/sdk-mvc.en.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
src/img/graphs/sdk-mvc.ru.png
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
src/img/graphs/sdk-mvp.en.png
Normal file
After Width: | Height: | Size: 63 KiB |
BIN
src/img/graphs/sdk-mvp.ru.png
Normal file
After Width: | Height: | Size: 75 KiB |
@ -162,8 +162,8 @@ interface IOfferPanel {
|
||||
constructor(
|
||||
searchBox, offerMap, container, options
|
||||
) {
|
||||
this.offerMap = offerMap;
|
||||
super(searchBox, container, options);
|
||||
this.offerMap = offerMap;
|
||||
}
|
||||
onCancelButtonClick() {
|
||||
/* <em> */offerMap.resetCurrentOffer();/* </em> */
|
||||
|
@ -1 +1,100 @@
|
||||
### MV*-фреймворки
|
||||
### [MV*-фреймворки][sdk-mv-frameworks]
|
||||
|
||||
Очевидным способом сделать менее сложными многослойные схемы, подобные описанным в предыдущей главе, является ограничение возможных путей взаимодействия между компонентами. Как мы описывали в главе «[Слабая связность](#back-compat-weak-coupling)», мы могли бы упростить код, если бы разрешили нижележащим субкомпонентам напрямую вызывать методы вышестоящих сущностей. Например, так:
|
||||
|
||||
```typescript
|
||||
class SearchBoxComposer
|
||||
implements ISearchBoxComposer {
|
||||
…
|
||||
protected context: ISearchBox;
|
||||
…
|
||||
public createOrder(offerId: string) {
|
||||
const offer = this.findOfferById(offerId);
|
||||
if (offer !== null) {
|
||||
// Вместо вызова
|
||||
// this.events.emit(
|
||||
// 'createOrder', { offer });
|
||||
/* <em> */this.context
|
||||
.createOrder(offer);/* </em> */
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Кроме того, мы можем убрать `Composer` из цепочки подготовки данных так так, чтобы подчинённые компоненты напрямую получали нужные поля напрямую из `SearchBox`:
|
||||
|
||||
```typescript
|
||||
class OfferListComponent
|
||||
implements IOfferListComponent {
|
||||
…
|
||||
protected context: SearchBox;
|
||||
…
|
||||
constructor () {
|
||||
…
|
||||
// Список заказов напрямую
|
||||
// получает нужные данные
|
||||
// и оповещения из `SearchBox`
|
||||
/* <em> */this.context.events.on(
|
||||
'offerListChange',
|
||||
() => {
|
||||
this.show(
|
||||
context.getOfferList()
|
||||
);
|
||||
}
|
||||
);/* </em> */
|
||||
}
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Тем самым мы утратили возможность подготавливать данные для их показа в списке и, тем самым, возможность встраивать их в любого родителя через соответствующий `Composer`. Но при этом мы сохранили возможность свободно использовать альтернативные реализации компонентов панели, поскольку реакция на взаимодействие пользователя с интерфейсом находится всё ещё находится под контролем `Composer`-а. Как бонус мы получили отсутствие двусторонних взаимодействий между тремя нашими сущностями:
|
||||
|
||||
* субкомпоненты *читают* состояние `SearchBox`-а, но не модифицируют его;
|
||||
|
||||
* `Composer` *получает оповещения* о взаимодействии пользователя с UI, но никак не влияет на сам UI;
|
||||
|
||||
* наконец, `SearchBox` никак не взаимодействует ни с тем, ни с другим — только лишь предоставляет контекст, методы его изменения и нотификации.
|
||||
|
||||
Сделав подобное упрощение, мы фактически получили компонент, следующий методологии «Model-View-Controller» (MVC)[ref MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller): `OfferList` и `OfferPanel` (а также код показа поля ввода) — это различные view, которые непосредственно наблюдает пользователь и взаимодействует с ними; `Composer` — это controller, который получает события от view и модифицирует модель (сам `SearchBox`).
|
||||
|
||||
**NB**: следуя букве подхода, мы должны выделить из `SearchBox` компонент-модель, который отвечает только за данные. Это упражнение мы оставим читателю.
|
||||
|
||||
[]()
|
||||
|
||||
Если мы выберем другие направления редукции полного взаимодействия, мы получим другие варианты MV*-фреймворков (Model-View-Viewmodel, Model-View-Presenter и т.д.) Все они, в конечно счёте, основаны на паттерне «модель», который мы обсудим ниже.
|
||||
|
||||
#### Паттерн «модель»
|
||||
|
||||
Общая черта, объединяющая все MV*-фреймворки — это требование к сущности «модель» (некоторого набора данных) *полностью детерминировано определять* внешний вид и состояние UI-компонента. Изменения в модели порождают и изменения в отображении компонента (или дерева компонентов; в некоторых подходах модель может быть одной на всё приложение и полностью определять весь интерфейс). В то же время визуальные представления не могут влиять на модель напрямую, так как им разрешено взаимодействовать только с контроллером.
|
||||
|
||||
SDK, реализованный в MV*-парадигмах, в теории получает несколько важных свойств:
|
||||
|
||||
* Принудительное разделение уровней абстракции, поскольку постулируется (но далеко не всегда выполняется, см. ниже), что модель содержит *семантичные высокоуровневые данные*.
|
||||
|
||||
* Практически исключены циклы в обработке событий, поскольку контроллер должен реагировать только на взаимодействие пользователя или разработчика с view, но не на изменения модели.
|
||||
|
||||
* Дополнительно, события изменения состояния модели обычно генерируются только в том случае, если состояние действительно изменилось (новое значение поля не совпадает со старым), и, таким образом, чтобы зациклить обработку события, система должна бесконечно осциллировать между двумя разными состояниями, что достаточно сложно допустить случайно.
|
||||
|
||||
* controller транслирует низкоуровневые события (взаимодействие пользователя с view) в высокоуровневые, тем самым предоставляя нужную глубину уровней абстракции и позволяя полностью менять UI при сохранении бизнес-логики;
|
||||
|
||||
* Данные модели полностью определяют состояние системы, что очень удобно при реализации такой сложной функциональности как восстановление приложения в случае сбоя, совместное редактирование, отмена последних действий и т.д.
|
||||
|
||||
* Один из частных случаев использования этого свойства — сериализация модели в виде URL (или App Links в случае мобильных приложений). Тогда URL полностью определяет состояние приложения, и любые изменения состояния отражаются в виде изменений URL. Этот подход чрезвычайно удобен тем, что можно сгенерировать специальные ссылки, открывающие нужный экран в приложении.
|
||||
|
||||
Иными словами, MV*-фреймворки представляют собой жёсткий шаблон, который помогает писать качественный код и не запутываться в потоках данных.
|
||||
|
||||
Однако эта жёсткость влечёт за собой недостатки. Если задаться целью *полностью* описать состояние компонента, то мы обязаны внести в него и такие данные, как выполняющиеся сейчас анимации и даже процент их выполнения. Таким образом, модель обязана будет содержать в себе все данные всех уровней абстракции и, более того, каким-то образом включать в себя две или более иерархии подчинения (по семантической и визуальной иерархиям, а так же, возможно, вычисляемые значения опций). В нашем примере это означает, например, что модель должна будет хранить и `currentSelectedOffer` для `OfferPanel`, и список показанных кнопок, и даже вычисленные значения иконок для кнопок.
|
||||
|
||||
Подобная полная модель представляет собой проблему не только теоретически и семантически (перемешивание в одной сущности разнородных данных), но и в практическом смысле — сериализация таких моделей окажется ограничена рамками конкретной версии API или приложения (поскольку они содержат все внутренние переменные, включая непубличные). Если мы в следующей версии изменим реализацию субкомпонентов, то старые ссылки и закэшированные состояния перестанут работать (либо нам потребуется держать слой совместимости, описывающий, как интерпретировать модели предыдущих версий).
|
||||
|
||||
Другая идеологическая проблема подхода — организация вложенных контроллеров. В системе с дочерними субкомпонентами все те проблемы, которые решил MV*-подход, возвращаются на новом уровне: нам придётся разрешить вложенным контроллерам либо перепрыгивать через уровни абстракции и модифицировать корневую модель, либо вызывать методы контроллеров родительских компонент. Оба подхода влекут за собой сильную связность сущностей и требуют очень аккуратного проектирования, иначе переиспользование компонентов окажется затруднено.
|
||||
|
||||
Если мы внимательно посмотрим на современные UI библиотеки, выполненные в MV*-парадигмах, то увидим, что они следуют парадигме весьма нестрого и лишь заимствуют из неё основной принцип: модель полностью определяет внешний вид компонента, и любые визуальные изменения инициируются через изменение модели контроллером. Дочерние компоненты при этом обычно имеют собственные модели (часто в виде подмножества родительской модели, дополненного собственным состоянием компонента), и в глобальной модели приложения находится только ограниченный набор полей. Этот подход адаптирован во многих современных UI-фреймворках, даже тех, которые от MV*-парадигм открещиваются (например, React[ref Why did we build React?](https://legacy.reactjs.org/blog/2013/06/05/why-react.html) [ref Mattiazzi, R. How React and Redux brought back MVC and everyone loved it](https://rangle.io/blog/how-react-and-redux-brought-back-mvc-and-everyone-loved-it)).
|
||||
|
||||
На эти же проблемы MVC-подхода обращает внимание в своём эссе Мартин Фаулер в своём эссе «Архитектуры GUI»[ref Fowler, M. GUI Architectures](https://www.martinfowler.com/eaaDev/uiArchs.html), и предлагает решение в виде фреймворка Model-View-*Presenter* (MVP), в котором место controller-а занимает сущность-presenter, в обязанности которой входит не только трансляция событий, но и подготовка данных для view, что позволяет разделить уровни абстракции (модель хранит только семантичные данные, описывающие предметную область, presenter — низкоуровневые данные, описывающие UI, в терминологии Файлера — application model или presentation model).
|
||||
|
||||
[]()
|
||||
|
||||
Предложенная Фаулером парадигма во многом схожа с концепцией `Composer`-а, которую мы обсуждали в предыдущей главе, но с одним заметным различием. По мысли Фаулера собственного состояния у presenter-а нет (за исключением, возможно, кэшей и замыканий), он только вычисляет данные, необходимые для показа визуального интерфейса, из данных модели. Если необходимо манипулировать каким-то низкоуровневым свойством, например, цветом текста в интерфейсе, то нужно разработать модель так, чтобы цвет текста вычислялся presenter-ом из какого-то высокоуровневого поля в модели (возможно, искусственно введённого), что ограничивает возможности альтернативных имплементаций субкомпонентов.
|
||||
|
||||
**NB**: на всякий случай уточним, что автор этой книги не предлагает `Composer` как альтернативную MV*-методологию. Идея предыдущей главы состоит в том, что сложные сценарии декомпозиции UI-компонентов решаются *только* искусственным введением мостиков-уровней абстракции. Неважно, как мы этот мостик назовём и какие правила для него придумаем.
|
@ -1,222 +0,0 @@
|
||||
### Декомпозиция UI-компонентов. MV*-подходы
|
||||
|
||||
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента (внешнего вида, бизнес-логики или поведения) приводит к кратному усложнению интерфейсов. Рассмотрим имплементацию функциональности создания заказа по клику на соответствующую кнопку. Внутри нашего класса `SearchBox` мы могли бы написать такой код:
|
||||
|
||||
```
|
||||
class SearchBox {
|
||||
// Список предложений
|
||||
public offerList: OfferList;
|
||||
// Панель отображения
|
||||
// выбранного предложения
|
||||
public offerPanel: OfferPanel;
|
||||
|
||||
// Инициализация
|
||||
init() {
|
||||
// Подписываемся на клик по
|
||||
// предложению
|
||||
this.offerList.on(
|
||||
'click',
|
||||
(event) => {
|
||||
this.selectedOffer =
|
||||
event.target.offer;
|
||||
this.offerPanel.show(
|
||||
this.selectedOffer
|
||||
);
|
||||
});
|
||||
this.offerPanel.on(
|
||||
'click', () => {
|
||||
this.createOrder();
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Ссылка на текущее выбранное предложение
|
||||
private selectedOffer: Offer;
|
||||
// Создаёт заказ
|
||||
private createOrder() {
|
||||
const order = await api
|
||||
.createOrder(this.selectedOffer);
|
||||
// Действия после создания заказа
|
||||
…
|
||||
}
|
||||
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
В данном фрагменте кода налицо полный хаос с уровнями абстракции, и заодно сделано множество неявных предположений:
|
||||
* единственный способ выбрать предложение — клик по элементу списка;
|
||||
* единственный способ сделать заказ — клик внутри элемента «панель предложения»;
|
||||
* заказ не может быть сделан, если предложение не было предварительно выбрано.
|
||||
|
||||
Такой код вполне может работать, если вы не собираетесь предоставлять возможно что-то кастомизировать в бизнес-логике или поведении компонента — потому что с таким кодом что-либо кастомизировать невозможно. Единственный работающий способ выбрать какое-то предложение для показа — эмулировать бросание события `'click'` в списке предложений. При этом в панель предложения невозможно добавить никаких кликабельных элементов, поскольку любой клик рассматривается как создание заказа. Если разработчик захочет, например, чтобы сделать заказ можно было свайпом по предложению в списке, то ему придётся:
|
||||
* по свайпу сгенерировать фиктивное событие `'click'` на `offerList`,
|
||||
* переопределить метод `offerPanel.show` так, чтобы он показывал панель с кнопкой где-то в невидимой части экрана и тут же генерировал `'click'` на этой фантомной кнопке.
|
||||
|
||||
Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым?
|
||||
|
||||
Первый очевидный шаг заключается в том, чтобы `SearchBox` перестал реагировать на низкоуровневые события типа `click`, а стал только лишь контекстом для нижележащих сущностей и работал в терминах своего уровня абстракции. А для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
|
||||
|
||||
Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из четырёх состояний:
|
||||
1. Пуст [это состояние, а также порядок перехода из него в другие состояния нас в рамках данной главы не интересует].
|
||||
2. Показан список предложений по запросу.
|
||||
3. Показано конкретное предложение пользователю.
|
||||
4. Создаётся заказ.
|
||||
|
||||
Допустимые переходы в рамках состояний 2-4 таковы: 2 → 3, 3 → 3 (выбрано другое предложение), 3 → 4, 3 → 2, 4 → 3. Соответствующие интерфейсы должны быть и предъявлены субкомпонентам: они должны нотифицировать об одном из переходов. Соответственно, `SearchBox` должен ждать не события `click`, а событий типа `selectOffer` и `createOrder`:
|
||||
|
||||
```
|
||||
this.offerList.on(
|
||||
'selectOffer',
|
||||
(event) => {
|
||||
this.selectedOffer =
|
||||
event.offer;
|
||||
this.offerPanel.show(
|
||||
this.selectedOffer
|
||||
);
|
||||
});
|
||||
this.offerPanel.on(
|
||||
'createOrder', () => {
|
||||
this.createOrder();
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Возможности по кастомизации субкомпонентов расширились: теперь нет нужды эмулировать `'click'` для выбора предложения, есть семантический способ сделать это через событие `selectOffer`; аналогично, какие события обрабатывает панель предложения для бросания события `createOrder` — больше не забота самого `SearchBox`-а.
|
||||
|
||||
Однако описанный выше пример — с заказом свайпом по элементу списка — всё ещё реализуется «костыльно» через открытие невидимой панели, поскольку вызов `offerPanel.show` всё ещё жёстко вшит в сам `SearchBox`. Мы можем сделать ещё один шаг, и сделать связность ещё более слабой: пусть `SearchBox` не вызывает напрямую методы субкомпонентов, а только извещает об изменении собственного состояния:
|
||||
|
||||
```
|
||||
this.offerList.on(
|
||||
'selectOffer',
|
||||
(event) => {
|
||||
this.selectedOffer =
|
||||
event.offer;
|
||||
this.state = 'offerSelected';
|
||||
this.emit('stateChange', {
|
||||
selectedOffer: this.
|
||||
selectedOffer
|
||||
});
|
||||
});
|
||||
this.offerPanel.on(
|
||||
'createOrder', () => {
|
||||
this.state = 'orderCreating';
|
||||
this.emit('stateChange');
|
||||
const order = await api.createOrder();
|
||||
this.state = 'orderCreated';
|
||||
this.emit('stateChange', {
|
||||
order
|
||||
});
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Тем самым мы фактически предоставляем доступ к описанному нами автомату состояний, и даём альтернативным имплементациям полную свободу действий. `offerPanel` не обязана «открываться», если такого состояния в ней нет и может просто проигнорировать изменения состояния на `offerSelected`. Наконец, мы могли бы полностью абстрагироваться от нижележащего UI, если бы прослушивали события `selectOffer` и `createOrder` не на конкретных субкомпонентах `offerList` и `offerPanel`, а позволили бы любому актору присылать их:
|
||||
|
||||
```
|
||||
this.onMessage((event) => {
|
||||
switch (event.type) {
|
||||
case 'selectOffer':
|
||||
this.selectedOffer =
|
||||
event.offer;
|
||||
this.state = 'offerSelected';
|
||||
this.emit('stateChange', {
|
||||
selectedOffer: this.
|
||||
selectedOffer
|
||||
});
|
||||
break;
|
||||
case 'createOrder':
|
||||
this.state = 'orderCreating';
|
||||
this.emit('stateChange');
|
||||
const order = await api.createOrder();
|
||||
this.state = 'orderCreated';
|
||||
this.emit('stateChange', {
|
||||
order
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Это решение выглядит достаточно общим и в своём роде идеальным (`SearchBox` сведён к своей чистой функциональности конечного автомата с хранением небольшого набора данных в виде `selectedOffer` и `order`), но при этом является, увы, очень ограниченно применимым:
|
||||
* он содержит очень мало функциональности, которая реально помогала бы программисту в его работе;
|
||||
* он заставляет программиста досконально разобраться в механике работы каждого субкомпонента и имплементировать её полностью, если необходима альтернативная реализация.
|
||||
|
||||
Или, если сформулировать другими словами, наш `SearchBox` не «перекидывает мостик», не сближает два программных контекста (высокоуровневый `SearchBox` и низкоуровневую имплементацию, скажем, `offerPanel`-а). Пусть, например, разработчик хочет сделать не сложную замену UX, а очень простую вещь: сменить дизайн кнопки «Заказать» на какой-то другой. Проблема заключается в том, что альтернативная кнопка не бросает никаких событий `'createOrder'` — она генерирует самый обычный `'click'`. А значит, разработчику придётся написать эту логику самостоятельно.
|
||||
|
||||
```
|
||||
class MyOfferPanel implements IOfferPanel {
|
||||
protected parentSearchBox;
|
||||
|
||||
render() {
|
||||
this.button = new CustomButton();
|
||||
this.button.on('click', () => {
|
||||
this.parentSearchBox.notify(
|
||||
'createOrder'
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
В нашем примере это не выглядит чем-то сложным (но это только потому, что наш конечный автомат очень прост и содержит очень мало данных), но трудно не согласиться с тем, что необходимость писать подобный код совершенно неоправдана: почти любая альтернативная реализация кнопки генерирует именно событие `'click'`.
|
||||
|
||||
Другая очень большая проблема состоит в том, что с подобным «плоским» интерфейсом (любой актор может отправить события `selectOffer` / `createOrder`) мы фактически просто перевернули дырявую изоляцию абстракций с ног на голову: раньше `SearchBox` должен был знать о низкоуровневых объектах и их поведении — теперь низкоуровневые объекты должны знать о логике работы `SearchBox`. Такая перевёрнутая пирамида лучше прямой (нам хотя бы не приходится эмулировать клики на скрытых объектах), но далеко не идеальна с точки зрения архитектуры конкретного приложения. Написанный в этой парадигме код практически невозможно использовать повторно (приведённый выше пример `MyOfferPanel` нельзя использовать для каких-либо других целей, потому что действие по клику на кнопку всегда одно и то же — создание заказа), что приводит к необходимости копипастинга кода со всеми вытекающими проблемами.
|
||||
|
||||
Мы можем решить и эту проблему, если искусственным образом «перекинем мостик» — введём дополнительный уровень абстракции (назовём его, скажем, «арбитром»), который позволяет транслировать контексты:
|
||||
|
||||
```
|
||||
class Arbiter implements IArbiter {
|
||||
protected currentSelectedOffer;
|
||||
|
||||
constructor(
|
||||
searchBox: ISearchBox,
|
||||
offerPanel: IOfferPanel
|
||||
) {
|
||||
// Панель показа предложений
|
||||
// должна быть каким-то образом
|
||||
// привязана к арбитру
|
||||
offerPanel.setArbiter(this);
|
||||
|
||||
searchBox.on('stateChange', (event) => {
|
||||
// Арбитр переформулирует события
|
||||
// `searchBox` в требования к
|
||||
// панели предложений.
|
||||
|
||||
// Если выбрано новое предложение
|
||||
if (this.currentSelectedOffer !=
|
||||
event.offer) {
|
||||
// Запоминаем предложение
|
||||
this.currentSelectedOffer =
|
||||
event.offer;
|
||||
// Даём команду на открытие панели
|
||||
this.emit('showPanel', {
|
||||
content: this.generateOfferContent(
|
||||
this.currentSelectedOffer
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Если же от кнопки создания заказа
|
||||
// пришло событие 'click'
|
||||
this.offerPanel.createOrderButton.on(
|
||||
'click',
|
||||
() => {
|
||||
this.searchBox.notify('createOrder');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected generateOfferContent(offer) {
|
||||
// Формирует контент панели
|
||||
…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Таким образом, мы убрали сильную связность компонентов: можно отдельно переопределить класс кнопки создания заказа (достаточно, чтобы он генерировал событие `'click'`) и даже саму панель целиком. Вся *специфическая* логика, относящаяся к работе панели показа приложений, теперь собрана в арбитре — саму панель можно переиспользовать в других частях приложения.
|
||||
|
||||
Более того, мы можем пойти дальше и сделать два уровня арбитров — между `SearchBox` и панелью предложений и между панелью предложений и кнопкой создания заказа. Тогда у нас пропадёт требование к `IOfferPanel` иметь поле `createOrderButton`, и мы сможем свободно комбинировать разные варианты: альтернативный способ подтверждения заказа (не по кнопке), альтернативная реализация панели с сохранением той же кнопки и т.д.
|
||||
|
||||
Единственной проблемой остаётся потрясающая сложность и неочевидность имплементации такого решения со всеми слоями промежуточных арбитров. Таков путь.
|
10
src/ru/graphs/sdk-mvc.mermaid
Normal file
@ -0,0 +1,10 @@
|
||||
sequenceDiagram
|
||||
participant M as Model
|
||||
participant V as View
|
||||
participant U as User
|
||||
participant C as Controller
|
||||
M ->> V: Определяет
|
||||
V ->> U: Представляет
|
||||
U ->> C: Взаимодействует
|
||||
C ->> M: Обновляет
|
||||
|
12
src/ru/graphs/sdk-mvp.mermaid
Normal file
@ -0,0 +1,12 @@
|
||||
sequenceDiagram
|
||||
participant M as Model
|
||||
participant P as Presenter
|
||||
participant V as View
|
||||
participant U as User
|
||||
M ->> P: Данные
|
||||
P ->> V: Вычисленное<br/>состояние приложения
|
||||
V ->> U: Представляет
|
||||
U ->> V: Взаимодействует
|
||||
V ->> P: Действия
|
||||
P ->> M: Обновления
|
||||
|
1645
src/scripts/mermaid.min.js
vendored
@ -278,6 +278,7 @@ export const templates = {
|
||||
graphHtmlTemplate: (graph) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="../docs/assets/fonts.css"/>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
@ -285,32 +286,25 @@ export const templates = {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: local-monospace;
|
||||
src: url(../src/fonts/RobotoMono-Regular.ttf);
|
||||
}
|
||||
|
||||
.actor-line {
|
||||
stroke: lightgray;
|
||||
opacity: 0.2;
|
||||
|
||||
* {
|
||||
font-family: local-sans !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="mermaid">${graph
|
||||
<pre class="mermaid">${graph
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</div>
|
||||
.replace(/>/g, '>')}</pre>
|
||||
<script src="../src/scripts/mermaid.min.js"></script>
|
||||
<script>mermaid.initialize({
|
||||
theme: 'neutral',
|
||||
fontFamily: 'local-monospace, monospace',
|
||||
fontSize: 14,
|
||||
fontSize: '16pt',
|
||||
sequence: {
|
||||
diagramMarginX: 20,
|
||||
diagramMarginY: 10,
|
||||
actorMargin: 5,
|
||||
mirrorActors: false,
|
||||
actorMargin: 20,
|
||||
mirrorActors: true,
|
||||
showSequenceNumbers: true
|
||||
}
|
||||
});</script>
|
||||
|