Проектирование модели формы
Signal Forms использует подход, основанный на модели: состояние и структура формы напрямую определяются предоставленной вами моделью. Поскольку она служит основой всей формы, важно начать с хорошо спроектированной модели формы. В этом руководстве рассматриваются рекомендации по проектированию моделей форм.
Модель формы и доменная модель
Формы используются для сбора данных от пользователя. В вашем приложении, вероятно, есть доменная модель, предназначенная для представления этих данных в виде, оптимизированном для бизнес-логики или хранения. Однако зачастую она отличается от того, как мы хотим моделировать данные в форме.
Модель формы представляет исходные пользовательские данные в том виде, в каком они отображаются в интерфейсе. Например, в форме можно попросить пользователя выбрать дату и временной интервал для встречи в отдельных полях ввода, даже если доменная модель представляет их как один объект JavaScript Date.
interface AppointmentFormModel {
name: string; // Appointment owner's name
date: Date; // Appointment date (carries only date information, time component is unused)
time: string; // Selected time as a string
}
interface AppointmentDomainModel {
name: string; // Appointment owner's name
time: Date; // Appointment time (carries both date and time information)
}
Для форм следует использовать модель, адаптированную к процессу ввода данных, а не просто повторно применять доменную модель.
Рекомендации по проектированию модели формы
Используйте конкретные типы
Всегда определяйте интерфейсы или типы для своих моделей, как показано в разделе Использование типов TypeScript. Явные типы обеспечивают более качественные подсказки IntelliSense, позволяют обнаруживать ошибки во время компиляции и служат документацией о данных, содержащихся в форме.
Инициализируйте все поля
Задайте начальные значения для каждого поля модели:
const taskModel = signal({
title: '',
description: '',
priority: 'medium',
completed: false,
});
const taskModel = signal({
title: '',
// Missing description, priority, completed
});
Если начальные значения не заданы, эти поля не будут включены в дерево полей и станут недоступны для взаимодействия с формой.
Сосредоточьтесь на конкретной задаче модели
Каждая модель должна представлять одну форму или связанный набор данных:
const loginModel = signal({
email: '',
password: '',
});
const appModel = signal({
// Login data
email: '',
password: '',
// User preferences
theme: 'light',
language: 'en',
// Shopping cart
cartItems: [],
});
Разделение моделей для разных задач упрощает понимание форм и их повторное использование. Если вы работаете с отдельными наборами данных, создайте несколько форм.
Учитывайте требования к валидации
Проектируйте модели с учетом валидации. Группируйте поля, которые валидируются вместе:
// Password fields grouped for comparison
interface PasswordChangeData {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
Такая структура упрощает валидацию нескольких полей (например, проверку, что newPassword совпадает с confirmPassword).
Сопоставляйте типы данных с элементами управления интерфейса
Типы свойств модели формы должны соответствовать типам данных, которые ожидаются элементами управления интерфейса.
Например, рассмотрим форму заказа напитков с полем size (упаковка из 6, 12 или 24 штук) и полем quantity. В интерфейсе для размера используется раскрывающийся список (<select>), а для количества — числовое поле ввода (<input type="number">).
Хотя варианты размера выглядят как числа, элементы <select> работают со строковыми значениями, поэтому size следует моделировать как строку. Элемент <input type="number">, напротив, работает с числами, поэтому quantity можно моделировать как число.
interface BeverageOrderFormModel {
size: string; // Bound to: <select> (option values: "6", "12", "24")
quantity: number; // Bound to: <input type="number">
} Избегайте undefined
Модель формы не должна содержать значения или свойства undefined. В Signal Forms структура формы определяется структурой модели, а undefined означает отсутствие поля, а не поле с пустым значением. Поэтому следует также избегать необязательных полей (например, {property?: string}), поскольку они неявно допускают undefined.
Чтобы представить свойство с пустым значением в модели формы, используйте значение, которое элемент управления интерфейса воспринимает как «пустое» (например, "" для <input type="text">). Если вы проектируете собственный элемент управления интерфейса, null часто подходит в качестве значения, обозначающего «пустое».
interface UserFormModel {
name: string; // Bound to <input type="text">
birthday: Date | null; // Bound to <input type="date">
}
// Initialize our form with empty values.
form(signal({name: '', birthday: null})); Избегайте моделей с динамической структурой
Структура модели формы является динамической, если ее форма меняется (то есть меняется набор свойств объекта) в зависимости от ее значения. Это происходит, когда тип модели допускает значения с разной структурой, например объединение типов объектов с разными свойствами или объединение объекта с примитивным типом. В следующих разделах рассмотрено несколько распространенных ситуаций, в которых модели с динамической структурой могут показаться привлекательными, но в итоге оказываются проблематичными.
Пустое значение для сложного объекта
Часто с помощью форм просят пользователей ввести новые данные, а не изменить уже имеющиеся в системе. Хороший пример — форма создания учетной записи. Ее можно смоделировать с помощью следующей модели формы.
interface CreateAccountFormModel {
name: {
first: string;
last: string;
};
username: string;
}
При создании формы возникает вопрос: каким должно быть начальное значение модели? Может показаться заманчивым создать form<CreateAccountFormModel | null>(), поскольку пользователь еще не ввел никаких данных.
createAccountForm = form<CreateAccountFormModel | null>(signal(/* what goes here, null? */));
Однако важно помнить, что Signal Forms управляется моделью. Если наша модель — null, а у null нет свойства name или username, это означает, что в форме также не будет соответствующих вложенных полей. На самом деле нам нужен экземпляр CreateAccountFormModel, у которого всем конечным полям присвоены пустые значения.
createAccountForm = form<CreateAccountFormModel>(
signal({
name: {
first: '',
last: '',
},
username: '',
}),
);
При таком представлении все необходимые вложенные поля существуют, и мы можем связать их с помощью директивы [formField] в шаблоне.
First: <input [formField]="createAccountForm.name.first" /> Last: <input [formField]="createAccountForm.name.last" /> Username: <input [formField]="createAccountForm.username" />
Поля, условно скрытые или недоступные
Формы не всегда бывают линейными. Часто нужно создавать условные сценарии, основанные на предыдущих ответах пользователя. Например, в форме можно предложить пользователю несколько способов оплаты. Для начала представим, как может выглядеть интерфейс такой формы.
Name: <input type="text" />
<section>
<h2>Payment Info</h2>
<input type="radio" /> Credit Card @if (/* credit card selected */) {
<section>
Card Number <input type="text" /> Security Code <input type="text" /> Expiration
<input type="text" />
</section>
}
<input type="radio" /> Bank Account @if (/* bank account selected */) {
<section>Account Number <input type="text" /> Routing Number <input type="text" /></section>
}
</section>
Лучший способ решить эту задачу — использовать модель формы со статической структурой, включающей поля для всех возможных способов оплаты. В схеме можно скрыть или отключить поля, которые в данный момент недоступны.
interface BillPayFormModel {
name: string;
method: {
type: string;
card: {
cardNumber: string;
securityCode: string;
expiration: string;
};
bank: {
accountNumber: string;
routingNumber: string;
};
};
}
const billPaySchema = schema<BillPayFormModel>((billPay) => {
// Hide credit card details when user has selected a method other than credit card.
hidden(billPay.method.card, {when: ({valueOf}) => valueOf(billPay.method.type) !== 'card'});
// Hide bank account details when user has selected a method other than bank account.
hidden(billPay.method.bank, {when: ({valueOf}) => valueOf(billPay.method.type) !== 'bank'});
});
При использовании этой модели объекты card и bank всегда присутствуют в состоянии формы. Когда пользователь переключает способ оплаты, мы обновляем только свойство type. Введенные им данные карты остаются в объекте card, и их можно снова отобразить, если пользователь переключится обратно.
Для этого сценария динамическая модель формы поначалу может показаться подходящим решением. Ведь если пользователь выбрал «Кредитная карта», поля для номера счета и банковского кода нам не нужны. Может возникнуть желание представить эту модель как дискриминированное объединение:
interface BillPayFormModel {
name: string;
method:
| {
type: 'card';
cardNumber: string;
securityCode: string;
expiration: string;
}
| {
type: 'bank';
accountNumber: string;
routingNumber: string;
};
}
Однако представьте, что произойдет в следующей ситуации:
- Пользователь вводит свое имя и данные кредитной карты.
- Он собирается отправить форму, но в последний момент замечает комиссию за удобство.
- Он переключается на оплату с банковского счета, решив заодно избежать комиссии.
- Собираясь ввести данные банковского счета, он передумывает: ему не хотелось бы, чтобы эти данные попали в утечку.
- Он переключается обратно на оплату кредитной картой и обнаруживает, что все введенные данные исчезли!
Это иллюстрирует еще одну проблему моделей формы с динамической структурой: они могут приводить к потере данных. Такая модель предполагает, что после скрытия поля содержащаяся в нем информация больше никогда не понадобится. Данные кредитной карты заменяются данными банковского счета, и восстановить данные карты уже невозможно.
Исключения
Хотя статическая структура обычно предпочтительна, есть отдельные сценарии, в которых динамическая структура необходима и поддерживается.
Массивы
Массивы — наиболее распространенное исключение. В формах часто нужно собирать переменное количество элементов, например список телефонных номеров, участников или позиций заказа.
interface SendEmailFormModel {
subject: string;
recipientEmails: string[];
}
В этом случае массив recipientEmails увеличивается и уменьшается по мере взаимодействия пользователя с формой. Длина массива может меняться, но структура отдельных элементов должна быть одинаковой (каждый элемент должен иметь одну и ту же форму).
Поля, обрабатываемые элементом управления интерфейса как единое целое
Еще один случай, в котором допустима динамическая структура, — это обработка сложного объекта элементом управления интерфейса как единого атомарного значения. То есть элемент управления не пытается связать или получить доступ к его вложенным полям по отдельности. В этом случае элемент управления обновляет значение, заменяя весь объект целиком, а не изменяя его внутренние свойства. Поскольку в этом сценарии структура формы не имеет значения, она может быть динамической.
Например, рассмотрим форму профиля пользователя, содержащую поле location. Местоположение выбирается с помощью сложного виджета «выбор местоположения» (например, карты или раскрывающегося списка с поиском), который возвращает объект координат. Если местоположение еще не выбрано или пользователь не хочет делиться своим местоположением, виджет указывает его как null.
interface Location {
lat: number;
lng: number;
}
interface UserProfileFormModel {
username: string;
// This property has dynamic structure,
// but that's ok because the location picker treats this field as atomic.
location: Location | null;
}
В шаблоне мы связываем поле location непосредственно с собственным элементом управления:
Username: <input [formField]="userForm.username" /> Location: <location-picker [formField]="userForm.location"></location-picker>
Здесь <location-picker> получает и возвращает объект Location целиком (или null), не обращаясь к userForm.location.lat или userForm.location.lng. Поэтому location может безопасно иметь динамическую форму, не нарушая принципы форм, управляемых моделью.
Преобразование модели формы в доменную модель и обратно
Поскольку модель формы и доменная модель по-разному представляют одно и то же понятие, нам нужен способ преобразования между этими представлениями. Чтобы показать пользователю в форме имеющиеся в системе данные, необходимо преобразовать их из представления доменной модели в представление модели формы. И наоборот, чтобы сохранить изменения пользователя, нужно преобразовать данные из представления модели формы в представление доменной модели.
Представим, что у нас есть доменная модель и модель формы, а также функции для преобразования между ними.
interface MyDomainModel { ... }
interface MyFormModel { ... }
// Instance of `MyFormModel` populated with empty input (e.g. `''` for string inputs, etc.)
const EMPTY_MY_FORM_MODEL: MyFormModel = { ... };
function domainModelToFormModel(domainModel: MyDomainModel): MyFormModel { ... }
function formModelToDomainModel(formModel: MyFormModel): MyDomainModel { ... } Преобразование доменной модели в модель формы
Когда мы создаем форму для редактирования имеющейся в системе доменной модели, обычно получаем ее либо в виде input() компонента формы, либо с сервера (например, через ресурс). В обоих случаях linkedSignal отлично подходит для применения преобразования.
Если мы получаем доменную модель в виде input(), можно использовать linkedSignal, чтобы создать записываемую модель формы на основе входного сигнала.
@Component(...)
class MyForm {
// The domain model to initialize the form with, if not given we start with an empty form.
readonly domainModel = input<MyDomainModel>();
private readonly formModel = linkedSignal({
// Linked signal based on the domain model
source: this.domainModel,
// If domain model is defined convert it to a form model, otherwise use an empty form model.
computation: (domainModel) => domainModel
? domainModelToFormModel(domainModel)
: EMPTY_MY_FORM_MODEL
});
protected readonly myForm = form(this.formModel);
}
Аналогично, если мы получаем доменную модель с сервера через ресурс, можно создать linkedSignal на основе его значения, чтобы получить formModel. В этом случае загрузка доменной модели может занять некоторое время, поэтому форму следует отключить до загрузки данных.
@Component(...)
class MyForm {
// Fetch the domain model from the backend.
readonly domainModelResource: ResourceRef<MyDomainModel | undefined> = httpResource(...);
private readonly formModel = linkedSignal({
// Linked signal based on the domain model resource
source: this.domainModelResource.value,
// Convert the domain model once it loads, use an empty form model while loading.
computation: (domainModel) => domainModel
? domainModelToFormModel(domainModel)
: EMPTY_MY_FORM_MODEL
});
protected readonly myForm = form(this.formModel, (root) => {
// Disable the entire form when the resource is loading.
disabled(root, {when: () => this.domainModelResource.isLoading()});
});
}
В приведенных выше примерах модель формы напрямую выводится из доменной модели. Однако в некоторых случаях может потребоваться более сложное сравнение нового значения доменной модели с предыдущими значениями доменной модели и модели формы. Это можно реализовать с помощью linkedSignal и его предыдущего состояния.
Преобразование модели формы в доменную модель
Когда нужно сохранить введенные пользователем данные в системе, следует преобразовать их в представление доменной модели. Обычно это происходит при отправке формы пользователем или непрерывно по мере ввода данных в форме с автосохранением.
Чтобы сохранять данные при отправке формы, можно выполнить преобразование в функции submit.
@Component(...)
class MyForm {
private readonly myDataService = inject(MyDataService);
protected readonly myForm = form<MyFormModel>(...);
handleSubmit() {
submit(this.myForm, async () => {
await this.myDataService.update(formModelToDomainModel(this.myForm.value()));
});
};
}
Также можно отправлять модель формы непосредственно на сервер и преобразовывать ее в доменную модель на сервере.
Для непрерывного сохранения обновляйте доменную модель в effect.
@Component(...)
class MyForm {
readonly domainModel = model.required<MyDomainModel>()
protected readonly myForm = form(...);
constructor() {
effect(() => {
// When the form model changes to a valid value, update the domain model.
if (this.myForm().valid()) {
this.domainModel.set(formModelToDomainModel(this.myForm.value()));
}
});
};
}
В приведенных выше примерах модель формы просто преобразуется в доменную модель. Однако вполне допустимо учитывать не только значение модели формы, но и все состояние формы. Например, чтобы экономить трафик, можно отправлять на сервер только частичные обновления, соответствующие изменениям пользователя. В этом случае функцию преобразования можно создать так, чтобы она принимала все состояние формы и возвращала разреженную доменную модель на основе значений и признаков изменения полей формы.
type Sparse<T> = T extends object ? {
[P in keyof T]?: Sparse<T[P]>;
} : T;
function formStateToPartialDomainModel(
formState: FieldState<MyFormModel>
): Sparse<MyDomainModel> { ... }
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/model-design