Spec-Zone.ru › Angular

Проектирование модели формы

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;
      };
}

Однако представьте, что произойдет в следующей ситуации:

  1. Пользователь вводит свое имя и данные кредитной карты.
  2. Он собирается отправить форму, но в последний момент замечает комиссию за удобство.
  3. Он переключается на оплату с банковского счета, решив заодно избежать комиссии.
  4. Собираясь ввести данные банковского счета, он передумывает: ему не хотелось бы, чтобы эти данные попали в утечку.
  5. Он переключается обратно на оплату кредитной картой и обнаруживает, что все введенные данные исчезли!

Это иллюстрирует еще одну проблему моделей формы с динамической структурой: они могут приводить к потере данных. Такая модель предполагает, что после скрытия поля содержащаяся в нем информация больше никогда не понадобится. Данные кредитной карты заменяются данными банковского счета, и восстановить данные карты уже невозможно.

Исключения

Хотя статическая структура обычно предпочтительна, есть отдельные сценарии, в которых динамическая структура необходима и поддерживается.

Массивы

Массивы — наиболее распространенное исключение. В формах часто нужно собирать переменное количество элементов, например список телефонных номеров, участников или позиций заказа.

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

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API