Связка React+MobX хорошо себя зарекомендовала при работе с формами, в то время как за реактивность модели данных в Angular обычно отвечает библиотека RxJS. Но что делать, если вы хотите воспользоваться преимуществами Angular в React или Node.js? В этой статье речь пойдет о новой библиотеке от Cloud X, которую мы разработали для того, чтобы проложить “мостик” из мира Angular, где всё богато, но дорого в мир React, где все дешево, но скудно. В этой статье я описываю применение ядра @cloudx/react-ui-kit-forms, которое отвечает за структуру модели данных, реактивность модели данных и контроль состава данных (валидацию), позволяя “скрестить” плюсы React и Angular на одном проекте. 

Эта статья является продолжением первой публикации о преимуществах и недостатках React и Angular с точки зрения создания форм. Там вы найдете плюсы и минусы каждого из фреймворков, а также те мои размышления, которые натолкнули меня на идею перенести практики разработки форм на Angular для связки React+MobX. Сегодня я подробно расскажу, как устроена библиотека @cloudx/react-ui-kit-forms, которая реализует структуру хранения данных, методы манипуляций данными и точки отслеживания изменений.

Начинаем с основы

В основу библиотеки легли интерфейсы моделей форм из пакета @angular/forms, представленные тремя основными классами: AbstactControl, FormControl и FormGroup. Безусловно, в силу применяемого технологического стека от оригинала остались только основная парадигма организации структур данных, распространения событий и семантика интерфейсов классов. На этом предлагаю переходить от описательной части к практической.  В качестве практического примера предлагаю рассмотреть типовую форму регистрации пользователя, состоящую из следующих полей: name, phone, zip, address, comment, agreement. Я хочу сделать форму, состоящую из нескольких секций. Для дальнейшего удобства чтения и сопровождения кода я разделю модель также на несколько секций. Пусть модель данных выглядит так:

{
	personal: {
    	name: string;
    	phone: string;
	};
	address: {
    	zip: number;
    	address: string;
	};
	comment: string;
	agreement: boolean;
}

По устоявшимся правилам профессиональной разработки, создадим интерфейсы, описывающие выбранную структуру данных:

interface PersonalInfo {
	name: FormControl<string>;
	phone: FormControl<string>;
}
interface Address {
	zip: FormControl<number>;
	street: FormControl<string>;
}
interface MyForm {
	personal: FormGroup<PersonalInfo>;
	address: FormGroup<Address>;
	comment: FormControl<string>;
	agreement: FormControl<boolean>;
}

Следующим шагом потребуется создать в проекте MobX store:

class UserRegistrationStore {
	protected _form: FormGroup<MyForm>;
 
    constructor() {
        this._form = new FormGroup<MyForm>({
            personal: new FormGroup<PersonalInfo>({
                name: new FormControl<string>(''),
                phone: new FormControl<string>(''),
        	}),
            address: new FormGroup<Address>({
                zip: new FormControl<number>(1001001),
                street: new FormControl<string>(''),
        	}),
            comment: new FormControl<string>(''),
            agreement: new FormControl<boolean>(false),
    	});
	}
 
	public get form(): FormGroup<MyForm> {
    	return this._form;
	}
}

Теперь от завершения создания модели данных нас отделяет всего лишь один шаг! Где-то в инициализации нашего приложения нужно инстанцировать стор:

export const userRegistrationStore = new UserRegistrationStore(); 

Всё, на этом создание модели завершено и подошло время организовать взаимодействие модели с UI слоем.

UI слой

В зависимости от выбранной библиотеки UI компонентов реализация будет немного отличаться, так что для примера я ограничусь стандартной HTML разметкой и создам простой React компонент:

const HTMLInputComponent = observer(() => {
	const control = userRegistrationStore.form.getControl('comment');
 
	return (
        <input
            type='text'
            value={control.value}
            onChange={(event) => {
                control.setValue(event.target.value);
        	}}
    	/>
	);
});

На этом связка слоя модели и UI слоя заканчивается, и мы получаем двустороннюю привязку данных, что позволяет получать в модели все изменения, сделанные пользователем в UI и одновременно с этим отображать пользователю все изменения, сделанные в модели. Актуальное состояние модели отражается в свойстве элемента управления value:

const formValue = userRegistrationStore.form.value;

Она имеет структуру данных, которую мы определили в самом начале.

Валидация значений

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

export interface ValidatorFn {
	(control: AbstractControl): string | null;
}
 
export const validator = (/* возможны аргументы */): ValidatorFn => {
	return (control: AbstractControl) => {
    	if (false /* условие */) {
            return 'Error message';
    	}
 
    	return null;
	};
};

И таких функций может быть практически неограниченное количество, в разумных, конечно пределах и передаются они в элемент управления вторым аргументом конструктора класса FormControl при создании экземпляра класса, например:

const myControl = new FormControl<string>('', [validator()]);

При решении определенных задач может потребоваться подключать или отключать определенные функции валидации, для этого предусмотрены специальные методы:

if (someCondition === false) {
    myControl.setValidators([]);
} else {
	myControl.setValidators([validator ()]);
}

Процесс валидации формы производится при каждом изменении значений полей модели, результаты проверки сохраняются в свойствах элементов управления valid и errors. Вариант использования может быть следующим:

if (userRegistrationStore.form.valid){
	sendForm(userRegistrationStore.form.value)
} else {
    showError(userRegistrationStore.form.errors)
}

В завершение описания скажу следующие. Я специально не заострял внимание на обработке событий, генерируемых элементами модели. В свою очередь каждый элемент управления формы имеет два observable свойства – это свойства value и errors.  Библиотеки mobx и mobx-react-lite предоставляют набор механизмов для работы с обозреваемыми сущностями и вопрос организации взаимодействия сущностей модели с иными сущностями зависит от выбранного архитектурного паттерна и остается в зоне ответственности конкретного разработчика.

Сколько кода нужно?

Далее я хочу провести оценку объема кода, который нужно написать разработчику чтобы реализовать форму из нашего примера на стандартных средствах React+Mobx. Представим, что мы практикуем Agile  методологии в процессах разработки и конечный вариант формы мы не знаем. Будем вести разработку инкрементально и за каждую итерацию будем добавлять по одному полю или функции. Для облегчения чтения, я рассмотрю только ��аботу со стором.

Итак, погнали!

Нулевая итерация, создаем стор

Mobx+React

export interface PersonalData {
  name: string;
  phone: string;
}
export interface AddressData {
  zip: string;
  address: string;
}
export interface FormData {
  personal: PersonalData;
  address: AddressData;
  comment: string;
  agreement: boolean;
}
class UserRegistrationStore {
  constructor() {
  }
}

ReactiveForms

interface PersonalInfo {
	name: FormControl<string>;
	phone: FormControl<string>;
}
interface Address {
	zip: FormControl<number>;
	street: FormControl<string>;
}
interface MyForm {
	personal: FormGroup<PersonalInfo>;
	address: FormGroup<Address>;
	comment: FormControl<string>;
	agreement: FormControl<boolean>;
}
class UserRegistrationStore {
	protected _form: FormGroup<MyForm>;
    constructor() {
	}
} 

Первая итерация. Добавлю пару полей PersonalInfo в класс стора, здесь и далее я буду отображать только вносимые изменения в классы.

MobX+React

Добавляю свойство, вношу изменения в конструктор, добавляю сеттеры.

…
    personal: PersonalData = {
    name: "",
    phone: "",
  };
 
…
makeObservable(this, {
    personal: observable,
    setPersonalField: action,
});
…
setPersonalField(field: keyof PersonalData, value: string): void {
    this.personal[field] = value;
}
…

ReactiveForms

Добавляю инициализацию формы  в конструктор.

…
this._form = new FormGroup<MyForm>({
	personal: new FormGroup<PersonalInfo>({
    	name: new FormControl<string>(''),
    	phone: new FormControl<string>(''),
	}),
});
…

Вторая итерация: Добавлю пару полей Address

Mobx+React

Добавляю свойство, вношу изменения в конструктор, добавляю сеттеры.

…
address: AddressData = {
    zip: "",
    address: "",
};
…
makeObservable(this, {
    personal: observable,
    address: observable,
    setPersonalField: action,
    setAddressField: action,
});
…
setAddressField(field: keyof AddressData, value: string): void {
    this.address[field] = value;
}
…

ReactiveForms

Добавляю поля в конструктор формы.

…
this._form = new FormGroup<MyForm>({
	personal: new FormGroup<PersonalInfo>({
   	 name: new FormControl<string>(''),
    	phone: new FormControl<string>(''),
	}),
	address: new FormGroup<Address>({
    	zip: new FormControl<number>(1001001),
    	street: new FormControl<string>(''),
	}),
});
…

Третья итерация: добавлю оставшиеся поля comment и agreement

Mobx+React

Добавляю свойство, вношу изменения в конструктор, добавляю сеттеры.

…
comment: string = "";
agreement: boolean = false;
…
makeObservable(this, {
    personal: observable,
    address: observable,
    comment: observable,
    agreement: observable,
    setPersonalField: action,
    setAddressField: action,
    setComment: action,
    setAgreement: action,
});
…
setComment(value: string): void {
    this.comment = value;
}
setAgreement(value: boolean): void {
    this.agreement = value;
}
…

ReactiveForms

Добавляю поля в конструктор формы

…
this._form = new FormGroup<MyForm>({
	personal: new FormGroup<PersonalInfo>({
    	name: new FormControl<string>(''),
    	phone: new FormControl<string>(''),
	}),
	address: new FormGroup<Address>({
    	zip: new FormControl<number>(1001001),
    	street: new FormControl<string>(''),
	}),
	comment: new FormControl<string>(''),
	agreement: new FormControl<boolean>(false),
});
…

Четвертая итерация: Теперь я хочу получить все значения полей для дальнейшего использования

Mobx+React

Добавляю геттер formData , внесу изменения в конструктор

…
makeObservable(this, {
    personal: observable,
    address: observable,
    comment: observable,
    agreement: observable,
    setPersonalField: action,
    setAddressField: action,
    setComment: action,
    setAgreement: action,
    formData: computed,
});
…
get formData(): FormData {
    return {
        personal: { ...this.personal },
        address: { ...this.address },
        comment: this.comment,
       agreement: this.agreement,
   };
}
…

ReactiveForms

Здесь достаточно предоставить публичный интерфейс обращения к форме.

…
public get form(): FormGroup<MyForm> {
	return this._form;
}
…

Пятая и последняя для этого примера итерация: Я хочу добавить валидацию данных, проверю, что все поля имеют значения

Mobx+React

Добавляю метод isValid, внесу изменения в конструктор

…
makeObservable(this, {
    personal: observable,
    address: observable,
    comment: observable,
    agreement: observable,
    setPersonalField: action,
    setAddressField: action,
    setComment: action,
    setAgreement: action,
    formData: computed,
    isValid: computed,
});
…
get isValid(): boolean {
    const { name, phone } = this.personal;
    const { zip, address } = this.address;
 
    return (
  	  name.trim() !== "" &&
  	  phone.trim() !== "" &&
  	  zip.trim() !== "" &&
  	  address.trim() !== "" &&
  	  this.agreement
	);
}
…

ReactiveForms

Добавлять строки нет надобности, внесу изменения в существующую инициализацию

…
this._form = new FormGroup<MyForm>({
	personal: new FormGroup<PersonalInfo>({
    	name: new FormControl<string>('', [required()]),
    	phone: new FormControl<string>('', [required()]),
	}),
	address: new FormGroup<Address>({
    	zip: new FormControl<number>(1001001, [required()]),
    	street: new FormControl<string>('', [required()]),
	}),
	comment: new FormControl<string>('', [required()]),
	agreement: new FormControl<boolean>(false,[required()]),
});
…

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

Для наглядности эта динамика отражена на графике
Для наглядности эта динамика отражена на графике

Архитектура

Перед тем, как подвести итог, я хочу рассмотреть основные строительные блоки Angular и React приложений и привести небольшой сравнительный анализ.

Основные блоки Angular приложения
Основные блоки Angular приложения

Как известно, в Angular, модель данных формы и разметка шаблона инкапсулированы в компоненте, что создает сильную связанность шаблона с моделью. Давайте теперь посмотрим на подобную схему для React приложения.

Основные блоки React приложения
Основные блоки React приложения

Как видно из диаграммы, модель данных, которую мы реализуем в store, не имеет сильной связности с разметкой шаблона, что позволяет нам как заменять визуальные компоненты без внесения каких-либо изменений в модель или store, так и использовать одну модель в разные компонентах. Таким образом, при внедрении моей библиотеки можно получить сразу две выгоды - это удобство создания реактивных моделей данных и слабую связанность слоя данных с UI слоем.

Обсудим, что дальше?

На этом хотелось бы сказать: «That’s all folks», но нет, здесь все только начинается. Впереди нас ждет история про автоматизацию разработки форм с применением ИИ и изменение как модели формы, так и UI слоя в реальном времени. Кому интересно, подписывайтесь и следите за обновлениями!