Недавно, в рамках TC39, предложение по внедрению декораторов в ECMAScript достигло третьего пре-финального этапа. Немного позже Microsoft выпустила пятую версию TypeScript, где новая реализация декораторов начала работать “из коробки” без экспериментальных флагов. Babel также взял на себя инициативу и в своей документации стал рекомендовать использовать новую реализацию декораторов. Все это означает, что декораторы наконец-то начинают полноценно входить в жизнь разработчиков JavaScript.
Именно по этой теме я решил рассказать, как с помощью декораторов можно улучшить опыт разработчика при создании форм.
Следует отметить, что в этой статье я буду рассказывать об подходе, основанном на использовании библиотеки MobX. Поэтому, если вы не используете MobX в своих проектах, статья может быть не так полезной, как могла бы быть. Однако вы можете прочесть ее как возможный источник вдохновения по созданию веб-форм.
Оглавление
Немного о предыстории
В моем предыдущем проекте мне пришлось разрабатывать множество сложных форм. Очень часто они состояли из десятков полей.
Большинство полей требовало валидации. Конечно, простые правила валидации, такие как проверка на заполненность поля или проверка правильности адреса электронной почты, возникали довольно часто. Но иногда эти правила были невероятно сложными. Например, в зависимости от того, что выбирает пользователь в одном поле, правила валидации для другого поля могли меняться. И в некоторых случаях было необходимо отключать валидацию одного поля при определенных значениях другого поля. И, конечно, для каждого поля могло быть несколько правил валидации, каждое из которых должно было выдавать свое собственное сообщение об ошибке.
Но валидация была только частью необходимой функциональности. Также нужно было отслеживать изменения в форме. Грубо говоря, отключать кнопку отправки, пока пользователь не внесет какие-либо изменения. Но, опять же, на формах было десятки полей, поэтому написание блоков if для каждого поля не было лучшим решением.
Ситуацию усугубляло то, что некоторые поля могли представлять собой массивы и наборы данных. И если пользователь удалял несколько значений из такого поля, а затем вводил те же значения вручную, форма должна была понимать, что вернулась к исходному состоянию. Кроме того, формы должны были уметь сбрасывать текущее состояние формы к исходному. И, конечно же, они должны были уметь взаимодействовать с сервером.
Я рассматривал различные библиотеки, такие как React Hook Form или Formik, но эти варианты меня не устроили. В масштабе этих требований код, даже с использованием этих библиотек, оказался слишком громоздким и трудноподдерживаемым. Поэтому я начал разрабатывать свое собственное решение.
Модель формы MobX
Важным шагом было разделение представления и логики. Мне нужно было найти способ описать логику формы в отдельной функции или объекте и минимизировать необходимость в повторении кода насколько это возможно.
В итоге я пришел к выводу, что удобно описывать логику формы в отдельном классе JavaScript. Далее в тексте я буду называть такой класс “схемой формы”. Каждое свойство такого класса может представлять поле в форме или выполнять какой-либо вспомогательный функционал. С помощью декораторов можно присвоить необходимую логику для каждого свойства.
В самом простом представлении, такой объект является обычным MobX хранилищем. Например, в следующем фрагменте кода показан самый простой пример схемы формы, состоящей из двух полей: “Имя” и “Фамилия”. Пока без какой-либо логики.
import { makeObservable, observable } from 'mobx';
export class BasicFormStore {
name = '';
surname = '';
constructor() {
makeObservable(this, {
name: observable,
surname: observable,
});
}
}
Валидация формы
Что такое валидация поля? Это одно или несколько правил для проверки значения поля. В нашем случае “поле” - это свойство класса. Это означает, что с помощью декоратора @validate можно назначить несколько правил валидации для соответствующего свойства.
import { FormSchema, validate } from '@yoskutik/mobx-form-schema';
import { makeObservable, observable } from 'mobx';
import { email, required } from 'path/to/validators';
export class LoginSchema extends FormSchema {
@validate(required(), email())
email = '';
constructor() {
super();
makeObservable(this, {
email: observable,
});
}
}
const schema = LoginSchema.create();
console.log(schema.isValid, schema.errors);
// false, { email: 'The field is required' }
schema.email = 'invalid.email';
console.log(schema.isValid, schema.errors);
// false, { email: 'Invalid email format' }
schema.email = 'valid@email.com';
console.log(schema.isValid, schema.errors);
// true, {}
Вы просто передаете несколько функций-валидаторов декоратору, и схема самостоятельно выполняет валидацию значения поля. Если есть несколько валидаторов, схема применяет их последовательно. И только если все правила успешно прошли, схема сообщает, что поле действительно.
И снова мы видим разделение кода. Логика формы хранится отдельно от объявления валидаторов. Конечно, это делается намеренно. Таким образом, формируется подход к написанию атомарных функций валидации. И благодаря этому общие правила могут легко повторно использоваться.
Как выглядит функция-валидатор?
Функция-валидатор для схемы представляет собой просто функцию, которая возвращает либо строку, либо булево значение.
export const required = () => (value?: string) => {
if (value?.trim()) return false;
return 'This field is required';
};
export const email = () => (value: string) => {
if (/\S+@\S+\.\S+/.test(value)) return false;
return 'Invalid email format';
};
export const minLength = (min: number) => (value: string) => {
if (value.length >= min) return false;
return `Should be at least ${min} characters.`;
};
Если функция возвращает false, валидация считается успешной. И если строка или true - нет. Более того, строка, переданная в валидатор, становится сообщением об ошибке для поля.
В качестве первого входного параметра функция получает текущее значение свойства. И в случае сложной валидации каждая функция-валидатор получает весь объект-схему со всеми свойствами в качестве второго параметра.
Поле “подтверждение пароля” должно иметь точно такое же значение, как и поле “пароль”. Поле ввода даты “с” должно содержать дату, предшествующую дате в поле “по”. Это базовые примеры тех случаев, когда нам нужно использовать всю схему для валидации.
Приведенный ниже пример показывает форму регистрации с примером проверки поля подтверждения пароля.
import { FormSchema, validate } from '@yoskutik/mobx-form-schema';
import { makeObservable, observable } from 'mobx';
import { email, minLength, required } from 'path/to/validators';
const confirmPassword = () => (
// Let’s use the second argument in order to understand whether
// the “confirmPassword” is the same as “password”
(confirmPasswordValue: string, schema: SignUpSchema) => {
if (confirmPasswordValue === schema.password) return false;
return 'Passwords mismatched';
}
);
export class SignUpSchema extends FormSchema {
// Email address field
@validate(required(), email())
email = '';
// Password field
@validate(required(), minLength(8))
password = '';
// Password confirmation field
@validate(required(), confirmPassword())
confirmPassword = '';
constructor() {
super();
makeObservable(this, {
email: observable,
password: observable,
confirmPassword: observable,
});
}
}
Условная валидация
Как я уже отмечал ранее, иногда могут возникать ситуации, когда валидацию нужно отключить. Поля могут быть необязательными или скрытыми по каким-то причинам, и в некоторых случаях валидация должна быть отключена в зависимости от значений в других полях.
Поскольку декоратор @validate уже используется для объявления обычной валидации, мы не можем использовать его. Но мы можем создать его модификатор: @validate.if. Такой модификатор будет работать практически так же, как и оригинальный, с единственным исключением: в дополнение к массиву валидаторов нужно передать предикатную функцию, которая говорит, нужна ли валидация в данный момент. Если предикат говорит, что валидация не требуется, свойство считается действительным.
Приведенный ниже пример показывает схему трех полей:
- Необязательное поле для ввода адреса электронной почты.
- Флажок, в котором пользователь говорит, что у него есть домашний питомец.
- Поле для ввода имени питомца. Если флажок активен, необходимо выполнять валидацию на предмет полноты поля.
import { FormSchema, validate } from '@yoskutik/mobx-form-schema';
import { makeObservable, observable, runInAction } from 'mobx';
import { email, required } from 'path/to/validators';
const shouldValidatePetName = (_name: string, schema: ConditionalSchema) => (
schema.doesHavePet
);
export class ConditionalSchema extends FormSchema {
// or it can be @validate.if(email => !!email, [email()])
@validate.if(Boolean, [email()])
email = '';
doesHavePet = false;
@validate.if(shouldValidatePetName, [required()])
petName = '';
constructor() {
super();
makeObservable(this, {
email: observable,
doesHavePet: observable,
petName: observable,
});
}
}
const schema = ConditionalSchema.create();
console.log(schema.isValid, schema.errors); // true, {}
runInAction(() => schema.doesHavePet = true);
console.log(schema.isValid, schema.errors);
// false, { petName: 'The value is required.' }
runInAction(() => schema.email = 'invalid.email');
console.log(schema.isValid, schema.errors);
// false, {
// petName: 'The value is required.',
// email: 'Invalid email format.',
// }
Это довольно простой пример валидации. Вы можете посмотреть более сложные примеры валидации, включая условную валидацию и правила, которые используют всю схему, на сайте документации.
Когда происходит валидация?
По умолчанию схема вычисляет валидацию в функции autorun из библиотеки MobX. Благодаря этому валидация свойства автоматически пересчитывается при его изменении. Но из-за этого, если другие свойства схемы участвовали в валидации, то при их изменении также будет пересчитана валидация.
Та же самая логика применяется к функции условной валидации. Если изменено нужное свойство или свойство, участвующее в условии, предикатная функция будет вызвана снова.
Вам не нужно беспокоиться о лишних пересчетах. Благодаря оптимизациям MobX и MobX Form Schema их не происходит. Тем не менее, возможно отключить автоматическую валидацию и начать валидировать данные вручную. Вы можете посмотреть примеры ручной валидации по ссылке.
Краткое резюме по валидации
Эти преимущества могут показаться субъективными, но мне действительно понравился такой подход в разработке кода. Он хорошо читаем и легко поддерживается. Он достаточно гибок и даже в сложных случаях не заставляет вас писать сложную логику.
Недостатком может показаться необходимость писать валидаторы с нуля. Даже самые базовые. В то время как другие библиотеки поставляют их готовыми. Но у меня есть замечания по этому поводу:
Даже базовые правила могут различаться для разных проектов. Например, валидация номера телефона или адреса электронной почты может различаться в разных странах.
Приложение может поддерживать несколько языков. И даже в пределах одного языка могут возникнуть ситуации, когда одно и то же правило в разных полях должно выдавать разные сообщения об ошибке.
Обе эти точки приводят к необходимости предоставления функциональности для переопределения или настройки базовых валидаторов. Но, как вы сами видели, базовые валидаторы могут состоять всего из 3 строк кода. И для меня лучше написать 3 строки кода с нуля, чем писать их для настройки готовой функциональности “из коробки”.
Что также замечательно, MobX Form Schema работает с декораторами как новой, так и старой реализации. Однако новая реализация обладает хорошей поддержкой типов, поэтому я не могу передать валидатор для числа в свойство строкового типа.
const rule = () => (value: number) => {
if (value > 0) return false;
return 'The value must be greater than 0';
};
export class SignUpSchema extends FormSchema {
// a typing error here, since `rule` must work with number properties
@validate(rule())
email = '';
}
Отслеживание изменений формы
Теперь давайте перейдем от валидации к отслеживанию изменений формы. Не сложно понять, что форма была изменена. Сначала вам нужно сохранить начальное состояние формы. Во-вторых, в нужный момент достаточно использовать такой кусок кода:
const isChanged = currentValue1 !== initialValue1
|| currentValue2 !== initialValue2
|| ...;
Это эффективный и простой способ, но он подходит только для простых форм. Чем больше полей в форме, тем длиннее будет это условие и тем сложнее будет поддерживать такой код.
Но это не единственная проблема. Помимо простых текстовых полей в форме могут быть более сложные поля. Например, на большинстве сайтов, связанных с карьерой, есть поле для указания навыков. И значение такого поля фактически должно быть либо массивом, либо множеством. И простое сравнение ссылок здесь не поможет понять, изменилось ли состояние формы.
Существует другой подход: глубокое сравнение.
import isEqual from 'lodash/isEqual';
const isChanged = isEqual(currentState, initialState);
Этот подход решает описанные выше проблемы. Однако затем возникают проблемы с лишними вычислениями. Идеально, чтобы форма говорила, изменилась она или нет после любого взаимодействия пользователя. Но вызывать глубокое сравнение при каждом изменении может быть слишком ресурсозатратным.
MobX позволяет обойти обе эти проблемы. В схеме формы, при изменении определенного поля, происходит только сравнение, которое проверяет, изменилось ли именно это поле.
И чтобы активировать отслеживание изменений формы, достаточно использовать декоратор: @watch.
export class UserSchema extends FormSchema {
@watch name = 'Initial name';
@watch surname = 'Initial surname';
}
const schema = UserSchema.create();
console.log(schema.isChanged); // false
schema.name = 'New Name';
console.log(
schema.isChanged, // true
schema.getInitial('name'), // 'Initial name'
);
schema.name = 'Initial name';
console.log(schema.isChanged); // false
В этой форме флаг isChanged всегда будет равен false, если имя равно “Initial name”, а фамилия равна “Initial surname”. Даже если свойство изменит свое значение на другое, а затем вернется к исходному состоянию.
Декоратор @watch использует сравнение по ссылке и сообщает схеме, изменилось ли значение свойства по сравнению с его исходным состоянием.
Вы могли заметить, что в приведенном выше примере я не вызывал функцию makeObservable. Это потому, что по умолчанию @watch применяет observable.ref к свойствам. Это делается по логическим причинам - если вам нужно только сравнение по ссылке, вряд ли вам потребуется глубокое наблюдение через observable. Тем не менее, вы можете добавить его или любые другие модификации observable сами без проблем.
Более важно, сама схема не помнит свое исходное состояние. Но если вы примените @watch, схема сохранит исходное состояние только для нужных полей. Таким образом, нет лишних расходов памяти.
Отслеживание сложных объектов
Благодаря сравнению по ссылке, @watch в основном нужен для наблюдения за примитивными значениями. Немного сложнее, когда речь идет о наблюдении за объектами. Например, при проверке, изменился ли массив, нам нужно проверить количество элементов в начальном и текущем состояниях, а также убедиться, что элементы совпадают на каждой из позиций.
Но декораторы-модификаторы приходят нам на помощь снова. С их помощью мы можем создать такие модификаторы, в которых вместо сравнения по ссылке будет использоваться некий другой тип сравнения. Например, если вы хотите использовать массив значений, используйте @watch.array, и если есть множество, используйте @watch.set. По умолчанию эти декораторы будут применять к свойствам схемы observable.shallow.
import { FormSchema, watch } from '@yoskutik/mobx-form-schema';
import { runInAction } from 'mobx';
export class ArraySchema extends FormSchema {
@watch.array skillsArray = ['HTML', 'CSS', 'JavaScript'];
@watch.set skillsSet = new Set(['HTML', 'CSS', 'JavaScript']);
}
const schema = ArraySchema.create();
runInAction(() => (schema.skillsArray = ['HTML']));
console.log(schema.isChanged, schema.changedProperties);
// true, Set(['skillsArray'])
runInAction(() => schema.skillsArray.push('CSS', 'JavaScript'));
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
runInAction(() => schema.skillsSet.delete('CSS'));
console.log(schema.isChanged, schema.changedProperties);
// true, Set(['skillsSet'])
runInAction(() => schema.skillsSet.add('CSS'));
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
Повторюсь, новые декораторы лучше работают с типизацией. Вы не можете применить @watch.array к свойству, не являющемуся массивом, или @watch.set к свойству, не являющемуся множеством.
Отслеживание вложенных схем
Схемы могут быть вложенными. Самая простая причина для этого - логическое разделение данных. Например, в форме информации о пользователе информация о его контактах может быть отдельной схемой, вложенной в главную схему.
import { FormSchema, watch } from '@yoskutik/mobx-form-schema';
import { runInAction } from 'mobx';
export class ContactsSchema extends FormSchema {
@watch tel = 'default tel value';
@watch email = 'default email value';
}
export class InfoSchema extends FormSchema {
@watch name = '';
@watch surname = '';
@watch.schema contacts = ContactsSchema.create();
}
const schema = InfoSchema.create();
runInAction(() => (schema.contacts.tel = 'new value'));
console.log(schema.isChanged, schema.changedProperties);
// true, Set(['contacts'])
runInAction(() => (schema.contacts.tel = 'default tel value'));
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
runInAction(() => (schema.contacts = ContactsSchema.create()));
console.log(schema.isChanged, schema.changedProperties);
// false, Set()
И, конечно, такая вложенная отдельная схема может использоваться в будущем без родительской схемы, если такая ситуация потребуется.
Кроме того, вы можете использовать модификатор @watch.schemasArray, если вам нужно использовать массив вложенных схем. Такой массив, например, может быть массивом блоков информации об опыте работы в форме резюме.
В случае, если у вас есть нестандартная структура данных, требующая нестандартной функции сравнения, вы можете создать собственную модификацию декоратора @watch с помощью метода watch.create. Но чтобы не раздувать статью, я просто скромно оставлю ссылку на документацию, если вас интересует просмотр примеров использования @watch.schemasArray и watch.create.
Восстановление формы в исходное состояние
В некоторых случаях функция восстановления формы может быть полезной. Особенно в формах редактирования с предзаполненными данными с сервера. И поскольку мы уже храним исходное состояние формы, нам совсем не сложно восстановить форму в исходное состояние.
import { FormSchema, watch } from '@yoskutik/mobx-form-schema';
import { runInAction } from 'mobx';
export class BasicSchema extends FormSchema {
@watch name = 'Joe';
@watch surname = 'Dough';
}
const schema = BasicSchema.create();
runInAction(() => {
schema.name = 'new name';
schema.surname = 'new surname';
});
console.log(schema.name, schema.surname); // 'new name', 'new surname'
schema.reset();
console.log(schema.name, schema.surname); // 'Joe', 'Dough'
И, конечно, массивы, множества, вложенные схемы и даже ваши собственные сущности (если вы описали их правильно) — все они будут корректно восстановлены.
Краткое заключение о слежении за изменениями
Я кратко отмечу, что, как и с валидацией, изменения отслеживаются автоматически. Но, конечно, также доступна возможность вручную проверять изменения.
Итак, с помощью всего лишь пары декораторов и методов полностью автоматизировано отслеживание изменений формы. Независимо от сложности схемы или количества вложенных схем в ней, вы всегда сможете понять, изменилась ли ваша форма. И вы всегда сможете восстановить ее в исходное состояние.
Вы даже можете создать схему формы для настроек IDE. Обычно в таких формах много вкладок, внутри которых есть вложенные вкладки. И вы легко можете отслеживать изменения и сбрасывать форму полностью, только на определенной вкладке или только во вложенной вкладке.
При этом эти наблюдения достаточно дешевы. Когда поле меняется, проверяется только это поле.
И, конечно, то, что @watch и его модификации способны сами применять модификации MobX к свойствам, позволяет сократить ваш код еще больше.
Взаимодействие формы с сервером
Иногда данные, полученные с сервера, требуют некоторой предварительной обработки перед использованием. Например, сервер не может отправить сущность Set или Date, но вам может быть удобнее использовать данные в этом формате.
Может быть и обратная ситуация, когда сервер требует данные в другом формате, чем тот, в котором они хранятся в схеме.
И обычно разработчики, которые испытывают такую необходимость, модифицируют данные после получения их, перед использованием или перед отправкой. Но с помощью схемы формы такие изменения можно описать непосредственно в схеме.
Инициализация
В предыдущих примерах вы видели, что для создания схемы необходимо вызвать статический метод create. Этот метод может принимать объект в качестве аргумента, на основе которого схема может быть заполнена данными.
import { FormSchema } from '@yoskutik/mobx-form-schema';
export class BasicSchema extends FormSchema {
name = '';
surname = '';
}
const schema1 = BasicSchema.create();
console.log(schema1.name, schema1.surname); // '', ''
const schema2 = BasicSchema.create({
name: 'Joe',
surname: 'Dough',
});
console.log(schema2.name, schema2.surname); // 'Joe', 'Dough'
Вы также можете описать, как данные, полученные в этом методе, должны быть предварительно обработаны перед тем, как схема начнет их использовать. Для этого можно использовать декоратор @factory.
import { factory, FormSchema } from '@yoskutik/mobx-form-schema';
const createDate = (data: string) => new Date(data);
export class BasicSchema extends FormSchema {
@factory.set
set = new Set<number>();
@factory(createDate)
date = new Date();
}
const schema = BasicSchema.create({
set: [0, 1, 2],
date: '2023-01-01T00:00:00.000Z',
});
console.log(schema.set instanceof Set, schema.date instanceof Date);
// true, true
Презентация
Поскольку каждая схема содержит вспомогательные данные и методы, для вас может быть полезно получить объект, содержащий исключительно полезные данные из схемы. Для этого вы можете использовать геттер presentation схемы, который по умолчанию создает копию схемы без вспомогательных методов и свойств.
import { FormSchema } from '@yoskutik/mobx-form-schema';
export class BasicSchema extends FormSchema {
name = 'Joe';
surname = 'Dough';
}
const schema = BasicSchema.create();
console.log(schema.presentation);
// {
// name: 'Joe',
// surname: 'Dough',
// }
Вы также можете использовать декоратор @present для изменения содержимого объекта презентации. И даже вы можете убрать некоторые из его свойств. Например, вам, вероятно, не захочется отправлять значение поля подтверждения пароля на сервер. Для этого вы можете использовать модификатор @present.hidden.
import { FormSchema, present } from '@yoskutik/mobx-form-schema';
const presentUsername = (username: string) => `@${username}`;
export class BasicSchema extends FormSchema {
@present(presentUsername)
username = 'joe-man';
name = 'Joe';
@present.hidden
someUtilityProperty = 'utility data';
}
const schema = BasicSchema.create();
console.log(schema.presentation);
// {
// name: 'Joe',
// username: '@joe-man',
// }
И впоследствии вы можете использовать этот объект презентации при отправке данных на сервер.
А как это использовать в React?
Я создал MobX Form Schema как пакет с минимальным набором зависимостей. Поэтому необязательно использовать React. Единственное, что вам нужно - это MobX, который должен быть в проекте.
Но тем не менее, я понимаю, что в большинстве случаев MobX используется с React, поэтому я подготовил пример использования моей библиотеки в приложении React. Но чтобы не раздувать статью, я просто предоставлю вам ссылки на нее: веб-документация, CodeSandbox.io, StackBlitz.com.
И просто, чтобы вас заинтересовать, я кратко покажу, как может выглядеть компонент для отображения формы с именем питомца из раздела “Условная валидация”.
export const ConditionalExample = observer(() => {
const schema = useMemo(() => ConditionalSchema.create(), []);
return (
<form>
{/* Since error output is standardized, TextField is able to display them itself */}
<TextField schema={schema} field="email" type="email" label="E-mail" />
<CheckboxField schema={schema} field="doesHavePet" label="I have a pet" />
{schema.doesHavePet && (
<TextField schema={schema} label="Pet's name" field="petName" required />
)}
<button type="submit">Submit</button>
</form>
);
});
Итак, означает ли это, что декораторы - это универсальное средство?
Пытаюсь ли я утверждать в своей статье, что этот подход к разработке форм - единственно правильный? Нет, конечно, в разработке нет универсальных решений. Однако мне кажется, что этот подход действительно упрощает процесс разработки. И, что важно, он почти не влияет на размер вашего бандла - за исключением самого MobX, вся функциональность, которую я описал, хранится в пакете менее чем 4 КБ. И учитывая, что вам придется писать меньше кода, вы только выиграете в размере бандла.
Более того, этот подход работает хорошо как с небольшими, так и с крупными формами.
Однако, да, вам понадобится MobX. По крайней мере, в моей реализации. Если кто-то сделает что-то подобное для других менеджеров состояния, мне будет интересно это увидеть.
Конец
В статье я показал большую часть, но не всю функциональность схемы формы. Я не показал, как работает валидация и отслеживание изменений в ручном режиме; я не показал все модификаторы декораторов. И вообще, если вас интересует этот подход к разработке форм, я рекомендую посетить сайт с документацией. Там есть много полезных материалов, включая полезные сценарии использования схемы формы. Я жду ваших отзывов в комментариях. Как вам этот подход в целом?
Ссылка на пакет npm.
Пока.