Я начал этот небольшой проект под названием isomorphic-validation, как эксперимент, в основном в образовательных целях. Несмотря на то, что существует множество других библиотек валидации, я решил все равно изобрести велосипед. Это была попытка скрыть все сложности, связанные с условными операторами и асинхронностью при создании пользовательского интерфейса, и сделать ее удобной для таких как я новичков, для приминения в проектах без фреймворка.
Я выдвинул следующие требования к библиотеке:
Группирование валидаций, чтобы состояние валидности группы зависело от каждой валидации в группе;
Возможность "подключения" эффектов пользовательского интерфейса к состояниям валидаторов, валидаций и групп валидаций;
Приминение валидаций в качестве обработчиков событий на стороне клиента и в качестве Express middleware на стороне сервера;
Возможность разделения клиентских валидаторов и эффектов от серверных;
Одинаковое использование синхронных и асинхронных валидаторов;
Удобство реализации любой стратегии валидации и на любой тип события формы;
Совместимость с библиотеками интернационализации.
Прежде всего я попрошу вас взглянуть на следующий образец кода и, не видя результата его выполнения, попытаться предположить, что он делает:
form.addEventListener(
'input',
Validation.group(
Validation(form.email)
.constraint(isEmail, { err: 'Must be in the E-mail format.' })
.validated(applyOutline(form.email, delayedOutline))
.changed(applyOutline(form.email, changedOutline))
.validated(applyBox(form.email, validIcon))
.validated(applyBox(form.email, errMsg)),
Validation(form.password)
.constraint(isStrongPassword, { err: 'Min. 8 characters, 1 capital letter, 1 number, 1 special character.' })
.validated(applyOutline(form.password, delayedOutline))
.changed(applyOutline(form.password, changedOutline))
.validated(applyBox(form.password, validIcon))
.validated(applyBox(form.password, errMsg)),
)
.changed(applyAccess(form.submitBtn, delayedAccess))
);
Здесь мы группируем два объекта валидации в один и привязываем к изменению его состояния валидности эффект, который в зависимости от этого состояния устанавливает доступ к кнопке отправки формы (аттрибут disabled
). К состоянию каждой отдельной валидации в группе тоже привязаны эффекты: отображение иконки, обводки и сообщения о некорректных данных.
Проще говоря, когда в оба поля введены валидные данные, кнопка отправки формы становится доступной для нажатия. Валидное состояние каждого поля обозначается иконкой, невалидное сопровождается обводкой и сообщением об ошибке с некоторой задержкой.
Песочница с полной версией этого примера находится здесь.
Мотивация
Существующие библиотеки валидации в основном предлагают набор валидаторов в том или ином виде и связанных с ними сообщений. Наличие валидаторов на все случаи жизни, собранных в одной библиотеке, далеко не всегда упрощает процесс их приминения. Некоторые библиотеки предоставляют функционал для отображения сообщений и индикации валидности полей ввода, но он фиксированный, работает по одному сценарию.
Мне же хотелось иметь простой и универсальный механизм, который облегчал бы реализацию вообще любого сценария, позволял бы скрыть все условия и фокусироваться только на том, что валидируем, и с какими побочными эффектами пользовательского интерфейса, не беспокоясь о различного рода пограничных случаях, таких как, например, пустые поля ввода пароля и подтверждения пароля равны или пока выполняется запрос, пользователь ввел еще данные и результат запроса не актуален и т.д.
Концепция
Библиотека не предоставляет готовых валидаторов (эту задачу выполняют другие библиотеки). Здесь логика, используемая в качестве валидаторов, должна быть завернута в функцию-предикат, на входе валидируемое значение, на выходе булево значение или промис с булевым значением. Я обычно использую валидаторы из библиотеки validator.js.
Библиотека экспортирует две сущности Validation
и Predicate
, а так же набор функций эффектов пользовательского интерфейса и хелперов.
Validation
- это сущность с состоянием и логикой. Может быть одиночной или групповой ( а еще "склеенной"). Состояние группы зависит от состояния каждой валидации в группе. А валидатор, добавленный группе, добавляется к каждой отдельной валидации в группе:
Validation.group(
Validation(form.firstName),
Validation(form.lastName),
)
.constraint(isAlpha, { msg: 'Только буквы разрешены.' })
.constraint(isLongerOrEqual(2), { msg: 'Не короче 2 символов.' })
.constraint(isShorterOrEqual(32), { msg: 'Не длиннее 32 символов.' })

"Склеенная" валидация - это тоже группа, отличается от обычной группы тем, что валидатор получает на вход валидируемые значения всех валидаций в группе, и состояние валидности всех валидаций в группе зависит от исполнения данного валидатора (взаимозависимые поля пароль и подтверждение пароля):
Validation.glue(
Validation(form.password),
Validation(form.pwdConfirm),
)
.constraint(equals, { msg: 'Пароль и подтверждение пароля должны совпадать.' })
Так же возможна реализация такой логики, в которой валидность данных одного поля зависит от другого (односторонняя зависимость). Пример можно посмотреть здесь.
Predicate
- это обвертка, необходимая для подключения эффектов к состояниям валидаторов и передачи сообщений до их добавления к валидации:
Validation()
.constraint(
Predicate(isEmailRegistered, { msg: 'Данный адрес уже зарегистрирован.' })
.started(doSomething)
.invalid(doSomethingElse)
)
Каждая валидация и валидатор имеют ряд состояний (конечный автомат), к которым можно подключить эффекты:
Validation.group(
Validation()
.constraint(
Predicate(isSomething)
.started(doSomething)
.valid(doSomething)
.invalid(doSomething)
.changed(doSomething)
.validated(doSomething)
)
.started(doSomething)
.valid(doSomething)
.invalid(doSomething)
.changed(doSomething)
.validated(doSomething)
)
.started(doSomething)
.valid(doSomething)
.invalid(doSomething)
.changed(doSomething)
.validated(doSomething)
Сгруппированные валидации доступны через свойство .validations
а добавленные валидаторы через свойство .constraints
:
Validation.group(
Validation(form.firstName),
Validation(form.lastName),
)
.validations.forEach( // <--
validation => {
validation.validated(doSomething);
}
);
//--------------------------------------
Validation.group(
Validation(form.email)
.constraint(isEmail),
Validation(form.password)
.constraint(isStrongPassword),
)
.constraints.forEach( // <--
(validator, formField) => {
validator.validated(applyOutline(formField));
}
);
Для использования тех же валидаций на стороне сервера, необходимо создать их, не привязывая к полям формы, и сгруппировать с помощью функции .profile()
:
const emailV = Validation()
.constraint(isMinLength(8), { next: false })
.constraint(isMaxLength(48), { next: false })
.constraint(isEmail, { next: false });
const passwordV = Validation()
.constraint(isStrongPassword);
const pwdConfirm = Validation();
Validation.glue(passwordV, pwdConfirm)
.constraint(equals);
const [signupForm, signupV] = Validation.profile(
'[name="signupForm"]',
['email', 'password', 'pwdConfirm'],
[emailV, passwordV, pwdConfirm],
);
Теперь валидация signupV
готова к использованию, как на клиенте, так и на сервере:
// на клиенте
signupForm.addEventListener('input', signupV);
// на сервере
app.post('/signup', urlencodeParser, signupV, signupHandler);
А это если необходимо отделить клиентские валидаторы и эффекты от серверных:
Validation()
.client // <--
.validated(doSomethingOnClient)
//-----------------------------------
Validation()
.server // <--
.invalid(doSomethingOnServer)
Например, таким образом можно отделить валидатор на клиенте, делающий запрос к серверу, и валидатор на сервере делающий запрос к базе данных:
const emailV = Validation()
.constraint(isEmail, { next: false }) // выполнится на клиенте и на сервере
.client
.constraint(isEmailRegisteredC); // выполнится только на клиенте
//-----------------------------------
emailV
.server
.constraint(isEmailRegisteredS); // выполнится только на сервере
Валидаторы можно выполнять условно и с задержкой. Задержка может быть полезна для валидаторов, делающих запросы, при валидации по событию input
. Для этого вместе с валидатором нужно передать объект с опциями:
const emailV = Validation()
.constraint(isEmail, { next: false }) // следующий не будет выполнен, пока текущий не вернет true
.constraint(isEmailRegisteredC, { debounce: 5000 }); // запрос на сервер
Дополнительно в этот объект с опциями можно поместить любые данные в любом виде, например сообщения или ключи переводов. Они будут проброшены под теми же именами в результат валиции. Пример использования совместно с библиотекой i18next можно найти здесь.
Эффекты пользовательского интерфейса
Отдельно от основного модуля библиотека предоставляет набор типичных для задач валидации эффектов, таких как: управление доступом к DOM элементам, изменение фона, отображение иконок и сообщений, добавление класса, добавление обводки.
Можно задавать разные значения эффектов и разную задержку их приминения в зависимости от валидности:
const delayedOutline = {
// при невалидном состоянии, розовая обводка, спустя 2 секунды
false: { delay: 2000, value: '2px solid lightpink' },
// при валидном состоянии, убрать обводку, спустя пол секунды
true: { delay: 500, value: '' },
};
Validation(form.email)
.constraint(isEmail)
.validated(applyOutline(delayedOutline))
.validate();
Но что делать если состояние, к которому привязан отложенный эффект, изменилось раньше, чем он наступил и для нового состояния он не актуален? Для этого используется идентификатор эффекта. Эффекты с одним и тем же идентификатором, примененные к одному и тому же элементу, отменяют друг друга:
const delayedOutline = {
false: { delay: 2000, value: '2px solid lightpink' },
true: { delay: 500, value: '2px solid green' },
};
const clearOutline = {
false: { delay: 500, value: '' },
true: { delay: 500, value: '' },
};
const outlineEID = 'outline'; // <--
Validation(form.email)
.constraint(isEmail)
.started(applyOutline(clearOutline, outlineEID)) // <--
.validated(applyOutline(delayedOutline, outlineEID)) // <--
.validate();
А вообще эффект функция возвращает пару функций, одна для отмены отложенного эффекта, другая для его установки:
const [cancel, set] = applyOutline();
Возможно этот паттерн кому-то что-то напоминает, особенно если заменить "apply" на "use".
Также вместо приминения библиотечных эффектов возможно привязать состояния компонентов к состояниям валидаций и валидаторов.
Приминение на клиенте и сервере
Приминение одних и тех же валидаций на клиенте и сервере сводится к нескольким шагам:
Подготовка валидаторов и сообщений.
Создание валидаций.
Добавление эффектов пользовательского интерфейса.
Создание профилей.
Граф зависимостей в проекте с формой регистрации и формой входа может выглядеть примерно так:

Здесь index.js
- это сервер, а signup.js
и signin.js
- точки входа для сборщика модулей. Стоит отметить, что обе формы используют одни и те же валидации эл.почты и пароля. Однако на форме регистрации добавляются проверки на равенство пароля и его подтверждения а так же проверка регистрации адреса эл.почты, при которой делается запрос на сервер. В свою очередь на сервере добавляется проверка на существование адреса эл.почты в базе данных.
Пошаговая инструкция с данным примером находится здесь.
Что мы имеем в результате:
Нет дублирования кода валидации между разными формами с полями одного типа.
Нет дублирования кода валидации между клиентом и сервером.
Вся логика валидации находится в одном месте, что обеспечивает один источник истины.
И на последок попрошу вас ответить на один вопрос.