Проверка ввода функционального стиля

Вступление

Обработка форм иногда кажется тривиальной, но давайте на секунду задумаемся о том, что задействовано в этом процессе. Нам нужно определить поля формы, нам нужно проверить поля, нам также может потребоваться отображать ошибки в соответствии с фактом, если ввод подтверждается, кроме того, нам нужно выяснить, является ли проверка мгновенной или только после нажатия кнопки отправки и т. и так далее.

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

Иногда мы можем решить повторяющуюся задачу с помощью прямой реализации, которую мы попытаемся определить в этой следующей статье.

Основы

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

const isNameValid = name => name.trim().length >= 6

Мы хотим, чтобы имя пользователя имело минимальную длину 6, что мы можем легко проверить, если она действительна. А что насчет возрастной функции? Мы могли бы подтвердить, что возраст может быть пустым или должен быть не менее 18.

const isAgeValid = age => (age === undefined || age >= 18)

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

const getErrors = ({ name, age }) => {
  const errors = {}
  if (!isNameValid(name)) {
    errors.name = 'Name has to be a minimum length of 6'
  }
  // ... more validations
  return errors
}

В результате мы реализовали проверку, которая работает для очень конкретного случая: вызов getError с name и age вернет объект, содержащий возможные сообщения об ошибках. Мы можем отображать сообщения об ошибках внутри функции просмотра или делать с ней что-нибудь полезное. Теперь, можем ли мы сделать лучше?

Разрушение вещей

isNameValid нельзя использовать повторно, как и isAgeValid, поэтому давайте сделаем наши функции более многоразовыми, чтобы мы могли комбинировать их по мере необходимости, расширяя их использование за пределы одной части приложения. и превращая их в функции, не зависящие от проекта. Но с чего начать?

const isNameValid = name => name.trim().length >= 6

Комбинирование вспомогательных функций для создания функций-предикатов кажется здесь разумным подходом. Нам все равно, будет ли введено имя или номер дома. Что нас интересует, так это тот факт, что наши входные данные больше заданной длины.

const isGreaterThan = (len, data) => data > len

Теперь наш isNameValid можно переписать примерно так:

const isNameValid = isGreaterThan

И может называться так

isNameValid(5, name.trim().length)

Это выглядит не очень элегантно, так что давайте еще раз уточним. Нас интересует длина значения name, поэтому давайте напишем функцию, которая ожидает ввода и возвращает его длину.

const getLength = data => data.length
isNameValid(5, getLength(name.trim()))

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

Это нормально и хорошо, когда нам нужно быстро что-то наладить и запустить, но менее оптимально, когда это делается повсеместно. Почему бы не абстрагироваться от обработки валидации? Давайте попробуем.

Уточнение обработки валидации

Наша первая попытка - либо вернуть сообщение об ошибке в случае сбоя предиката, либо значение null в случае успеха.

const isGreatThan = (len, data) => (len > data )
  ? null
  : `A minimum length of ${(len + 1)} is required.`

Таким образом, вызов функции isGreaterThan вернет либо error string, либо null. Это позволяет нам определить сообщение об ошибке один раз и использовать его повсюду. Но все же в этом решении есть что-то неоптимальное. Мы определили сообщение об ошибке без учета контекста, в котором оно применяется. Возможно, мы хотим определить другое сообщение в другом приложении или в другой части существующего приложения. Пора еще раз провести рефакторинг нашей реализации.

const isGreaterThan = (len, errorMsg, data) => (len > data)
  ? null
  : errorMsg

Теперь мы можем вызвать isGreaterThan с определенной минимальной длиной и сообщением об ошибке и использовать его во всем приложении. Взгляните на это.

const errorMsg =  `A minimum length of 6 is required for Name.`
const isNameGreaterThanFive = isGreaterThan.bind(null, 5, errorMsg)
isNameGreaterThanFive(getLength(name.trim()))

Все это хорошо и отчасти можно использовать повторно, но что, если бы мы могли определить функцию, которая ожидает кортеж предиката и error message и либо возвращает null, либо строка ошибки.

const createPredicate = ([test, errorMsg])
  => a => test(a) ? null : errorMsg

Возвращаясь к нашей исходной функции isGreaterThan, мы можем провести рефакторинг до этого.

const errorMsg =  'A minimum length of 6 is required.'
const isGreaterThanFive =
  createPredicate([isGreaterThan.bind(null, 5), errorMsg])

Последний рефакторинг позволяет нам определить правила проверки для данного контекста, возможно, пройти через все валидаторы и вернуть ошибки. Для этого мы напишем функцию, которая ожидает карту, содержащую правила проверки для набора определенных входных данных. С этого момента мы будем использовать Ramda для map, filter и т. Д.

const createPredicates = validations 
  => R.map(createPredicate, validations)

Что можно отремонтировать до:

const createPredicates = R.map(createPredicate)

Теперь, когда у нас есть createPredicates, мы можем просто передать объект проверки. Интересно то, что входные данные могут иметь две или более проверки, которые необходимо применить. Например, поле random должно содержать заглавную букву и иметь минимальную длину 8.

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

const validationRules = {
  name: [
    [ isGreaterThan(5),
      `Minimum Name length of 6 is required.`
    ],
  ],
  random: [
    [ isGreaterThan(7), 'Minimum Random length of 8 is required.' ],
    [ hasCapitalLetter, 'Random should contain at least one uppercase letter.' ],
  ]
}

const validations = R.map(createPredicates, validationRules)

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

Представьте, что у нас есть такие входные данные.

const inputData = { name 'abcdef', random: 'z'}

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

const runPredicates = ([input, validations]) =>
  R.map(predFn => predFn(input), createPredicates(validations))

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

const validate = inputData => 
  R.map(data => runPredicates(data), inputData)

Или даже проще.

const validate = R.map(runPredicates)

Прежде чем продолжить, давайте посмотрим на нашу текущую реализацию.

const createPredicate = ([test, errorMsg]) 
  => a => test(a) ? null : errorMsg
const createPredicates = R.map(createPredicate)
const runPredicates = ([input, validations]) =>
  R.map(predFn => predFn(input), createPredicates(validations))
const validate = R.map(data => runPredicates(data))

На данный момент мы могли бы запустить validate с объектом проверки, содержащим ключи полей, которые сопоставляются с кортежем, содержащим ввод и предикаты.

validate({
  name: [
    'foobar',
    [
      [ isGreaterThan(5), 'Minimum length of 6 is required.' ]
    ],
  ]
})

Выполнение приведенного выше примера вернет объект, содержащий следующие значения.

{ name: [null] }

Если бы мы столкнулись с двумя функциями проверки, мы могли бы получить результат такого типа.

{ name: [null, 'Should contain at least one uppercase letter.'] }

Нас интересуют только сообщения об ошибках, и мы также хотим избавиться от массива. Теперь мы можем выбирать между разными вариантами. Обычным подходом было бы отфильтровать любые нулевые значения и присоединить к результату.

['foo', null, null, 'bar'].filter(x => x !== null).join(', ')

Это означает, что нам придется выполнить эту операцию «очистки» перед возвратом результата из нашей функции validate. Для этого потребуется лишь небольшое изменение внутри нашей функции validate.

const validate = R.map(data => 
  R.join(' ', R.filter(R.identity, runPredicates(data))))

И приведи, например, такой вывод.

{ name: 'Minimum length of 6 is required.' }

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

Расширенное решение

Теперь, когда у нас есть базовая реализация, пора усовершенствовать наше решение и сделать его еще более полезным. Вспомним, как реализован наш createPredicate.

const createPredicate = ([test, errorMsg]) => a 
  => test(a) ? null : errorMsg

Все это хорошо, но давайте добавим еще одну библиотеку. Мы добавим сказки Either, что поможет нам упростить обработку проверки. В документации указано, что «Either может содержать значение типа a или значение типа b в любой момент времени ... Обычно эта структура используется для представления вычислений, которые могут сбой, если вы хотите предоставить дополнительную информацию о сбое ».

На данный момент все, что нам нужно знать, это то, что Either может быть контейнером Left или Right, в котором хранится значение. Для более подробного объяснения я бы рекомендовал прочитать фантастическое В основном адекватное руководство по функциональному программированию (особенно главы 8–10) Брайана Лонсдорфа или обратиться к Сказочной документации. Чтобы лучше понять, почему использование Either может иметь смысл, подробнее рассмотрим следующий пример.

const hasCapitalLetter = (a) => /[A-Z]/.test(a)
  ? Either.Right(a)
  : Either.Left('Nope')

Допустим, наша функция validator не работает, теперь у нас есть экземпляр Either Left, содержащий явное сообщение об ошибке. Если преимущество пока неясно, скоро будет. Оглядываясь назад на наш исходный createPredicate, теперь мы можем провести его рефакторинг, чтобы вернуть либо Right, либо Left.

const createPredicate = 
  ([predFn, e]) => a => predFn(a) ? Right(a) : Left(e)

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

const validate = 
  R.map(R.compose(R.sequence(Either.of), runPredicates))

Это все, что нам нужно сделать, чтобы наша validate могла возвращать Right или Left. Чтобы полностью понять, что здесь происходит, давайте рассмотрим это шаг за шагом.

const validate = inputValidation => {
  const result = 
    inputValidation.map(validate => runPredicates(validate))
  return R.sequence(Either.of, result)
}

Мы сопоставляем все пары ввода / проверки и применяем к ним функцию runPredicates. В результате мы получаем объект, содержащий массив Eithers, но на самом деле мы хотим знать, есть ли где-то внутри этого массива экземпляр Left.

Здесь интересно понять, что как только будет найдено одно Left, результатом будет Left. Если Left не найдено, результатом будет один Right, содержащий все успешные результаты. Это именно то, что делает метод последовательности Ramda, поэтому мы просто запускаем последовательность с результатом наших сопоставленных пар проверки. Вот пример прямо из документации Ramda с Maybe.

R.sequence(Maybe.of, [Just(1), Just(2), Just(3)])
//=> Just([1, 2, 3])
R.sequence(Maybe.of, [Just(1), Just(2), Nothing()])
//=> Nothing()

Давайте также вспомним наши входные данные.

const validationRules = {
  name: [
    [ isGreaterThan(5),
      `Minimum Name length of 6 is required.`
    ],
  ],
  random: [
    [ isGreaterThan(7), 'Minimum Random length of 8 is required.' ],
    [ hasCapitalLetter, 'Random should contain at least one uppercase letter.' ],
  ]
}

const inputData = { name: 'Foo', random: 'test' }

У нас есть validationRules и inputData, которые могут поступать через отправку или выборку формы, на самом деле это не имеет значения. Что нам нужно, так это возможность объединить два входа в более крупную структуру. Этого можно добиться, быстро добавив вспомогательную функцию, которая позаботится о создании объекта проверки.

const makeValidationObject = R.mergeWithKey((k, l, r) => [l, r])

Мы используем функцию Ramda mergeWithKey, которая ожидает два объекта и возвращает [input, validation] в нашем случае, как только два ключа существуют в обоих объектах. Наконец, давайте представим функцию, которая ожидает данные, которые необходимо проверить, и сами правила проверки, поэтому нам не нужно вручную заботиться о создании новой структуры.

const getErrors =  R.compose(validate, makeValidationObject)

Когда мы передаем наши input данные и правила проверки в getErrors, мы получаем объект, содержащий все ключи со значениями Left или Right как результат.

getErrors(inputData, validationRules)

Вот полный код на текущий момент.

import R from 'ramda'
import Either from 'data.either'

const { Right, Left } = Either

const makePredicate = 
  ([predFn, e]) => a => predFn(a) ? Right(a) : Left(e)

const makePredicates = R.map(makePredicate)

const runPredicates = ([input, validations]) =>
  R.map(predFn => predFn(input), makePredicates(validations))

const validate = 
  R.map(R.compose(R.sequence(Either.of), runPredicates))

const makeValidationObject = R.mergeWithKey((k, l, r) => [l, r])

const getErrors =  R.compose(validate, makeValidationObject)

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

{ tab1: { userName: '' }, tab2: { streetNr: 2 } }

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

Чтобы уточнить, что мы имеем, когда вызываем getErrors, следующее.

{
  name: Left('Mininum Length of 6'),
  random: Right('FoobarBaz')
}

Нас интересуют ошибки. Давайте напишем функцию, которая знает, как обрабатывать экземпляр Either, применяя метод cata к экземпляру и возвращая желаемое значение.

const displayError = result =>
  result.cata({
    Right: a => null,
    Left: errorMsg => errorMsg
  })

Теперь все, что нам нужно сделать, это сопоставить результат и вызвать displayError для каждого свойства.

R.map(displayError, getErrors(inputData, validationRules))

В этом конкретном случае мы получаем либо строку, либо ноль.

{ name: null, random: "Minimum Random length of 8 is required." }

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

Вот полный пример.

Outro

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

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

Есть предположения? Дайте мне знать.

Есть вопросы или отзывы? Подключиться через Twitter

Ссылки

Рамда

Либо сказка