Привет, Хабр!
Каждый работавший с формами в Angular, рано или поздно сталкивается с ситуацией, когда стандартных полей ввода недостаточно. Хочется сделать что‑то свое: например, красивый рейтинг в виде звёздочек, компонент для ввода телефона с маской или даже кастомный текстовый редактор на базе contenteditable. Однако просто создать компонент недостаточно, Angular Forms не поймет, как работать с вашим контролом без дополнительных вмешательств.
ControlValueAccessor и зачем он нужен
ControlValueAccessor — это специальный интерфейс Angular, который служит мостом между API форм и вашим кастомным элементом в DOM. Он позволяет Angular связать значение FormControl с пользовательским компонентом или директивой, как если бы это был обычный <input> или <select>.
У интерфейса всего четыре метода (три обязательных и один опциональный):
writeValue(obj: any): void— записать значение в компонент. Angular вызовет этот метод, когда нужно отобразить новое значение из модели формы в вашем контроле (например, при инициализации или когда кто‑то вызвалformControl.setValue(...)вне компонента).registerOnChange(fn: any): void— регистрирует колбэк, который нужно вызывать при изменении значения внутри вашего компонента. Angular передаст сюда функцию, которую вы должны вызвать, когда пользователь изменил значение вашего кастомного поля. Через этот колбэк новое значение попадает обратно в форму.registerOnTouched(fn: any): void— регистрирует колбэк для случая, когда пользователь «потрогал» контрол (то есть совершил фокус/блюр или иное взаимодействие). Этот колбэк нужно вызывать, когда контроль теряет фокус или пользователь закончил с ним взаимодействие, чтобы пометить поле как touched (это влияет на валидацию и отображение ошибок).setDisabledState(isDisabled: boolean): void(опциональный) — получает сигнал о смене состояния отключенности контрола. Angular вызовет этот метод, если нужно переключить ваш компонент в режим disabled (например, при вызовеformControl.disable()).
Angular поставляется с несколькими встроенными реализациями ControlValueAccessor для стандартных элементов: есть аксессоры для текстовых <input>, для чекбоксов, радиокнопок, <select> и так далее Они автоматически применяются, когда Angular Forms инициализирует поле ввода. Но если мы создаем свой компонент или директиву, Angular о них не знает. Чтобы наш компонент заработал как форм control, нужно самому реализовать интерфейс ControlValueAccessor и зарегистрировать его.
Причем на одном поле ввода может быть зарегистрировано несколько value accessor'ов — например, один встроенный и один наш собственный, но пользовательский аксессор всегда имеет приоритет, и кастомных аксессоров может быть не более одного на поле. Обычно лишних не бывает, если вы все делаете правильно.
Регистрация кастомного компонента через NG_VALUE_ACCESSOR
Чтобы Angular применил наш ControlValueAccessor, нужно зарегистрировать его в системе зависимостей с токеном NG_VALUE_ACCESSOR. Делается это с помощью провайдера в декораторе компонента (или директивы). Покажу сразу на примере. Допустим, мы хотим сделать компонент рейтинга со звездами (классический звездочный рейтинг от 1 до 5).
Начнем с заготовки компонента:
import { Component, forwardRef, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-star-rating',
template: `...`, // шаблон добавим позже
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent),
multi: true
}
]
})
export class StarRatingComponent implements ControlValueAccessor {
@Input() maxRate: number = 5;
// текущее значение рейтинга, которое хранит компонент
private currentRate: number = 0;
// флаг отключения
private disabled = false;
// Колбэки, которые нам передаст Angular
private onChange: (value: number) => void = () => {}; // изначально пустые,
private onTouched: () => void = () => {}; // чтобы избежать ошибок
// ... реализация методов интерфейса ...
}
Объявили провайдер NG_VALUE_ACCESSOR и указали useExisting: forwardRef(() => StarRatingComponent). Эта конструкция нужна, чтобы зарегистрировать наш компонент как поставщика value accessor'а. Фреймворк соберет всех провайдеров с токеном NG_VALUE_ACCESSOR в один массив (за счет multi: true) и при инициализации каждого поля формы будет просматривать, нет ли среди них подходящего для данного поля. Если на элементе есть наш компонент (с селектором app-star-rating), Angular найдёт наш провайдер и будет знать, что этому элементу сопоставлен кастомный ControlValueAccessor. Без этого шага ничего дальше работать не будет.
Кроме регистрации, в коде выше я сразу задал основные поля: currentRate — текущее значение рейтинга, disabled — флаг, показывающий отключен ли компонент, и две функции‑колбэка onChange и onTouched. Обратите внимание: я инициализировал onChange и onTouched пустыми функциями () => {}. Подстраховываемся, если вдруг Angular вызовет наши методы до того, как успел зарегистрировать реальные колбэки.
Реализация интерфейса ControlValueAccessor
Теперь реализуем четыре метода интерфейса внутри класса компонента:
export class StarRatingComponent implements ControlValueAccessor {
// ... предыдущий код ...
// Метод, вызываемый фреймворком для установки значения
writeValue(value: number): void {
// Если пришло null/undefined, нормализуем к пустому значению
this.currentRate = value ?? 0;
}
// Регистрация колбэков, вызываемых при изменении и "таче" контрола
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
// Опциональный метод, вызываемый при выключении/включении контрола
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
// ... остальные методы компонента, если есть ...
}
В writeValue просто принимаем новое значение и записываем его в currentRate. Angular будет вызывать writeValue каждый раз, когда внешнее значение формы меняется, например, при инициализации формы, или если кто‑то извне программно установил значение формы. Тут важно не вызывать никаких this.onChange! Наша задача лишь отобразить переданное значение. Если вдруг в value приходит null или undefined, я подстраховал код оператором ?? 0, чтобы не записать в currentRate null (на практике часто делают value == null ? '' : value для строковых полей). В нашем случае рейтинг число, null логично трактовать как 0 (не выбрано ни одной звезды).
registerOnChange и registerOnTouched будут вызываться Angular при инициализации контрола. Фреймворк передаст нам функции, которые нужно запомнить. Мы сохраняем их в this.onChange и this.onTouched, позже мы сами вызовем их в нужный момент.
setDisabledState Angular вызовет, когда нужно включить или отключить наш контрол. В простейшем случае мы можем сохранить флаг и, например, в шаблоне делать кнопки дизейблед. Просто записываем isDisabled в наше свойство disabled. Реализовывать этот метод стоит, иначе при formControl.disable() ваш компонент не узнает, что его надо отключить. Более того, если компонент может быть использован в шаблонных формах, нужно дополнительно обрабатывать атрибут [disabled], потому что в шаблонных формах setDisabledState не вызывается для стандартных полей.
Вы могли заметить, что в методе writeValue мы ничего не вызываем, а просто ставим значение, а вот в будущем в методе, обрабатывающем клик пользователя, мы будем звать onChange. Это не случайно. Никогда не вызывайте onChange внутри writeValue — иначе получите бесконечный цикл или дублирование событий. writeValue вызывается, когда форма сообщает компоненту новое значение, поэтому не надо сообщать обратно форме о том же самом. Некоторые делают ошибку: переиспользуют внутренний метод обновления и вызывают в нем onChange всегда, из‑за чего при установке значения извне форма узнаёт о изменении дважды. В итоге valueChanges стреляют лишние события, и можно надолго зависнуть в отладке такого поведения.
Логика работы кастомного поля
Интерфейс реализован, провайдер добавили, но компонент пока бесполезен, он нигде не вызывает onChange и onTouched. Пора написать саму логику рейтинга. Представим простую реализацию: есть определенное количество звёзд (по умолчанию 5, но сделаем параметр maxRate), и мы хотим закрашивать (или подсвечивать) звёзды вплоть до выбранной. Пользователь кликает по звезде — мы сохраняем новый рейтинг и сообщаем форме об изменении. Также нужно сообщать о том, что контроль был затронут пользователем (touched).
Сделаем шаблон компонента с использованием Angular директивы *ngFor для генерации звёздочек:
<!-- star-rating.component.html -->
<span class="star"
*ngFor="let star of starsArray; let index = index"
[class.filled]="star <= currentRate"
(click)="setRate(star)">
★
</span>
Здесь:
-
starsArray— это массив чисел от 1 доmaxRate. Можно генерировать его на лету или в геттере, либо вngOnInit. Проще всего сделать геттер:get starsArray(): number[] { return Array.from({ length: this.maxRate }, (_, i) => i + 1); }Тогда
*ngForсгенерирует<span>для каждого числа от 1 до 5 (или доmaxRate, если задан). Каждой звезде мы ставим класс
filled, если ее номер меньше или равен текущему рейтингу, чтобы визуально выделять выбранные звезды.На клик по звезде вызываем метод
setRate(star)— передаем номер звезды.
Теперь напишем метод setRate в компоненте:
export class StarRatingComponent implements ControlValueAccessor {
// ... предыдущие поля и методы ...
setRate(rate: number): void {
if (this.disabled) {
return;
}
this.currentRate = rate;
this.onChange(rate); // сообщаем форме новое значение
this.onTouched(); // сообщаем, что контрол взаимодействовал (touched)
}
}
При клике по звезде, если компонент не отключен, мы обновляем внутреннее состояние currentRate и вызываем сохраненный колбэк onChange, передав туда новое значение. Тем самым Angular узнает о новом значении и обновит модель формы. Заодно вызываем onTouched(), помечаем, что контроль был тронут пользователем. В нашем случае любое нажатие сразу меняет значение, так что поле точно можно считать «touched».
Если бы у нас было поле, где пользователь может кликнуть или сфокусироваться, но не изменить значение, вызов onTouched лучше делать на событие blur или по факту взаимодействия даже без изменения значения. onChange сообщаем при изменении значения, onTouched, когда пользователь вообще взаимодействовал с контролом, даже если значение не поменялось.
На этом базовая логика готова. Форма, к которой будет подключен наш компонент, получит новое значение через onChange, а также узнает, что контроль теперь в состоянии touched (Angular поставит CSS‑класс ng-touched для этого поля и может валидировать ошибки.
Использование кастомного контрола в форме
Предположим, есть форма с полем рейтинга. Можно использовать компонент как в реактивной форме, так и в шаблонной, благодаря тому, что мы всё реализовали правильно.
Пример с реактивной формой:
// В компоненте родителе
this.feedbackForm = new FormGroup({
name: new FormControl(''), // просто текстовое поле
rating: new FormControl(3, Validators.required) // поле рейтинга, по умолчанию 3
});
<!-- В шаблоне родителя -->
<form [formGroup]="feedbackForm" (ngSubmit)="submitFeedback()">
<label>
Ваш рейтинг:
<app-star-rating formControlName="rating"></app-star-rating>
</label>
<button type="submit">Отправить</button>
</form>
<app-star-rating> теперь полноценный контрол формы. Атрибут formControlName="rating" говорит Angular: на этот компонент повесь FormControl с именем rating. Angular найдет в нашем компоненте провайдер NG_VALUE_ACCESSOR и будет знать, что для взаимодействия нужно использовать наши методы.
При инициализации он вызовет registerOnChange, registerOnTouched (передаст нам функции) и writeValue(3), потому что начальное значение рейтинга у нас установлено 3.
Наш компонент отобразит три заполненные звезды. Когда пользователь кликнет, например, на 5-ю звезду, наш код вызовет onChange(5), и Angular обновит значение feedbackForm.controls.rating на 5. Если форма уже была отправлена или пользователь уходил с поля, onTouched() пометит поле как затронутое.
Пример с шаблонной формой (Template‑driven Forms):
<form #form="ngForm" (ngSubmit)="send()">
<app-star-rating name="rating" [(ngModel)]="model.rating" [disabled]="isReadonly"></app-star-rating>
<button [disabled]="form.invalid">Send</button>
</form>
Для шаблонной формы мы используем директиву ngModel. Обратите внимание на несколько моментов:
У компонента должно быть прописано свойство
name, иначе NgModel не сможет его зарегистрировать внутри формы. В реактивных формах это не требуется, а вот в template‑driven обязательно.Двусторонняя привязка
[(ngModel)]связывает полеmodel.ratingс нашим компонентом. Angular будет под капотом вызывать наши методы CVA, но для разработчика это прозрачно.Атрибут
[disabled]привязан к некой переменнойisReadonly. Здесь важна наша реализация: при измененииisReadonlyAngular установит свойствоdisabledнашего компонента, и наш setter (setDisabledState) отработает либо нам самим нужно реализоватьdisabledчерез@Input()(как мы сделали). Мы это учли, поэтому компонент корректно отключится приisReadonly = true.
Отдельно замечу про одну ловушку шаблонных форм: инициализация ngModel иногда происходит с задержкой, из‑за чего writeValue может вызваться дважды — сперва с null, потом с реальным значение. Это давний известный баг/особенность Angular. В нашем примере это не страшно: сначала придет null, мы выставим рейтинг 0, потом сразу придет фактическое значение и перезапишет. Но будьте к этому готовы, если вдруг увидите лишний вызов writeValue при использовании [(ngModel)]. В реактивных формах такого поведения нет.
Надеюсь, статья получилась максимально полезной и помогла вам разобраться во всех деталях создания своего контролла. Если у вас остались вопросы или вы хотите поделиться своим опытом, смело пишите в комментариях. Удачной разработки и приятных случайностей.
Если вы работаете с формами в Angular и хотите уверенно создавать собственные компоненты, стоит обратить внимание на курс Angular Developer. На нём подробно разбираются такие темы, как реализация ControlValueAccessor, управление состоянием и взаимодействие с формами. Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.
А чтобы познакомиться с программой ближе, приглашаем вас на серию открытых уроков:
29 октября в 20:00 — Angular UI‑Kit с нуля: как построить библиотеку переиспользуемых компонентов
13 ноября в 20:00 — От нуля до пиццы за 60 минут: Angular Reactive Forms в бою
19 ноября в 20:00 — Создание приложения Movie Watchlist Manager на Angular: от компонентов до управления состоянием