1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-02-22 18:42:09 +02:00

Блокировки

This commit is contained in:
Sergey Konstantinov 2023-03-21 23:17:47 +02:00
parent 21b3f5470a
commit cf9643fd4b
4 changed files with 383 additions and 238 deletions

View File

@ -16,7 +16,7 @@
С одной стороны нам может показаться, что наш UI — это просто надстройка над клиент-серверным `search`, визуализирующая результаты поиска. Увы, это не так; перечислим проблемы, с которыми мы никогда не сталкивались при разработке API без визуальных компонент.
##### Захват разделяемого ресурса
##### Разделяемые ресурсы
Предположим, что мы хотим разрешить разработчику подставить в наш `SearchBox` свой поисковый запрос — например, чтобы дать возможность разместить в приложении баннер «найти лунго рядом со мной», по нажатию на который происходит переход к нашему компоненту с введённым запросом «лунго». Программно, разработчику потребуется показать соответствующий экран и вызвать метод `SeachBox.search`.
@ -41,7 +41,7 @@
С точки зрения разработчика SDK это означает, что класс `Button` должен позволять независимо переопределять и внешний вид кнопки, и реакцию на действия пользователя, и элементы UX.
##### Двойная иерархия подчинения сущностей
##### Множественная иерархия подчинения сущностей
Предположим, что разработчик хочет обогатить дизайн списка предложений иконками сетей кофеен. Если изображение известно, оно должно быть показано всюду, где происходит работа с предложением конкретной кофейни.
@ -53,4 +53,23 @@
Возникает вопрос: если выбрано предложение сетевой кофейни, какая иконка должна быть на кнопке подтверждения заказа — та, что унаследована из данных предложения (логотип кофейни) или та, что унаследована от «рода занятий» самой кнопки? Элемент управления «создать заказ», таким образом, встроен в две иерархии сущностей (по визуальному отображению и по данным) и в равной степени наследует обоим.
Можно легко продемонстрировать, как пересечение нескольких предметных областей в одном объекте быстро приводит к крайне запутанной и неочевидной логике. Например, представим себе следующую функциональность: если в данных предложения есть поле `checkoutButtonIconUrl`, то иконка будет взята из этого поля — вполне разумное соглашение, если мы хотим позволить выставлять на кнопке иконку сети кофеен, в которой делается заказ. Но тогда разработчик сможет её кастомизировать и показывать не сеть кофеен, а какую-то свою фирменную иконку для действия «заказ», подменив в данных поле `checkoutButtonIconUrl` для каждого результата поиска:
```
const searchBox = new SearchBox({
// Предположим, что мы разрешили
// переопределять поисковую функцию
searchApi: function (params) {
const res = await api.search(params);
res.forEach((item) {
item.checkoutButtonIconUrl =
<URL нужной иконки>;
});
return res;
}
}
```
*Формально* этот подход корректен и никаких рекомендаций не нарушает. Но с точки зрения связности кода, его читабельности — это полная катастрофа, поскольку следующий разработчик, которого попросят заменить иконку *кнопке*, очень вряд ли пойдёт читать код *функции поиска предложений*.
С решением вышеуказанных проблем, увы, всё обстоит очень сложно. В следующих главах мы рассмотрим паттерны проектирования, позволяющие в том числе разделить области ответственности составляющих компонента; но очень важно уяснить одну важную мысль: полное разделение, то есть разработка функционального SDK+UI, дающего разработчику свободу в переопределении и внешнего вида, и реакции на действия, и UX — невероятно дорогая в разработке задача, которая в лучшем случае утроит вашу иерархию абстракций. Универсальный совет здесь ровно один: *три раза подумайте прежде чем предоставлять возможность программной настройки UI-компонентов*. Хотя цена ошибки дизайна программных интерфейсов для UI-библиотек, как правило, не очень высока (вряд ли клиент потребует рефанд из-за неработающей анимации нажатия кнопки), плохо структурированный, нечитабельный и глючный SDK вряд ли может рассматриваться как сильное клиентское преимущество вашего API.

View File

@ -1,201 +1,72 @@
### Декомпозиция UI-компонентов. MV*-подходы
### Вычисляемые свойства
Сложность решения проблем, описанных в предыдущей главе, заключается прежде всего в том, что почти любой объект вложен в две (а то и три) различные иерархии сущностей. Если мы возьмём объект «кнопка создания заказа», то она:
* вложена в иерархию визуальных сущностей: экран → `SearchBox` → компонент «просмотр предложения» → компонент «панель действий»;
* привязана к некоторой сущности внутри иерархии данных (результату поиска);
* как программный объект является наследником каких-то базовых классов, отвечающих за UX (системного объекта типа «кнопка», который является реализацией системного интерфейса «интерактивный элемент» и так далее).
Наличие множественных линий наследования усложняет кастомизация компонентов, поскольку подразумевает, что они могут наследовать важные свойства по любой из вертикалей (иконка кнопки может быть задана и в визуальных настройках компонента, и в данных, и в родительском классе) и, более того, любое поддерево иерархии может быть вообще полностью заменено (например, вместо использования системной кнопки мы можем отрисовать её напрямую как графический примитив). При этом корректность взаимодействия с нашей кастомизированной кнопкой по всем остальным иерархиям должна сохраняться:
* при изменении дерева визуальных объектов (например, если мы сделаем свою отдельную всегда видимую кнопку «заказать» для каждого элемента в списке) ничего не должно измениться ни в работе с данными, ни в UX кнопки;
* если разработчик подменит источник данных (например, реализовав свой алгоритм поиска), кнопка должна продолжать работать как в смысле UX (реагировать на действия пользователя) так и в плане бизнес-логики (создавать заказ);
* если будет выбрана альтернативная реализация самого рендеринга кнопки, с точки зрения обработки данных, бизнес-логики и UX ничего измениться не должно.
Как мы помним из главы «Разделение уровней абстракции», объединение в одном объекте двух-трёх-четырёх разноплановых контекстов — чрезвычайно деструктивное решение, которое приводит к «перепрыгиваниям» по областям ответственности и, как следствие, переусложнённому коду, в рамках которого разработчику придётся манипулировать множеством самых разных сущностей из самых отдалённых фрагментов документации. Увы, с визуальными компонентами у нас просто нет другого выбора: наша кнопка действительно является таким многомерным контекстом, если мы хотим дать возможность гибко настраивать её внешний вид, UX и бизнес-логику. Таков путь.
Можно легко продемонстрировать, как пересечение нескольких предметных областей в одном объекте быстро приводит к крайне запутанной и неочевидной логике. Например, представим себе следующую функциональность: если в данных предложения есть поле `checkoutButtonIconUrl`, то иконка будет взята из этого поля — вполне разумное соглашение, если мы хотим позволить выставлять на кнопке иконку сети кофеен, в которой делается заказ. Но тогда разработчик сможет её кастомизировать и показывать не сеть кофеен, а какую-то свою фирменную иконку для действия «заказ», подменив в данных поле `checkoutButtonIconUrl` для каждого результата поиска:
Вернёмся к проблеме, которую мы описали в предыдущей главе. Пусть у нас имеется кнопка, которая получает одно и то же свойство `iconUrl` по двум вертикалям — из данных и из настроек отображения:
```
const searchBox = new SearchBox({
// Предположим, что мы разрешили
// переопределять поисковую функцию
searchApi: function (params) {
const res = await api.search(params);
res.forEach((item) {
item.checkoutButtonIconUrl =
<URL нужной иконки>;
});
return res;
}
}
```
*Формально* этот подход корректен и никаких рекомендаций не нарушает. Но с точки зрения связности кода, его читабельности — это полная катастрофа, поскольку следующий разработчик, которого попросят заменить иконку *кнопке*, очень вряд ли пойдёт читать код *функции поиска предложений*.
Выход из этого логического тупика мы подробно разбирали в главах раздела «Обратная совместимость», посвящённых сильной и слабой связности. Единственный способ «перебросить мостик» между всеми контекстами, собранными в одном UI-компоненте — это декомпозировать компонент на несколько сущностей, каждая из которых отвечает за определённый уровень абстракции, и организовать взаимодействие между ними на принципах слабой связности.
Идея декомпозиции визуальных объектов, разумеется, придумана не нами и формализована во множестве методологий — в первую очередь, MV*-фреймворках (Model—View—Controller, Model—View—Presenter и т.д.) Все они, в конечно счёте, предлагают реализовать следующий подход:
1. Выделить сущность *модель*, отвечающую только за данные, поверх которых построен компонент.
2. Потребовать, чтобы внешний вид и поведение компонента полностью определялись его моделью.
3. Построить механизм изменений внешнего вида компонента через изменение модели.
Разница между MV*-подходами заключается в разрешённых направлениях взаимодействия между составляющими.
(схемы MVC, MVP, MVVP)
MV*-подход выглядит вполне простым и понятным в случае статических объектов, но, увы, становится гораздо менее удобным в случае интерактивных, поскольку оставляет открытым вопрос о том, где и как должно храниться *состояние* компонента (например, «происходит ли сейчас анимация нажатия кнопки»). Фактически, это тоже часть данных, на которых строится компонент, но редко часть модели (в том числе потому, что это явное нарушение принципа изоляции уровней абстракции). Для компонентов, обладающих сложным (и асинхронно изменяемым) внутренним состоянием мы должны пойти ещё дальше, и декомпозировать также данные, поверх которых он строится. UI-компоненты, как правило, оперируют:
* данными, полученными из внешних источников, и связанные с бизнес-логикой (в случае нашей кнопки — это предложение, которое по нажатию конвертируется в заказ);
* данными, унаследованными по различным иерархиям (например, из визуальной иерархии кнопка получает положение своего контекста на экране);
* данными, которые описывают её собственное внутреннее состояние (например, состояние анимации нажатия кнопки).
Если мы позволяем кастомизировать всё вышеперечисленное, кнопка превращается в очень сложный объект:
```
class Button extends SystemButton {
// Модель данных
public model: IButtonModel;
// Состояние кнопки
public state: IButtonState;
// Отображение кнопки
public view: IButtonView;
}
// Каждый из суб-компонентов
// должен в свою очередь
// предоставлять функциональность:
interface IButtonModel {
// доступа к данным
data: IDataAccessor,
// генерации событий изменения
// данных
events: IEventEmitter,
// вышестоящий контекст
parentContext: IContext
}
```
Напомним, префикс `I` мы используем, чтобы индицировать *интерфейсы* (абстрактные классы). В данном случае подразумевается, что кнопка должна уметь работать с любой моделью, состоянием и/или отображением, лишь бы они выполняли требуемый программный контракт. Именно благодаря этому требованию мы можем говорить о полной замене одного из компонентов кнопки альтернативной реализацией.
**NB**: чтобы замена компонента работала, должен существовать способ её осуществить — например, через функции-билдеры.
```
class Button {
constructor() {
this.view = this.buildView();
}
}
// Разработчик может переопрелить
// метод построения отображения
class MyButton {
buildView() {
return new MyCustomView();
}
}
```
Чтобы вся эта система заработала, мы должны добавить к ней события: каждый из компонентов генерирует определённые события и обрабатывает события других компонентов. Например, реакцию на нажатие мы можем описать так:
```
button.events.on(
// Получаем событие
// нажатия кнопки
'press',
(event) => {
// Пытаемся заблокировать
// кнопку, чтобы не допустить
// отправки двух запросов
// на создание заказа
button.state.lock().then(
(lock) => {
// В случае успеха
// создаём заказ
const order = await api
.createOrder(
button.data.offer
);
lock.release();
this.events.emit(
'orderCreated',
order
);
},
(error) => {
// Если блокировку
// не удалось получить,
// как-то обработать
// ошибку
}
);
const button = new Button({
model: {
iconUrl: <URL#1>
}
);
button.view.options.iconUrl = <URL#2>;
```
В свою очередь, сущность view должен опираться на состояние кнопки и события её изменения:
При этом мы можем легко представить себе, что по обеим иерархиям свойство `iconUrl` было получено от кого-то из родителей — например, данные могут быть сгруппированы по бренду, и иконка будет задана для всей группы. Также возможно, что мы разрешим переопределять иконку вообще всем кнопкам через задание свойств по умолчанию для всего SDK.
В этой ситуации у нас возникает вопрос: каким образом задавать приоритеты, какой из возможных вариантов опции будет выбран.
Современные графические SDK в зависимости от выбранного подхода делятся на две категории: построенные по образу и подобию CSS и все остальные.
#### Приоритеты наследования
Простой подход «в лоб» к этому вопросу — либо зафиксировать приоритеты в точности (скажем, заданное в опциях отображения значение всегда важнее заданного в модели, и они оба всегда важнее любого унаследованного свойства), либо попросту запретить наследование и заставить разработчика копировать все нужные ему свойства. То есть в нашем примере сам разработчик должен написать что-то типа:
```
class ButtonView {
public parent: Button;
protected initialize () {
this.parent.state.events.on(
'lock',
() => {
// Вносит изменения во
// внешний вид кнопки
}
);
// аналогично для 'unlock'
// Объект view должен
// генерировать событие 'press',
// только если он не заблокирован
this.element.onTouch(
(event) => {
if (!this.parent.state.locked) {
this.events.emit('press');
}
}
)
}
const button = new Button(…);
if (button.model.checkoutButtonIconUrl) {
button.view.iconUrl =
button.model.checkoutButtonIconUrl;
} else if (
button.model.parentCategory.iconUrl
) {
button.view.iconUrl =
button.model.parentCategory.iconUrl;
}
```
Важно, что при этом каждая составляющая должна оперировать сущностями своей области ответственности, как показано в примере выше: объект View получает от системы событие о том, что над ним произошло касание пользователя, но превращает его в событие 'press' только если UI не заблокирован, таким образом выступая «фильтром», который переформулирует низкоуровневые системные события так, чтобы они стали *ближе* к бизнес-логике.
(В достаточно сложном API оба этих подхода приведут к одинаковому результату. Если приоритеты фиксированы, то это рано или поздно приведёт к необходимости написать код, подобный вышеприведённому, так как разработчик не сможет добиться нужного результата иначе.)
Заменив получение и генерацию событий в некоторых парах компонентов на вызовы методов родительского контекста, мы можем редуцировать систему до MV*- или, скажем, Redux-подобного фреймворка. В рамках MVC мы написали бы следующий код:
Достоинства простого решения очевидны — разработчик сам имплементирует ту логику, которая ему нужна. Недостатки тоже очевидны — во-первых, это лишний код; во-вторых, разработчик быстро запутается в том, какие правила он реализовал и почему.
Альтернативный подход — это предоставить возможность декларативно задавать правила, каким образом для конкретной кнопки определяется её иконка, либо напрямую в виде CSS, либо предоставив какие-то похожие механизмы типа:
```
class ButtonView {
protected model;
protected controller;
protected initialize () {
this.element.onTouch(
function (event) {
this.controller.onPress();
}
)
this.model.onChange(
function () {
// Перерисовывает кнопку
// в соответствие с моделью
}
)
}
}
class OrderButtonController () {
protected writeableModel;
public onPress () {
this.writeableModel.set('locked', true);
const order = await api
.createOrder(
this.model.offer
);
this.writeableModel.set('order', order);
this.writeableModel.set('locked', false);
}
}
api.options.addRule(
// Читать примерно так: кнопки
// типа `checkout` значение `iconUrl`
// берут из поля `iconUrl` своей модели
'button[@type=checkout].iconUrl',
'model.iconUrl'
)
```
Как можно заметить, получившийся код полностью эквивалентен предыдущему варианту с точностью до замены событий на вызовы функций. Это неслучайно — смысл MV*-фреймворков как раз заключается в том, чтобы интерфейсно *запретить* определённые направления распространения событий (скажем, только контроллер может изменять состояние модели), тем самым снизив общую сложность взаимодействия.
Думаем, излишне уточнять, что разработка своей CSS-подобной системы — огромное количество работы, и к тому же изобретение велосипеда. Использование настоящего CSS, если оно возможно — более разумный подход, однако и он зачастую совершенно избыточен, и только весьма ограниченный набор возможностей системы будет реально использоваться разработчиками.
#### Вычисленные значения
Очень важно не забыть предоставить разработчику не только способы задать приоритеты параметров, но и возможность узнать, какой же из вариантов значения был применён. Для этого мы должны разделить заданные и вычисленные значения:
```
// Задаём значение в процентах
button.view.width = '100%';
// Получаем реально применённое
// значение в пикселях
button.view.computedStyle.width;
```
Не менее важна и возможность получать оповещения об изменении вычисленных значений.

View File

@ -1,71 +1,146 @@
### Вычисляемые свойства
### Разделяемые сущности и асинхронные блокировки
Наличие множественных линий наследования усложняет кастомизация компонентов, поскольку подразумевает, что он может наследовать важные свойства по любой из вертикалей (иконка кнопки может быть задана и в визуальных настройках компонента, и в данных, и в родительском классе) и, более того, любое поддерево иерархии может быть вообще полностью заменено (например, вместо использования системной кнопки мы можем отрисовать её напрямую как графический примитив). При этом корректность взаимодействия с нашей кастомизированной кнопкой по всем остальным иерархиям должна сохраняться:
* при изменении дерева визуальных объектов (например, если мы сделаем свою отдельную всегда видимую кнопку «заказать» для каждого элемента в списке) ничего не должно измениться ни в работе с данными, ни в UX кнопки;
* если разработчик подменит источник данных (например, реализовав свой алгоритм поиска), кнопка должна продолжать работать как в смысле UX (реагировать на действия пользователя) так и в плане бизнес-логики (создавать заказ);
* если будет выбрана альтернативная реализация самого рендеринга кнопки, с точки зрения обработки данных, бизнес-логики и UX ничего измениться не должно.
Вернёмся к проблеме, которую мы обрисовали в главе «UI-компоненты». Пусть у нас имеется кнопка, которая получает одно и то же свойство `iconUrl` по двум вертикалям — из данных и из настроек отображения, причём в обоих вертикалях оно может наследоваться от какого-то из родителей:
Другой важный паттерн, который мы должны рассмотреть — это доступ к общим ресурсам. Предположим, что в нашем учебном приложении разработчик решил открывать экран предложения с анимацией. Для этого он воспользуется объектом offerPanel, который мы реализуем в составе searchBox:
```
const button = new Button({
model: {
iconUrl: <URL#1>
// По событию выбора предложения
searchBox.on('selectOffer', (offer) {
// Показываем выбранное предложение
// в панели, но размещаем её
// за границей экрана
searchBox.offerPanel.render(offer, {
left: screenWidth
});
// Анимируем положение панели
searchBox.offerPanel.view.animate(
'left', 0, '1s'
);
});
```
Возникает вопрос: а что должно произойти, если, например, пользователь пытается прокрутить список предложений, если сейчас происходит анимация панели? Логически, мы должны эту операцию запретить. Мы можем изменить *состояние* компонента, выставив флаг «происходит анимация»:
```
searchBox.on('selectOffer', (offer) {
searchBox.offerPanel.render(offer, {
left: screenWidth
});
searchBox.state.isAnimating = true;
await searchBox.offerPanel
.view.animate('left', 0, '1s');
searchBox.state.isAnimating = false;
});
searchBox.on('scroll', (event) => {
// Если сейчас происходит анимация
if (searchBox.state.isAnimating) {
// Запретить действие
return false;
}
);
button.view.options.iconUrl = <URL#2>;
});
```
При этом мы можем легко представить себе, что по обеим иерархиям свойство `iconUrl` было получено от кого-то из родителей — например, данные могут быть сгруппированы по бренду, и иконка будет задана для всей группы. Также возможно, что мы разрешим переопределять иконку вообще всем кнопкам через задание свойств по умолчанию для всего SDK.
Но этот код очень плох по множеству причин:
* непонятно, как его модифицировать, если у нас появятся разные виды анимации, причём некоторые из них будут требовать блокировки прокрутки, а некоторые — нет;
* явно нарушена изоляция абстракций — логическое событие прокрутки списка оказывается привязанным к низкоуровневому состоянию «идёт анимация»;
* сложно предсказать, что произойдёт, если каким-то образом (например, программно) будет открыто другое предложение — оба процесса будут «драться» за один флаг и один объект
* если при выполнении анимации произойдёт какая-то ошибка, флаг `isAnimating` не будет сброшен, и прокрутка будет заблокирована навсегда.
В этой ситуации у нас возникает вопрос: каким образом задавать приоритеты, какой из возможных вариантов опции будет выбран.
Современные графические SDK в зависимости от выбранного подхода делятся на две категории: построенные по образу и подобию CSS и все остальные.
#### Приоритеты наследования
Простой подход «в лоб» к этому вопросу — либо зафиксировать приоритеты в точности (скажем, заданное в опциях отображения значение всегда важнее заданного в модели, и они оба всегда важнее любого унаследованного свойства), либо попросту запретить наследование и заставить разработчика копировать все нужные ему свойства. То есть в нашем примере сам разработчик должен написать что-то типа:
Корректное решение первых двух проблемы — это абстрагирование от самого факта анимации и переформулирование проблемы в высокоуровневых терминах. Почему мы запрещаем прокрутку во время анимации? Потому что появление панели предложения как бы «захватывает» эту область экрана. Пользователь не может работать с другими объектами в этой области во время анимации (или, скорее, нет разумного сценария использования, при котором пользователю может понадобиться это делать). Следовательно, именно такой флаг нам и надо объявить — признак «разделяемая область на экране заблокирована»:
```
const button = new Button(…);
if (button.model.iconUrl) {
button.view.iconUrl = button.model.iconUrl;
} else if (
button.model.parentCategory.iconUrl
) {
button.view.iconUrl =
button.model.parentCategory.iconUrl;
searchBox.on('selectOffer', (offer) {
searchBox.offerPanel.render(offer, {
left: screenWidth
});
searchBox.state.
isInnerAreaLocked = true;
await searchBox.offerPanel
.view.animate('left', 0, '1s');
searchBox.state.
isInnerAreaLocked = false;
});
searchBox.on('scroll', (event) => {
// Если сейчас происходит анимация
if (searchBox.state
.isInnerAreaLocked) {
// Запретить действие
return false;
}
});
```
Такой подход улучшает семантику операций, но не помогает с проблемами параллельного доступа и ошибочно неснятых флагов. Чтобы решить их, нам нужно сделать ещё один шаг: не просто ввести флаг, но и процедуру его *захвата* (вполне классическим образом по аналогии с управлением разделяемыми ресурсами в системном программировании):
```
try {
const lock = await searchBox
.state.acquireLock('innerArea', '2s');
await searchBox.offerPanel
.view.animate('left', 0, '1s');
lock.release();
} catch (e) {
// Какая-то логика обработки
// невозможности захвата ресурса
}
```
(В достаточно сложном API оба этих подхода приведут к одному результату. Если приоритеты фиксированы, то это рано или поздно приведёт к необходимости написать код, подобный вышеприведённому, так как разработчик не сможет добиться нужного результата иначе.)
**NB**: вторым параметром в `acquireLock` мы передали время жизни блокировки — 2 секунды. Если в течение двух секунд блокировка не снята, она будет отменена автоматически.
Достоинства простого решения очевидны — разработчик сам имплементирует ту логику, которая ему нужна. Недостатки тоже очевидны — во-первых, это лишний код; во-вторых, разработчик быстро запутается в том, какие правила он реализовал и почему.
Альтернативный подход — это предоставить возможность задавать правила, каким образом для конкретной кнопки определяется её иконка, либо напрямую в виде CSS, либо предоставив какие-то похожие механизмы типа:
В таком подходе мы можем реализовать не только блокировки, но и программируемые прерывания и реакцию на на них:
```
api.options.addRule(
// Читать примерно так: кнопки
// типа `checkout` значение `iconUrl`
// берут из поля `iconUrl` своей модели
'button[@type=checkout].iconUrl',
'model.iconUrl'
)
const lock = await searchBox
.state.acquireLock(
'innerArea',
'2s', {
// Добавляем описание,
// кто и зачем пытается
// выполнить блокировку
reason: 'selectOffer',
offer
}
);
lock.on('released', () => {
// Если у нас забрали блокировку,
// отменяем анимацию
searchBox.offerPanel.view
.cancelAnimation();
})
// Если другой актор пытается
// перехватить блокировку
lock.on('tryLock', (sender) => {
// Если это другое предложение
// разрешааем перехват и отменяем
// текущую блокировку
if (sender.reason == 'selectOffer') {
lock.release();
} else {
// Иначе запрещаем перехват
return false;
}
})
await searchBox.offerPanel
.view.animate('left', 0, '1s');
lock.release();
```
Думаем, излишне уточнять, что разработка своей CSS-подобной системы — огромное количество работы, и к тому же изобретение велосипеда. Использование настоящего CSS, если оно возможно — более разумный подход, однако и он зачастую совершенно избыточен, и только весьма ограниченный набор возможностей системы будет реально использоваться разработчиками.
**NB**: хотя пример выше выглядит крайне переусложнённым, в нём не учтено ещё множество нюансов:
* `offerPanel` тоже должен стать разделяемым ресурсом, и его точно так же надо захватывать;
* при перехвате блокировки должна останавливаться не анимация вообще, а та конкретная операция, запущенная в рамках конкретной блокировки.
#### Вычисленные значения
Очень важно не забыть предоставить разработчику не только способы задать приоритеты параметров, но и возможность узнать, какой же из вариантов значения был применён. Для этого мы должны разделить заданные и вычисленные значения:
```
// Задаём значение в процентах
button.view.width = '100%';
// Получаем реально применённое
// значение в пикселях
button.view.computedStyle.width;
```
Не менее важна и возможность получать оповещения об изменении вычисленных значений.
Упражнение «найти все разделяемые ресурсы и дополнить пример корректной работой с ними» мы оставим читателю.

View File

@ -0,0 +1,180 @@
### Декомпозиция UI-компонентов. MV*-подходы
Сложность решения проблем, описанных в предыдущей главе, заключается прежде всего в том, что почти любой объект вложен в две (а то и три) различные иерархии сущностей. Если мы возьмём объект «кнопка создания заказа», то она:
* вложена в иерархию визуальных сущностей: экран → `SearchBox` → компонент «просмотр предложения» → компонент «панель действий»;
* привязана к некоторой сущности внутри иерархии данных (результату поиска);
* как программный объект является наследником каких-то базовых классов, отвечающих за UX (системного объекта типа «кнопка», который является реализацией системного интерфейса «интерактивный элемент» и так далее).
Как мы помним из главы «Разделение уровней абстракции», объединение в одном объекте двух-трёх-четырёх разноплановых контекстов — чрезвычайно деструктивное решение, которое приводит к «перепрыгиваниям» по областям ответственности и, как следствие, переусложнённому коду, в рамках которого разработчику придётся манипулировать множеством самых разных сущностей из самых отдалённых фрагментов документации. Увы, с визуальными компонентами у нас просто нет другого выбора: наша кнопка действительно является таким многомерным контекстом, если мы хотим дать возможность гибко настраивать её внешний вид, UX и бизнес-логику. Таков путь.
Идея декомпозиции визуальных объектов, разумеется, придумана не нами и формализована во множестве методологий — в первую очередь, MV*-фреймворках (Model—View—Controller, Model—View—Presenter и т.д.) Все они, в конечно счёте, предлагают реализовать следующий подход:
1. Выделить сущность *модель*, отвечающую только за данные, поверх которых построен компонент.
2. Потребовать, чтобы внешний вид и поведение компонента полностью определялись его моделью.
3. Построить механизм изменений внешнего вида компонента через изменение модели.
Разница между MV*-подходами заключается в разрешённых направлениях взаимодействия между составляющими.
(схемы MVC, MVP, MVVP)
MV*-подход выглядит вполне простым и понятным в случае статических объектов, но, увы, становится гораздо менее удобным в случае интерактивных, поскольку оставляет открытым вопрос о том, где и как должно храниться *состояние* компонента (например, «происходит ли сейчас анимация нажатия кнопки»). Фактически, это тоже часть данных, на которых строится компонент, но редко часть модели (в том числе потому, что это явное нарушение принципа изоляции уровней абстракции). Для компонентов, обладающих сложным (и асинхронно изменяемым) внутренним состоянием мы должны пойти ещё дальше, и декомпозировать также данные, поверх которых он строится. UI-компоненты, как правило, оперируют:
* данными, полученными из внешних источников, и связанные с бизнес-логикой (в случае нашей кнопки — это предложение, которое по нажатию конвертируется в заказ);
* данными, унаследованными по различным иерархиям (например, из визуальной иерархии кнопка получает положение своего контекста на экране);
* данными, которые описывают её собственное внутреннее состояние (например, состояние анимации нажатия кнопки).
Если мы позволяем кастомизировать всё вышеперечисленное, кнопка превращается в очень сложный объект:
```
class Button extends SystemButton {
// Модель данных
public model: IButtonModel;
// Состояние кнопки
public state: IButtonState;
// Отображение кнопки
public view: IButtonView;
}
// Каждый из суб-компонентов
// должен в свою очередь
// предоставлять функциональность:
interface IButtonModel {
// доступа к данным
data: IDataAccessor,
// генерации событий изменения
// данных
events: IEventEmitter,
// вышестоящий контекст
parentContext: IContext
}
```
Напомним, префикс `I` мы используем, чтобы индицировать *интерфейсы* (абстрактные классы). В данном случае подразумевается, что кнопка должна уметь работать с любой моделью, состоянием и/или отображением, лишь бы они выполняли требуемый программный контракт. Именно благодаря этому требованию мы можем говорить о полной замене одного из компонентов кнопки альтернативной реализацией.
**NB**: чтобы замена компонента работала, должен существовать способ её осуществить — например, через функции-билдеры.
```
class Button {
constructor() {
this.view = this.buildView();
}
}
// Разработчик может переопрелить
// метод построения отображения
class MyButton {
buildView() {
return new MyCustomView();
}
}
```
Чтобы вся эта система заработала, мы должны добавить к ней события: каждый из компонентов генерирует определённые события и обрабатывает события других компонентов. Например, реакцию на нажатие мы можем описать так:
```
button.events.on(
// Получаем событие
// нажатия кнопки
'press',
(event) => {
// Пытаемся заблокировать
// кнопку, чтобы не допустить
// отправки двух запросов
// на создание заказа
button.state.lock().then(
(lock) => {
// В случае успеха
// создаём заказ
const order = await api
.createOrder(
button.data.offer
);
lock.release();
this.events.emit(
'orderCreated',
order
);
},
(error) => {
// Если блокировку
// не удалось получить,
// как-то обработать
// ошибку
}
);
}
);
```
В свою очередь, сущность view должен опираться на состояние кнопки и события её изменения:
```
class ButtonView {
public parent: Button;
protected initialize () {
this.parent.state.events.on(
'lock',
() => {
// Вносит изменения во
// внешний вид кнопки
}
);
// аналогично для 'unlock'
// Объект view должен
// генерировать событие 'press',
// только если он не заблокирован
this.element.onTouch(
(event) => {
if (!this.parent.state.locked) {
this.events.emit('press');
}
}
)
}
}
```
Важно, что при этом каждая составляющая должна оперировать сущностями своей области ответственности, как показано в примере выше: объект View получает от системы событие о том, что над ним произошло касание пользователя, но превращает его в событие 'press' только если UI не заблокирован, таким образом выступая «фильтром», который переформулирует низкоуровневые системные события так, чтобы они стали *ближе* к бизнес-логике.
Заменив получение и генерацию событий в некоторых парах компонентов на вызовы методов родительского контекста, мы можем редуцировать систему до MV*- или, скажем, Redux-подобного фреймворка. В рамках MVC мы написали бы следующий код:
```
class ButtonView {
protected model;
protected controller;
protected initialize () {
this.element.onTouch(
function (event) {
this.controller.onPress();
}
)
this.model.onChange(
function () {
// Перерисовывает кнопку
// в соответствие с моделью
}
)
}
}
class OrderButtonController () {
protected writeableModel;
public onPress () {
this.writeableModel.set('locked', true);
const order = await api
.createOrder(
this.model.offer
);
this.writeableModel.set('order', order);
this.writeableModel.set('locked', false);
}
}
```
Как можно заметить, получившийся код полностью эквивалентен предыдущему варианту с точностью до замены событий на вызовы функций. Это неслучайно — смысл MV*-фреймворков как раз заключается в том, чтобы интерфейсно *запретить* определённые направления распространения событий (скажем, только контроллер может изменять состояние модели), тем самым снизив общую сложность взаимодействия.