Когда я впервые услышал про compliant-механизмы, был весьма впечатлен. Хоть они и окружают нас в повседневности — в виде застежек рюкзака, кнопок мыши или колпачков от шампуней, — мы редко задумываемся о концепции таких устройств.
Если говорить кратко, в compliant-механизме для обеспечения его технических характеристик используют деформацию. В то время как в традиционной технике (rigid body) гибкость зачастую является негативным качеством материала, сompliant-механизмы используют ее для передачи силы и движения в нужном направлении, вместо соединений из нескольких подвижных деталей.
Поскольку такой механизм является единым целым, а не набором частей, скрепленных осями, резьбой, шарнирами, дающими люфт, лишнее трение и сложное изнашивание, — его движение полностью предсказуемо. Подобная точность понятна: ты двигаешь одну часть, а все остальное связано с ней изгибающимися секциями. Они не могут изменяться в размере, как традиционные пружины, и постоянно корректируют, направляют друг друга.
Я нахожу в этом отличную аналогию на декларативный подход в программировании — в противовес императивному, в котором, подобно передачи движения через шарниры и оси, команды управляют изменениями состояния. И я хотел бы поделиться определенной философией написания Angular-кода, сложившейся у меня за время работы над библиотекой переиспользуемых компонентов для пользовательского интерфейса.
Compliant-компоненты
Angular изначально предполагает определенную долю декларативности: дата-байндинг, работа с событиями, активное использование observable-модели. Несложные компоненты буквально описаны утверждениями, что чем является и как связано с окружением. Они не выглядят набором инструкций, изменяющих состояние в ту или иную сторону. Однако с усложнением задачи компонент может легко утратить это описательное качество и превратиться в череду вызовов, подписок и хранимых данных, отвечающих за его поведение, синхронизировать которые становится всё сложнее.
При внимательном рассмотрении оказывается, что состоянием компонента часто являются только его инпуты, а остальное можно вычислить. Это означает — использовать геттер. Чтобы лучше это понять, давайте создадим несколько компонентов, используя такой подход.
Линейный график
Это довольно незамысловатый компонент. Мы передаем ему массив пар чисел и хотим, чтобы SVG построил по ним путь. Мы постараемся организовать наш код так, чтобы в нем не было императивных манипуляций с состоянием. Можно повесить компонент прямо на нативный SVG, чтобы избежать вложенности:
@Component({
selector: "svg[lineChart]",
templateUrl: "./line-chart.template.html",
styleUrls: ["./line-chart.style.less"],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
preserveAspectRatio: "none"
}
})
export class LineChartComponent {}
Кроме данных нужно также задать границы видимой области. Можно просто задать viewBox
снаружи, но куда удобнее вынести это в отдельные инпуты и собрать строку геттером:
@HostBinding('attr.viewBox')
get viewBox(): string {
return `${this.x} ${this.y} ${this.width} ${this.height}`;
}
Чтобы сделать компонент интереснее, давайте добавим инпут на уровень сглаживания. Шаблон будет состоять из единственного path
-элемента:
<svg:path
fill="none"
stroke="currentColor"
vector-effect="non-scaling-stroke"
stroke-width="2"
[attr.d]="d"
/>
Нам нужно рассчитать атрибут d
. Одним из вариантов может стать сеттер на входные данные. Но в будущем мы можем добавить другие связанные функции, например заливку под графиком или подсказки при наведении. Поэтому давайте добавим геттер, чтобы не управлять состоянием вручную:
get d(): string {
return this.data.reduce(
(d, point, index) =>
index ? `${d} ${draw(this.data, index, this.smoothing)}` : `M ${point}`,
""
);
}
Вот и всё! Сами функции, рассчитывающие путь, я описывать не буду — их можно нагуглить. В конце будет живое демо всех созданных в статье компонентов, где вы сможете подглядеть исходный код.
Media-директива
Давайте теперь возьмемся за что-нибудь посложнее. Мы хотим научиться управлять медиаэлементами, такими как аудио- или видеотеги. И мы будем делать это с минимумом императивных вызовов. Нам нужно контролировать три вещи: текущее время, громкость и состояние воспроизведения/паузы. Все они также могут меняться нативными контролами, так что это будет двусторонний байндинг:
@Input()
currentTime = 0;
@Input()
paused = true;
@Input()
@HostBinding("volume")
volume = 1;
@Output()
readonly currentTimeChange = new EventEmitter<number>();
@Output()
readonly pausedChange = new EventEmitter<boolean>();
@Output()
readonly volumeChange = new EventEmitter<number>();
@HostListener("volumechange")
onVolumeChange() {
this.volume = this.elementRef.nativeElement.volume;
this.volumeChange.emit(this.volume);
}
Видите @HostBinding
на volume
? Этого достаточно для громкости. Но не в случае с currentTime
: он быстро меняется сам во время воспроизведения. Поэтому байндинг тут вызовет заикания из-за цикла постоянных обновлений. Так что заменим инпут на сеттер и будем обрабатывать только измененные значения:
@Input()
set currentTime(currentTime: number) {
if (currentTime !== this.currentTime) {
this.elementRef.nativeElement.currentTime = currentTime;
}
}
get currentTime(): number {
return this.elementRef.nativeElement.currentTime;
}
@HostListener("timeupdate")
@HostListener("seeking")
@HostListener("seeked")
onCurrentTimeChange() {
this.currentTimeChange.emit(this.currentTime);
}
Для воспроизведения и паузы тоже создадим пару «геттер/сеттер»:
@Input()
set paused(paused: boolean) {
if (paused) {
this.elementRef.nativeElement.pause();
} else {
this.elementRef.nativeElement.play();
}
}
get paused(): boolean {
return this.elementRef.nativeElement.paused;
}
Имея такую директиву, написать свой видеоплеер не составит труда:
<video
#video
media
class="video"
[(currentTime)]="currentTime"
[(paused)]="paused"
(click)="toggleState()"
>
<ng-content></ng-content>
</video>
<div class="controls">
<button
class="button"
type="button"
title="Play/Pause"
(click)="toggleState()"
>
{{icon}}
</button>
<input
class="progress"
type="range"
[max]="video.duration"
[(ngModel)]="currentTime"
/>
</div>
С помощью ng-content
пользователи смогут предоставить свои видеофайлы как для нативного видеотега. А код компонента будет бессовестно краток:
currentTime = 0;
paused = true;
get icon(): string {
return this.paused ? "u23F5" : "u23F8";
}
toggleState() {
this.paused = !this.paused;
}
Комбо-бокс
Теперь, когда мы настроились на нужный лад, давайте окунемся в серьезный код. Комбо-бокс — куда более сложный пример, но не волнуйтесь. У нас не будет функций длиннее строки! Ну, разве что одна.
В этой части я буду полагаться на декларативный
preventDefault
. Он работает благодаря библиотеке ng-event-plugins, о которой я писал ранее.
Начнем с шаблона. Мы не будем создавать кастомный контрол, поскольку это отдельная тема. Вместо этого мы обернем нативный input
, чтобы у пользователей был полный контроль над ним:
<combo-box [items]="items">
<input type="text" [(ngModel)]="value">
</combo-box>
Внутренний шаблон будет использовать label
для того, чтобы input
фокусировался при клике по стрелке. Это хак! Разработчики не смогут добавить доступный лейбл, просто обернув наш компонент. Но для примера этого хватит, чтобы сэкономить время:
<label>
<ng-content></ng-content>
<div class="toggle" (mousedown.prevent)="toggle()"></div>
</label>
<div *ngIf="open" class="list" (mousedown.prevent)="noop()">
<div
*ngFor="let item of filteredItems; let index = index"
class="item"
[class.item_active]="isActive(index)"
(click)="onClick(item)"
(mouseenter)="onMouseEnter(index)"
>
{{item}}
</div>
</div>
Отменять действие по умолчанию для mousedown
нужно, чтобы фокус не покидал поле ввода. У компонента один единственный инпут — массив строк-подсказок при вводе. И в данном случае у него будет одно внутреннее состояние, которое придется контролировать вручную.
Вы можете подумать, что это состояние раскрытости выпадашки, но нет. Это индекс текущего выбранного элемента выпадашки. Мы будем использовать NaN
как индикатор отсутствия текущего элемента, чтобы остаться в рамках типа number
. Открытость выпадашки будет контролировать геттер на наличие выбранного элемента:
get open(): boolean {
return !isNaN(this.index);
}
Нам нужно сузить варианты исходя из введенного пользователем текста. Мы добавим NgControl
в виде @ContentChild
, чтобы получить доступ к его значению. Это позволит нам отфильтровать массив:
@ContentChild(NgControl)
private readonly control: NgControl;
get value(): string {
return String(this.control.value);
}
get filteredItems(): readonly string[] {
return this.items.filter(item =>
item.toLowerCase().includes(this.value.toLowerCase())
);
}
Теперь мы можем добавить еще один геттер для индекса элемента выпадашки:
get clampedIndex(): number {
return limit(this.index, this.filteredItems.length - 1);
}
// ...
function limit(value: number, max: number): number {
return Math.max(Math.min(value || 0, max), 0);
}
С ним безопасно работать, поскольку он всегда находится в рамках реально доступных индексов.
Теперь добавим обработчики событий, на которые мы подписались в шаблоне. Нам нужно открывать и закрывать список по клику на стрелку, выбирать элемент из списка и обновлять активный индекс по наведению:
onClick(item: string) {
this.selectItem(item);
}
onMouseEnter(index: number) {
this.index = index;
}
@HostListener('keydown.esc')
@HostListener('focusout')
close() {
this.index = NaN;
}
toggle() {
this.index = this.open ? NaN : 0;
}
private selectItem(value: string) {
this.control.control.setValue(value);
this.close();
}
Теперь добавим работу с клавиатуры: по нажатию стрелок будем менять индекс, а клавишей Enter — выбирать пункт. Также список будет показываться при вводе текста в поле:
@HostListener('keydown.arrowDown.prevent', ['1'])
@HostListener('keydown.arrowUp.prevent', ['-1'])
onArrow(delta: number) {
this.index = this.open
? limit(
this.clampedIndex + delta,
this.filteredItems.length - 1
)
: 0;
}
@HostListener('keydown.enter.prevent')
onEnter() {
this.selectItem(
this.open
? this.filteredItems[this.clampedIndex]
: this.value
)
}
@HostListener('input')
onInput() {
this.index = this.clampedIndex;
}
Вот, в общем-то, и все. Компонент полностью работоспособен. Мы написали его в декларативном стиле, оставив только одно состояние для ручного контроля. У нас нет императивных команд вроде «показать выпадашку». Вместо этого мы описали поведение компонента относительно его состояний: введенного текста, списка вариантов и активного элемента в нем. Вот почему это называется декларативным подходом.
В реальности подобный компонент требует доработки доступности. Вы можете добавить ARIA-атрибуты, такие как
aria-activedescendant
. Так скрин-ридеры и другие технологии доступности тоже будут в курсе активного элемента. Узнать больше про комбобокс-паттерн можно тут и тут.
Вы задумывались, отчего АК-47 является столь популярным оружием последних десятилетий? В его конструкции всего восемь подвижных частей, благодаря этому его легко производить, использовать и обслуживать. Это применимо и к архитектуре приложений: чем меньше состояний нужно обрабатывать, тем надежнее будет код. Простой дизайн — надежный дизайн. И хотя декларативный код поначалу может показаться непростым, когда к нему привыкаешь — начинаешь ценить его аккуратность.
Производительность
Естественный вопрос, который может возникнуть: если мы продолжаем все пересчитывать, не пострадает ли производительность нашего приложения? Разумеется, для такого подхода обязательна OnPush
-стратегия проверки изменений. И, откровенно говоря, я не встречал ситуации, когда стратегия Default
была бы оправдана, кроме компонента отображения ошибки в поле, поскольку на момент написания статьи в Angular так и не появился стрим на изменение touched-состояния.
Чтобы оценить производительность, давайте внимательно посмотрим на то, что мы делаем в геттерах. Конкатенация строк, как в случае с viewBox
, имеет скорость порядка 1 млрд операций в секунду, или 300 млн на средненьком android-устройстве. Очевидно, это не может повредить скорости работы компонента. То же самое относится к простым арифметическим и булевым операциям.
Все становится интереснее, когда дело доходит до работы с массивами и объектами. Перебор массива из 100 элементов для поиска последнего имеет скорость 15 млн операций в секунду на моем ПК и в десять раз медленнее — на смартфоне. Немутабельные операции, которые создают новые экземпляры массивов, еще медленнее. Фильтрация массива из 100 элементов выдает только 3 млн операций в секунду на компьютере и всего 300 тысяч — на телефоне. Работа с созданием объектов — схожий кейс за счет внутренней механики JavaScript. Можете сами оценить быстродействие вот тут. Это значит, что для того, чтобы концепция compliant-компонентов взлетела, требуется оптимизация.
Добавим простую мемоизацию, чтобы избежать лишних пересчетов. Мы можем создать декоратор для чистых методов. Он будет запоминать переданные аргументы и последний подсчитанный результат. Если аргументы не изменились, он просто вернет прошлое значение:
export function Pure<T>(
_target: Object,
propertyKey: string,
{ enumerable, value }: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> {
const original = value;
return {
enumerable,
get(): T {
let previousArgs: ReadonlyArray<unknown> = [];
let previousResult: any;
const patched = (...args: Array<unknown>) => {
if (
previousArgs.length === args.length &&
args.every((arg, index) => arg === previousArgs[index])
) {
return previousResult;
}
previousArgs = args;
previousResult = original(...args);
return previousResult;
};
Object.defineProperty(this, propertyKey, {
value: patched
});
return patched as any;
}
};
}
Таким образом мы можем переписать наш код на пары «геттер + чистый приватный метод»:
get filteredItems(): readonly string[] {
return this.filter(this.items, this.value);
}
@Pure
private filter(items: readonly string[], value: string): readonly string[] {
return items.filter(item =>
item.toLowerCase().includes(value.toLowerCase())
);
}
Давайте оценим этот подход. Сравним его с неоптимизированным декларативным кодом и императивным, в котором все обновляется руками в ngOnChanges
: stackblitz.com/edit/compliant-components-performance-ivy.
В этом примере — список из 1000 компонентов и кнопка, которая запускает проверку изменений во всех разом. Императивные компоненты равноценны холостому прогону, ведь инпуты не меняются и ничего не происходит. Декларативные же компоненты содержат в себе несколько геттеров. Логическая проверка значения на превышение предела, конкатенация строк, математический расчет вместе с @HostBinding
, вешающим класс. А также перебор массива, создание массива и создание объекта. Не забывайте, что все это умножено на 1000, ведь каждый компонент содержит эти геттеры в себе. Последний столбец в примере использует @Pure
-декоратор для операций с массивами и объектами. Такие результаты я получил в среднем за 100 прогонов проверки изменений на своем компьютере:
А такие — на смартфоне:
Различия на ПК укладываются в погрешность, в то время как android-девайс средней производительности выдает разницу в 10%. Можно посмотреть на это и сказать: «Да оно же на 10% медленнее!» Но можно взглянуть на это иначе. Даже на слабом устройстве с несколькими тысячами геттеров, считающимися одновременно, мы остаемся в пределах 60 кадров в секунду. И это всего на 1,5 миллисекунды дольше, чем холостой прогон — накладные расходы от Angular.
На практике операции с DOM чаще всего могут стать бутылочным горлышком. Это самые затратные операции типового приложения. Помните байндинг геттера на класс? Фишка в том, что браузеры не будут вносить изменения в DOM, если значение осталось прежним. Это касается классов, стилей или атрибутов.
Если организовывать свое дерево компонентов разумно, применять мемоизации и использовать OnPush
, декларативный подход не станет затыком производительности.
Итог
Написанные таким образом компоненты надежные и гибкие. Возможно, вам потребуется некое смещение установок в голове, чтобы освоиться с этим подходом. Но когда вы начнете мыслить декларативно, увидите, какое это удовольствие — писать и поддерживать подобный код. От вас требуется быть внимательным, чтобы не допускать ошибок с производительностью. Но по своему опыту могу сказать, что оно того стоит!
Компоненты, которые мы создали, можно потыкать здесь:
Я возглавляю разработку проприетарного UI-кита в Tinkoff. Вся библиотека построена на принципах, изложенных в этой статье. Прямо сейчас библиотека в процессе выхода в open-source, и основополагающий пакет уже доступен на GitHub и npm. В нем вы найдете описанный Pure-декоратор и много других полезных низкоуровневых инструментов для создания крутых приложений на Angular. Мы обязательно расскажем про них в будущих статьях, так что до встречи!
Автор: Александр Инкин