Как стать автором
Обновить

Isomorphic-validation — Javascript библиотека, облегчающая валидацию пользовательского ввода

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров955

Я начал этот небольшой проект под названием 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".

Также вместо приминения библиотечных эффектов возможно привязать состояния компонентов к состояниям валидаций и валидаторов.

Приминение на клиенте и сервере

Приминение одних и тех же валидаций на клиенте и сервере сводится к нескольким шагам:

  1. Подготовка валидаторов и сообщений.

  2. Создание валидаций.

  3. Добавление эффектов пользовательского интерфейса.

  4. Создание профилей.

Граф зависимостей в проекте с формой регистрации и формой входа может выглядеть примерно так:

Приминение одних и тех же валидаций на клиенте и сервере.
Приминение одних и тех же валидаций на клиенте и сервере.

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

Пошаговая инструкция с данным примером находится здесь.

Что мы имеем в результате:

  • Нет дублирования кода валидации между разными формами с полями одного типа.

  • Нет дублирования кода валидации между клиентом и сервером.

  • Вся логика валидации находится в одном месте, что обеспечивает один источник истины.

И на последок попрошу вас ответить на один вопрос.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы считаете, имеет ли такая библиотека право на существование?
50% Да, имеет.2
0% Врядли.0
50% Трудно сказать.2
0% Я не понял(а), какую проблему решает библиотека и зачем она вообще нужна.0
Проголосовали 4 пользователя. Воздержались 2 пользователя.
Теги:
Хабы:
+1
Комментарии5

Публикации

Работа

Ближайшие события