Метаданные поля
Метаданные поля — это реактивные данные, которые можно прикрепить к отдельному полю. Встроенные валидаторы ограничений Angular, такие как required() и min(), используют эту систему внутри себя. Иными словами, каждый раз, когда вы вызываете валидатор, вы добавляете значение в ключ метаданных для соответствующего поля.
В этом руководстве подробно рассматривается система метаданных: как редьюсеры объединяют значения, добавленные несколькими правилами схемы, как писать собственные редьюсеры, как чтение сочетается с hasMetadata() и как управляемые метаданные связывают объекты с учетом жизненного цикла с отдельными полями.
Вы уже использовали метаданные
Когда вы вызываете required() в схеме и читаете .required() у полученного поля в шаблоне, вы используете систему метаданных. state.required — это не особое свойство. Это удобный геттер, который возвращает текущее значение встроенного ключа метаданных REQUIRED.
import {Component, signal} from '@angular/core';
import {form, required, FormField} from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<form>
<label>
Username
@if (registrationForm.username().required()) {
<span class="required-marker" aria-hidden="true">*</span>
}
<input [formField]="registrationForm.username" />
</label>
</form>
`,
})
export class Registration {
registrationModel = signal({username: ''});
registrationForm = form(this.registrationModel, (path) => {
required(path.username);
});
}
Вызов required(path.username) добавляет значение в ключ метаданных REQUIRED этого поля. Чтение registrationForm.username().required() возвращает накопленное значение. Ключ метаданных связывает эти два действия.
Несколько встроенных валидаторов ограничений работают по этой схеме:
| Валидатор | Ключ метаданных | Тип |
геттер FieldState |
|---|---|---|---|
required() | REQUIRED | boolean | required |
min() | MIN | number | undefined | min |
max() | MAX | number | undefined | max |
minLength() | MIN_LENGTH | number | undefined | minLength |
maxLength() | MAX_LENGTH | number | undefined | maxLength |
pattern() | PATTERN | RegExp[] | pattern |
Валидаторы, не проверяющие ограничения, такие как email() и validate(), не добавляют значения в метаданные. Они выполняют проверку и сообщают об ошибке валидации, но не публикуют реактивное значение, которое можно прочитать в шаблонах.
Когда использовать пользовательские метаданные
Если вам нужны реактивные данные, привязанные к определенному полю, которые не охватываются встроенными сигналами состояния, такими как valid(), disabled() и touched(), используйте пользовательские метаданные.
Примеры:
-
Конфигурация, привязанная к повторно используемым схемам полей. Символ валюты для поля цены, чтобы любой шаблон или пользовательский элемент управления, отображающий это поле, мог его вывести. Или
MIN_DATEиMAX_DATEдля поля даты, которые считывает повторно используемый компонент выбора диапазона. - Разобранные значения, общие для нескольких правил одного поля. Номер телефона разбирается один раз в формат E.164, чтобы валидатор формата и проверка уникальности могли использовать одно и то же каноническое представление без повторного разбора.
-
Подсказки для отображения, сформированные на основе состояния поля. Уровень серьезности (
'info' | 'warning' | 'error'), который интерфейс отображает в виде значков и меток, или контекстная справочная подсказка, меняющаяся в зависимости от введенных пользователем данных и заполненности других полей.
Если вы храните отдельный Map<fieldKey, value> наряду с формой, чтобы отслеживать данные для каждого поля, это признак того, что метаданные — подходящий инструмент. Метаданные хранятся рядом со схемой, остаются реактивными и учитывают жизненный цикл поля.
Создание ключа метаданных
Чтобы создать пользовательский ключ, вызовите createMetadataKey<TWrite>(). Параметр типа описывает значения, которые будут добавлять правила схемы.
import {createMetadataKey} from '@angular/forms/signals';
export const USERNAME_HELP = createMetadataKey<string>();
Каждый вызов createMetadataKey() создает новый уникальный ключ. Даже если параметры типа совпадают, два вызова создадут два разных ключа. Поэтому определяйте каждый ключ один раз на уровне модуля и импортируйте его там, где он нужен.
ПРИМЕЧАНИЕ: По умолчанию ключ, созданный без редьюсера, использует семантику «переопределения»: если несколько правил задают значение ключа, побеждает последнее.
Задание значений в схеме
Чтобы зарегистрировать значение ключа для определенного поля, используйте metadata(path, key, logic) внутри функции схемы.
import {Component, computed, signal} from '@angular/core';
import {form, metadata, FormField} from '@angular/forms/signals';
import {USERNAME_HELP} from './metadata-keys';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<form>
<label>
Username
<input [formField]="registrationForm.username" />
</label>
<p class="help">{{ usernameHelp() }}</p>
</form>
`,
})
export class Registration {
registrationModel = signal({username: ''});
registrationForm = form(this.registrationModel, (path) => {
metadata(path.username, USERNAME_HELP, ({value}) => {
const username = value();
if (username.length === 0) {
return 'Choose a unique username between 3 and 20 characters.';
}
if (username.length < 3) {
return 'Keep typing, usernames are at least 3 characters.';
}
if (username.length > 20) {
return 'Usernames are at most 20 characters.';
}
return 'Looks good.';
});
});
usernameHelp = computed(() => this.registrationForm.username().metadata(USERNAME_HELP)?.() ?? '');
}
Логическая функция получает контекст поля, в котором доступны value — сигнал текущего значения поля, state — FieldState поля и такие методы, как valueOf(path) и stateOf(path), для чтения других полей той же формы. Все сигналы, прочитанные функцией, становятся реактивными зависимостями: когда меняется value(), метаданные пересчитываются, а все шаблоны, читающие ключ, обновляются.
Чтение метаданных поля
hasMetadata(key) возвращает true, если какое-либо правило схемы зарегистрировало ключ для этого поля. state.metadata(key) возвращает undefined, если ни одно правило не зарегистрировало ключ, и сигнал с текущим редуцированным значением в противном случае.
registrationForm.username().hasMetadata(USERNAME_HELP); // true if any metadata() rule registered this key
Структура этого внутреннего значения (может ли оно само быть undefined и какой тип оно содержит) зависит от редьюсера ключа. Редьюсеры рассматриваются в следующем разделе.
Если ключ может быть не зарегистрирован, проверяйте это перед чтением с помощью hasMetadata():
@if (registrationForm.username().hasMetadata(USERNAME_HELP)) {
<p class="help">{{ registrationForm.username().metadata(USERNAME_HELP)!() }}</p>
}
Если вы уверены, что правило всегда регистрирует ключ (поскольку это делает схема в том же файле), проверку hasMetadata() можно пропустить и использовать необязательную цепочку как компактную альтернативу:
const message = registrationForm.username().metadata(USERNAME_HELP)?.(); // message: string | undefined
Если же правило гарантированно зарегистрировало ключ, уберите необязательную цепочку и используйте утверждение:
const message = registrationForm.username().metadata(USERNAME_HELP)!(); // message: string | undefined (still, because the inner value may be undefined)
В приведенном выше примере компонента необязательная цепочка используется внутри computed(), поэтому шаблон привязывается к обычному string, используя пустое значение по умолчанию для первоначального кадра.
Это весь API для одного источника значений. В следующем разделе рассматривается, что происходит, когда несколько правил схемы добавляют значения в один ключ, и как объединить их с помощью редьюсеров.
Объединение значений с помощью редьюсеров
Семантика переопределения подходит, когда для определенного поля значение в ключ добавляет только одно правило. Как только значения добавляют два правила, первое значение молча отбрасывается:
const HELP = createMetadataKey<string>();
form(model, (path) => {
metadata(path.username, HELP, () => 'Choose something unique across the system.');
metadata(path.username, HELP, () => 'Usernames are 3 to 20 characters.');
});
После выполнения обоих правил state.metadata(HELP)!() возвращает только второе сообщение. Обычно это совсем не то, что вам нужно. Значения часто поступают из разных источников: например, две схемы, объединенные с помощью apply(), каждая из которых добавляет справочный текст, или несколько правил валидации, добавляющих подсказки.
Чтобы объединить значения, передайте редьюсер в createMetadataKey(). Редьюсер описывает, как объединять отдельные значения в накопленный результат:
import {createMetadataKey, MetadataReducer} from '@angular/forms/signals';
const HELP = createMetadataKey<string, string[]>(MetadataReducer.list());
form(model, (path) => {
metadata(path.username, HELP, () => 'Choose something unique across the system.');
metadata(path.username, HELP, () => 'Usernames are 3 to 20 characters.');
});
// state.metadata(HELP)!() === [
// 'Choose something unique across the system.',
// 'Usernames are 3 to 20 characters.',
// ]
Обратите внимание на два параметра типа в createMetadataKey<TWrite, TAcc>: первый — это тип значений, добавляемых каждым правилом, второй — тип, возвращаемый редьюсером. При использовании list() правила добавляют значения типа string, а поле возвращает значение типа string[].
Встроенные редьюсеры
Angular предоставляет шесть встроенных редьюсеров в пространстве имен MetadataReducer. У override() есть две формы с немного различающейся семантикой; в таблице они перечислены отдельно:
| Редьюсер | Тип аккумулятора | Описание | Начальное значение |
|---|---|---|---|
list<T>() | T[] | Принимает значения типа T | undefined; добавляет значения, отличные от undefined | [] |
or() | boolean |
true, если хотя бы одно значение равно true
| false |
and() | boolean |
true, только если каждое значение равно true
| true |
min() | number | undefined | Сохраняет наименьшее добавленное число | undefined |
max() | number | undefined | Сохраняет наибольшее добавленное число | undefined |
override() | T | undefined | Последнее значение заменяет предыдущее (поведение по умолчанию) | undefined |
override(fn) | T | То же самое, но с заданным начальным значением | fn() |
list() — единственный встроенный редьюсер, тип элемента которого шире типа элемента аккумулятора. Правило может добавить undefined, и редьюсер молча отбросит это значение. Так встроенный ключ PATTERN обрабатывает динамические правила pattern(), логическая функция которых возвращает undefined: значение undefined пропускается, а не включается в итоговый список регулярных выражений.
Как ключи встроенных валидаторов используют редьюсеры
Хотя MetadataReducer.min() и MetadataReducer.max() являются редьюсерами, вас может удивить, что это не валидаторы. MetadataReducer.min() выбирает наименьшее значение, добавленное в ключ, тогда как валидатор min() задает нижнюю границу значения поля. У них общее название, но решают они разные задачи.
Встроенные ключи ограничений выбирают редьюсеры исходя из того, что означает «самое строгое» ограничение. Часто это противоположно тому, что можно предположить по названию ключа:
| Ключ | Редьюсер | Обоснование |
|---|---|---|
REQUIRED | or() | Если какое-либо правило required() дает результат true, поле является обязательным. |
MIN | max() | Ограничение минимального значения строже при большем значении. Если одно правило требует >= 5, а другое — >= 10, эффективный минимум равен 10. |
MAX | min() | Ограничение максимального значения строже при меньшем значении. Если одно правило устанавливает предел 100, а другое — 50, эффективный максимум равен 50. |
MIN_LENGTH | max() | Та же логика, что и для MIN: побеждает наибольшая из требуемых длин. |
MAX_LENGTH | min() | Та же логика, что и для MAX: побеждает наименьшая из разрешенных длин. |
PATTERN | list<RegExp>() | Каждый вызов pattern() добавляет регулярное выражение; значение должно соответствовать всем таким выражениям. |
Именно поэтому подход «побеждает самое строгое ограничение» позволяет правильно использовать min(path.age, 18) и min(path.age, 21) в двух объединенных схемах. Каждый вызов регистрирует собственный валидатор, который проверяет заданное им ограничение (поэтому значение ниже любого из этих ограничений не пройдет проверку). Кроме того, каждый вызов добавляет значение в общедоступный ключ MIN, а state.metadata(MIN)!() возвращает агрегированное значение (21), чтобы интерфейс и пользовательские элементы управления могли считывать эффективный минимум.
Создание пользовательского редьюсера
Чтобы написать собственный редьюсер, реализуйте объект, соответствующий интерфейсу MetadataReducer<TAcc, TItem>:
interface MetadataReducer<TAcc, TItem> {
reduce: (acc: TAcc, item: TItem) => TAcc;
getInitial: () => TAcc;
}
Пользовательский редьюсер можно определить, если ни один из встроенных не соответствует нужной семантике. Например, ключ SEVERITY, сохраняющий наиболее высокий уровень серьезности из добавленных правилами:
import {createMetadataKey, type MetadataReducer} from '@angular/forms/signals';
type Severity = 'info' | 'warning' | 'error';
const SEVERITY_RANK: Record<Severity, number> = {info: 0, warning: 1, error: 2};
const maxSeverity: MetadataReducer<Severity | undefined, Severity> = {
reduce(acc, item) {
if (acc === undefined) return item;
return SEVERITY_RANK[item] > SEVERITY_RANK[acc] ? item : acc;
},
getInitial: () => undefined,
};
export const SEVERITY = createMetadataKey<Severity, Severity | undefined>(maxSeverity);
Теперь любое количество правил может добавлять уровень серьезности, а поле будет возвращать самый высокий:
form(model, (path) => {
metadata(path.password, SEVERITY, () => 'info');
metadata(path.password, SEVERITY, ({value}) => (value().length < 12 ? 'warning' : 'info'));
metadata(path.password, SEVERITY, ({value}) =>
/password|1234/i.test(value()) ? 'error' : 'info',
);
});
Редьюсер выполняется при изменении сигналов любого из добавленных значений, поэтому state.metadata(SEVERITY)!() всегда содержит актуальное наихудшее значение среди всех правил.
СОВЕТ: Пишите редьюсеры без побочных эффектов: reduce() должен зависеть только от двух аргументов, а getInitial() должен возвращать одно и то же значение при каждом вызове. Редьюсеры выполняются внутри реактивного вычисления, которое запускается повторно при изменении сигналов любого из добавленных значений, поэтому редьюсеры с побочными эффектами приводят к несогласованным метаданным.
Привязка объектов с учетом жизненного цикла с помощью управляемых метаданных
Управляемые метаданные хранят в поле объект с учетом жизненного цикла, а не реактивное значение. Используйте их для объектов, привязанных к отдельному полю, например resource(), который получает внешние данные, effect(), синхронизирующего данные с внешней системой, или дескриптора сервиса, область действия которого ограничена одним полем.
Создание управляемого ключа
Чтобы определить управляемый ключ, вызовите createManagedMetadataKey<TRead, TWrite>(create). Переданная функция create создает значение, хранящееся в ключе.
import {Signal} from '@angular/core';
import {httpResource} from '@angular/common/http';
import {createManagedMetadataKey} from '@angular/forms/signals';
export interface UrlPreview {
title: string;
description?: string;
image?: string;
}
export const URL_PREVIEW = createManagedMetadataKey((_state, url: Signal<string | undefined>) => {
return httpResource<UrlPreview>(() => {
const currentUrl = url();
return currentUrl ? {url: '/api/url-preview', params: {url: currentUrl}} : undefined;
});
});
Функция create получает FieldState поля и Signal<TAcc> данных, добавленных правилами metadata() для этого ключа, и возвращает объект, который должен храниться в поле. Возвращаемое значение сохраняется как есть: в отличие от неуправляемых ключей, фреймворк не оборачивает его в computed().
create выполняется один раз при создании поля, в контексте внедрения зависимостей этого поля. Это позволяет вызывать inject(), resource() и effect() внутри create и связывает очистку с жизненным циклом поля: когда поле уничтожается, Angular уничтожает контекст внедрения зависимостей, а любые зарегистрированные в нем обратные вызовы resource(), effect() или DestroyRef автоматически очищают ресурсы.
Поскольку create сам по себе не реактивен, поведение, которое должно реагировать на изменения сигналов, нужно реализовать внутри effect(), resource() или httpResource(), созданных при первоначальном вызове. URL_PREVIEW демонстрирует этот подход: httpResource() считывает сигнал URL внутри функции запроса, поэтому запрос выполняется повторно при каждом изменении сигнала. Правило схемы (metadata(path.url, URL_PREVIEW, ({value}) => value())) определяет, какие данные передать, а управляемый ключ — что с ними делать.
Использование управляемого ключа в форме
Чтобы использовать управляемый ключ в форме, зарегистрируйте правило metadata() для этого ключа, а затем считайте возвращенный объект из состояния поля.
import {Component, computed, signal} from '@angular/core';
import {applyEach, form, metadata, FormField} from '@angular/forms/signals';
import {URL_PREVIEW} from './url-preview';
@Component({
selector: 'app-link-editor',
imports: [FormField],
template: `
<form>
@for (link of linksForm.links; track link) {
<fieldset>
<label>
URL
<input [formField]="link.url" />
</label>
<!-- Read the URL_PREVIEW key for this link's url field; the result is the resource its create function produced -->
@let preview = link.url().metadata(URL_PREVIEW);
@if (preview?.isLoading()) {
<p>Loading preview...</p>
} @else if (preview?.hasValue() && preview.value(); as data) {
<article class="preview">
<h3>{{ data.title }}</h3>
@if (data.description) {
<p>{{ data.description }}</p>
}
</article>
} @else if (preview?.error()) {
<p class="error">Could not load preview.</p>
}
</fieldset>
}
<button type="button" (click)="addLink()">Add link</button>
</form>
`,
})
export class LinkEditor {
linksModel = signal({links: [{url: ''}]});
linksForm = form(this.linksModel, (path) => {
// Register the URL_PREVIEW key on each link's url field.
// applyEach runs the schema per item, so create() runs once per link
// and each link gets its own resource.
applyEach(path.links, (itemPath) => {
metadata(itemPath.url, URL_PREVIEW, ({value}) => value());
});
});
addLink() {
this.linksForm.links().value.update((links) => [...links, {url: ''}]);
}
}
Каждый элемент массива получает собственный ресурс URL_PREVIEW, поскольку applyEach независимо регистрирует правила схемы для каждого элемента. Когда пользователь добавляет ссылку, для поля нового элемента выполняется create. Когда ссылка удаляется (здесь этот случай не показан, но это распространенный подход), фреймворк уничтожает инжектор поля вместе с ресурсом.
Дальнейшие действия
Помните, что метаданные нужны для того, чтобы реактивные данные перемещались вместе с полем при объединении схем, накапливались при выполнении правил и очищались в соответствии с жизненным циклом поля. Они используют ту же систему, что и встроенные валидаторы Angular, и могут быть адаптированы под ваши задачи.
Подробную документацию по API см. здесь:
-
createMetadataKey()— определение ключа метаданных с необязательным редьюсером -
createManagedMetadataKey()— определение ключа метаданных с учетом жизненного цикла -
metadata()— добавление значения в ключ метаданных в схеме -
MetadataReducer— встроенные редьюсеры для объединения значений
Другие руководства по Signal Forms:
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/forms/signals/field-metadata