1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-02-16 18:34:31 +02:00

Section V finished

This commit is contained in:
Sergey Konstantinov 2023-09-23 00:44:02 +03:00
parent e23823f014
commit 6d59da76f2
25 changed files with 195 additions and 235 deletions

View File

@ -0,0 +1,90 @@
### [Computed Properties][sdk-computed-properties]
Let's revisit one of the problems we outlined in the “[Problems of Introducing UI Components](#sdk-ui-components)” chapter: the existence of multiple inheritance lines complicates customizing UI components as it implies that component properties could be inherited from any such line.
For example, imagine we have a button that can borrow its `iconUrl` property from two sources — from data [in our case, from offer data originated in the offer search results] and the component's options:
```typescript
class Button {
static DEFAULT_OPTIONS = {
iconUrl: <default icon>
}
constructor (data, options) {
this.data = data;
// Overriding default options
// with custom values
this.options = extend(
Button.DEFAULT_OPTIONS,
options
)
}
render() {
this.iconElement.src =
this.data.iconUrl ||
this.options.iconUrl
}
}
```
It is also plausible to suggest that the `iconUrl` property in both hierarchies is inherited from some parent entities:
* The default option values could be defined in the base class which `Button` extends.
* The data for the button could be hierarchical (for example, if we decide to group offers in the same coffee shop chain, and the icon is to be taken from the parent group).
* To facilitate customizing the visual style of the components, we could allow overriding icons in all SDK buttons.
In this situation, a question of priorities arises: if a property is defined in several hierarchies (let's say, in the offer data and in the default options), how should the priorities be set to select one of them?
The straightforward approach to tackling this issue is to prohibit any inheritance and force developers to explicitly set the properties they need. In our example, it would mean that developers will need to write something like this:
```typescript
const button = new Button(data);
if (data.createOrderButtonIconUrl) {
button.view.iconUrl =
data.createOrderButtonIconUrl;
} else if (data.parentCategory.iconUrl) {
button.view.iconUrl =
data.parentCategory.iconUrl;
}
```
The main advantage of this approach is obvious: developers implement the logic they need themselves. The disadvantages are also apparent: first, developers will need to write excessive and often copy-pasted code (“boilerplate”); second, they will soon become confused about which rules are in place and why.
A slightly more complex solution is allowing inheritance but rigidly fixing priorities (let's say the value set in the component's options always takes precedence over the one set in the data, and they both are more important than any inherited value). However, for any complex API, the result will be the same: if developers need a different order of resolving priorities, they will write code similar to the one above.
An alternative approach is to expose the possibility of defining rules for how exactly the icon is resolved for a specific button, either declaratively or imperatively:
```json
// The declarative approach: the rules
// described in some data format
{
"button.checkout.iconUrl": "@data.iconUrl"
}
```
```typescript
// The imperative approach: the value
// is calculated by the provided function
api.options.addRule(
'button.checkout.iconUrl',
(data, options) => data.iconUrl
)
```
The most coherent implementation of this approach is the CSS technology.[ref CSS](https://www.w3.org/Style/CSS/) We are not actually proposing using a full CSS rule engine in component libraries (because of its overwhelming complexity and excessiveness for most cases), but we are cautiously drawing the reader's attention to the fact that supporting some subset of CSS-like rules could *significantly* simplify the task of customizing UI components.
#### Calculated Values
It is important not only to provide a mechanism for setting rules to determine how values are resolved but also to allow *obtaining* the value that was actually used. To achieve this, we need to distinguish between the concept of a set value and a computed value:
```typescript
// Set a value as a percentage
button.view.width = '100%';
// Retrieve the actual applied value
// in pixels
button.view.computedStyle.width;
```
It is also a good practice to provide an event for changes in the computed value of such a calculated property.

View File

@ -0,0 +1,7 @@
### [Conclusion][sdk-conclusion]
In the previous eight chapters, we aimed to convey two important observations:
* Developing a high-quality UI library is a very complex engineering task.
* This task cannot be mechanically reduced to auto-generating SDKs based on a specification or data model.
Looking back at what was written, we cannot confidently claim that we found the best examples and the clearest wording for such a complex subject area. However, we hope that we have helped make the reader's life and the lives of their users a bit easier.

View File

@ -1 +0,0 @@
### Computed Properties

View File

@ -1 +0,0 @@
### Вычисляемые свойства

View File

@ -73,7 +73,7 @@ let searchBox = new SearchBox({
});
return res;
}
})
});
```
*Формально* этот подход корректен и никаких рекомендаций не нарушает. Но с точки зрения связности кода, его читабельности — это полная катастрофа, поскольку следующий разработчик, которого попросят заменить иконку *кнопке*, очень вряд ли пойдёт читать код *функции поиска предложений*.

View File

@ -0,0 +1,90 @@
### [Вычисляемые свойства][sdk-computed-properties]
Вернёмся к одной из проблем, описанных в главе «[Проблемы встраивания UI-компонентов](#sdk-ui-components)»: наличие множественных линий наследования усложняет кастомизация компонентов, поскольку подразумевает, что они могут наследовать важные свойства по любой из вертикалей.
Пусть у нас имеется кнопка, которая получает одно и то же свойство `iconUrl` по двум вертикалям — из данных [т.е., в случае нашего примера, из результатов поиска предложений] и из настроек отображения:
```typescript
class Button {
static DEFAULT_OPTIONS = {
iconUrl: <иконка по умолчанию>
}
constructor (data, options) {
this.data = data;
// Разрешаем переопределять
// опции по умолчанию
this.options = extend(
Button.DEFAULT_OPTIONS,
options
)
}
render() {
this.iconElement.src =
this.data.iconUrl ||
this.options.iconUrl
}
}
```
При этом мы можем легко представить себе, что по обеим иерархиям свойство `iconUrl` было получено от кого-то из родителей по любой из вертикалей:
* опции по умолчанию могут быть определены в базовом классе, от которого унаследован `Button`;
* данные, на которых строится кнопка, могут быть общими для группы кнопок или сами по себе быть иерархическими (например, если мы будем группировать предложения сети кофеен и наследовать иконку именно родительской группы);
* в целях облегчить кастомизацию визуального стиля компонент мы можем разрешить переопределять иконку вообще всем кнопкам через задание свойств по умолчанию для всего SDK.
В этой ситуации у нас возникает вопрос: если значение определено сразу в нескольких иерархиях (например, и в данных предложения, и в опциях по умолчанию), каким образом задавать приоритеты, чтобы выбирать одно из них?
Простой подход «в лоб» к этому вопросу — попросту запретить наследование и заставить разработчика копировать все нужные ему свойства. То есть в нашем примере сам разработчик должен написать что-то типа:
```typescript
const button = new Button(data);
if (data.createOrderButtonIconUrl) {
button.view.iconUrl =
data.createOrderButtonIconUrl;
} else if (data.parentCategory.iconUrl) {
button.view.iconUrl =
data.parentCategory.iconUrl;
}
```
Достоинства простого решения очевидны — разработчик сам имплементирует ту логику, которая ему нужна. Недостатки тоже очевидны — во-первых, это лишний и зачастую дублирующийся код; во-вторых, разработчик быстро запутается в том, какие правила он реализовал и почему.
Чуть более сложный подход к проблеме — разрешить наследование, но строго зафиксировать приоритеты (скажем, заданное в опциях отображения значение всегда важнее заданного в данных, и они оба всегда важнее любого унаследованного свойства). Однако в достаточно сложном API результат будет тот же самым: если разработчику необходим другой порядок приоритетов, ему придётся задавать нужные свойства вручную, т.е. в итоге писать код, подобный вышеприведённому.
Альтернативный подход — это предоставить возможность задавать правила, каким образом для конкретной кнопки определяется её иконка, декларативно или императивно:
```json
// Декларативный подход:
// описываем правила в каком-то формате
{
"button.checkout.iconUrl": "@data.iconUrl"
}
```
```typescript
// Императивный подход — программно
// добавляем функцию вычисления значения
api.options.addRule(
'button.checkout.iconUrl',
(data, options) => data.iconUrl
)
```
Наиболее последовательная реализация этого подхода — CSS[ref CSS](https://www.w3.org/Style/CSS/). Мы не то чтобы рекомендуем использовать CSS-подобные правила в библиотеках компонентов (в силу потрясающей сложности их имплементации и, в большинстве случаев, избыточности), но осторожно замечаем, что поддержка какого-то простого подмножества подобного рода правил *значительно* облегчает кастомизацию визуальных компонент для разработчиков.
#### Вычисленные значения
Очень важно не забыть предоставить разработчику не только способы задать приоритеты параметров, но и возможность узнать, какой же из вариантов значения был применён. Для этого мы должны разделить заданные и вычисленные значения:
```typescript
// Задаём значение в процентах
button.view.width = '100%';
// Получаем реально применённое
// значение в пикселях
button.view.computedStyle.width;
```
Хорошей практикой также будет предоставлять доступ не только к вычисляемым значениям, но и к событию изменения этого значения.

View File

@ -0,0 +1,7 @@
### [Заключение][sdk-conclusion]
Предыдущие восемь глав были написаны нами, чтобы раскрыть две очень важные мысли:
* разработка качественной UI-библиотеки — это отдельная и весьма непростая инженерная задача;
* и эта задача не сводится к автоматической генерации SDK по спецификации / модели данных.
Оглядываясь на всё написанное, мы с трудом можем сказать, что нашли лучшие примеры и самые понятные слова для описания такой сложной предметной области. Мы, тем не менее, надеемся, что сделали вашу жизнь — и жизнь ваших пользователей — чуточку проще.

View File

@ -1,152 +0,0 @@
### Разделяемые ресурсы и асинхронные блокировки
Другой важный паттерн, который мы должны рассмотреть — это доступ к общим ресурсам. Предположим, что в нашем учебном приложении разработчик решил открывать экран предложения с анимацией. Для этого он воспользуется объектом offerPanel, который мы реализуем в составе searchBox:
```
class OfferPanel {
constuctor(searchBox) {
searchBox.on(
'selectOffer',
(event) => {
// Показываем выбранное предложение
// в панели, но размещаем её
// за границей экрана
this.render(event.offer, {
left: screenWidth
});
// Анимируем положение панели
this.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;
}
});
```
Но этот код очень плох по множеству причин:
* непонятно, как его модифицировать, если у нас появятся разные виды анимации, причём некоторые из них будут требовать блокировки прокрутки, а некоторые — нет;
* этот код просто плохо читается: совершенно непонятно, почему флаг `isAnimating` влияет на обработку события `scroll`;
* сложно предсказать, что произойдёт, если каким-то образом (например, программно) будет открыто другое предложение — оба процесса будут «драться» за один флаг и один объект;
* если при выполнении анимации произойдёт какая-то ошибка, флаг `isAnimating` не будет сброшен, и прокрутка будет заблокирована навсегда.
Корректное решение первых двух проблемы — это абстрагирование от самого факта анимации и переформулирование проблемы в высокоуровневых терминах. Почему мы запрещаем прокрутку во время анимации? Потому что появление панели предложения как бы «захватывает» эту область экрана. Пользователь не может работать с другими объектами в этой области во время анимации (или, скорее, нет разумного сценария использования, при котором пользователю может понадобиться это делать). Следовательно, именно такой флаг нам и надо объявить — признак «разделяемая область на экране заблокирована»:
```
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) {
// Какая-то логика обработки
// невозможности захвата ресурса
}
```
**NB**: вторым параметром в `acquireLock` мы передали время жизни блокировки — 2 секунды. Если в течение двух секунд блокировка не снята, она будет отменена автоматически.
В таком подходе мы можем реализовать не только блокировки, но и программируемые прерывания и реакцию на них:
```
const lock = await searchBox
.state.acquireLock(
'innerArea',
'2s', {
// Добавляем описание,
// кто и зачем пытается
// выполнить блокировку
reason: 'selectOffer',
offer
}
);
lock.on('lost', () => {
// Если у нас забрали блокировку,
// отменяем анимацию
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();
```
**NB**: хотя пример выше выглядит крайне переусложнённым, в нём не учтено ещё множество нюансов:
* `offerPanel` тоже должен стать разделяемым ресурсом, и его точно так же надо захватывать;
* при перехвате блокировки должна останавливаться не анимация вообще, а та конкретная операция, которая была запущена в рамках конкретной блокировки.
Упражнение «найти все разделяемые ресурсы и дополнить пример корректной работой с ними» мы оставим читателю.

View File

@ -1,69 +0,0 @@
### Вычисляемые свойства
Наличие множественных линий наследования усложняет кастомизация компонентов, поскольку подразумевает, что они могут наследовать важные свойства по любой из вертикалей (иконка кнопки может быть задана и в визуальных настройках компонента, и в данных, и в родительском классе).
Вернёмся к проблеме, которую мы описали в предыдущей главе. Пусть у нас имеется кнопка, которая получает одно и то же свойство `iconUrl` по двум вертикалям — из данных и из настроек отображения:
```
const button = new Button({
model: {
iconUrl: <URL#1>
}
);
button.view.options.iconUrl = <URL#2>;
```
При этом мы можем легко представить себе, что по обеим иерархиям свойство `iconUrl` было получено от кого-то из родителей — например, данные могут быть сгруппированы по бренду, и иконка будет задана для всей группы. Также возможно, что мы разрешим переопределять иконку вообще всем кнопкам через задание свойств по умолчанию для всего SDK.
В этой ситуации у нас возникает вопрос: каким образом задавать приоритеты, какой из возможных вариантов опции будет выбран.
Современные графические SDK в зависимости от выбранного подхода делятся на две категории: построенные по образу и подобию CSS и все остальные.
#### Приоритеты наследования
Простой подход «в лоб» к этому вопросу — либо зафиксировать приоритеты в точности (скажем, заданное в опциях отображения значение всегда важнее заданного в данных, и они оба всегда важнее любого унаследованного свойства), либо попросту запретить наследование и заставить разработчика копировать все нужные ему свойства. То есть в нашем примере сам разработчик должен написать что-то типа:
```
const button = new Button(…);
if (button.data.checkoutButtonIconUrl) {
button.view.iconUrl =
button.data.checkoutButtonIconUrl;
} else if (
button.data.parentCategory?.iconUrl
) {
button.view.iconUrl =
button.data.parentCategory.iconUrl;
}
```
(В достаточно сложном API оба этих подхода приведут к одинаковому результату. Если приоритеты фиксированы, то это рано или поздно приведёт к необходимости написать код, подобный вышеприведённому, так как разработчик не сможет добиться нужного результата иначе.)
Достоинства простого решения очевидны — разработчик сам имплементирует ту логику, которая ему нужна. Недостатки тоже очевидны — во-первых, это лишний код; во-вторых, разработчик быстро запутается в том, какие правила он реализовал и почему.
Альтернативный подход — это предоставить возможность декларативно задавать правила, каким образом для конкретной кнопки определяется её иконка, либо напрямую в виде CSS, либо предоставив какие-то похожие механизмы типа:
```
api.options.addRule(
// Читать примерно так: кнопки
// типа `checkout` значение `iconUrl`
// берут из поля `iconUrl` своей модели
'button[@type=checkout].iconUrl',
'model.iconUrl'
)
```
Думаем, излишне уточнять, что разработка своей CSS-подобной системы — огромное количество работы, и к тому же изобретение велосипеда. Использование настоящего CSS, если оно возможно — более разумный подход, однако и он зачастую совершенно избыточен, и только весьма ограниченный набор возможностей системы будет реально использоваться разработчиками.
#### Вычисленные значения
Очень важно не забыть предоставить разработчику не только способы задать приоритеты параметров, но и возможность узнать, какой же из вариантов значения был применён. Для этого мы должны разделить заданные и вычисленные значения:
```
// Задаём значение в процентах
button.view.width = '100%';
// Получаем реально применённое
// значение в пикселях
button.view.computedStyle.width;
```
При этом необходимо предоставить доступ не только к вычисляемым значениям, но и к событию изменения этого значения.

View File

@ -1,9 +0,0 @@
### В заключение
Предыдущие восемь глав были написаны нами, чтобы раскрыть две очень важные мысли:
* разработка качественной UI-библиотеки — это отдельная и весьма непростая инженерная задача;
* и эта задача не сводится к автоматической генерации SDK по спецификации.
Автор этой книги в течение 10 лет разрабатывал подобную сложную интерактивную визуальную систему, позволяющую кастомизировать компоненты вплоть до замены технологии рендеринга, и может уверенно констатировать: не знакомые с проблематикой разработки такого SDK программисты и менеджеры склонны считать проблемы UI несущественными и не заслуживающими большого внимания, и это само по себе быстро становится проблемой. (Впрочем, то же самое мы можем сказать и про разработку API в целом.)
Оглядываясь на всё написанное, мы с трудом можем сказать, что нашли лучшие примеры и самые понятные слова для описания такой сложной предметной области. Мы, тем не менее, надеемся, что сделали вашу жизнь — и жизнь ваших пользователей — чуточку проще. Спасибо за ваше внимание!