Предупреждение
Хотелось бы сразу предупредить о том, что все изложенное ниже, скорее, рассуждения на тему современных реалий мира JavaScript, нежели описание конкретного решения.
Предыстория
Примерно год назад фронтенд нашего проекта, в качестве фреймворка для которого используется Vue.js 2, дошел до состояния, хорошо описываемого словами "проще сжечь".
Дело было вот в чем: изначально, при выборе стека технологий, мы ориентировались на знакомые и простые, на наш взгляд, в освоении фреймворки. Мое предвзятое отношение к React, которое, к слову, со временем только усугубилось, не позволило выбрать его в качестве основы. С Angular версий 2+ и TypeScript никто из нас тогда не был знаком, а Ember мы сочли слишком сложным для того, чтобы быстро можно было сделать нормально работающий прототип, да и найти специалистов, знающих его, не так уж легко, хотя именно этот фреймворк я считаю лучшим выбором при наличии достаточно сильной команды и приличного бюджета. Так проект стартовал с базовым набором в виде Vue.js, Vuetify, Vuex и Vuelidate.
Как и любой стартап, проект рос, развивался, менялись требования, форм становилось все больше, и сложность каждой из них, как и количество взаимосвязей, возрастало. В нашем распоряжении была прекрасная библиотека Vuelidate, что сэкономила нам массу времени на начальном этапе, и именно она же вела нас к коллапсу по мере роста сложности и размеров проекта.
Точкой невозврата для нас стал момент выхода плагина Vue Composition API для Vue 2. К тому времени наш нетипизированный корабль уже трещал по всем швам, и решено было убить сразу нескольких зайцев одним выстрелом: полностью перевести проект на TypeScript, внедрить Vue Composition API и решить проблемы валидации сложных форм.
А проблемы были. Vuelidate - отличная библиотека с массой готовых валидаторов и остается таковой до тех пор, пока мы имеем дело с, так сказать, плоскими формами - формами, не использующими вложенных компонентов, также требующих валидации. Vuelidate работает на уровне компонента, требует наличия миксина, содержит массу правил, которые нам не были нужны, но все равно тянулись в составе пакета, поддержка TypeScript и Composition API на тот момент отсутствовала, и мы не могли более с этим миритьcя. К примеру, для того, чтобы по нажатию на кнопку "отправить" выделить все неправильно заполненные поля, приходилось серьезно извернуться, особенно при валидации переиспользуемых компонентов. Еще одной проблемой было отсутствие привязки текстового сообщения об ошибке к каждому конкретному правилу. В результате, для каждого поля приходилось создавать computed-свойство, что раздувало компонент до неприличных размеров. Ключевая, на мой взгляд, проблема всех подобных библиотек в том, что они пытаются охватить максимальный спектр задач, и, в результате, все равно не могут решить их все так, как нужно пользователю, при этом лишаются так необходимой универсальному решению легкости и гибкости.
Пример валидации для простого компонента с двумя полями
<template>
<form @submit.prevent="login">
<div>
<input
placeholder="E-mail"
id="email"
v-model="email"
maxlength="100"
@change="$v.email.$touch()"
/>
<div style="color: red" v-if="emailError">{{ emailError }}</div>
</div>
<div>
<input
placeholder="Password"
type="password"
id="password"
v-model="password"
maxlength="100"
@change="$v.password.$touch()"
/>
<div style="color: red" v-if="passwordError">{{ passwordError }}</div>
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
</template>
<script>
import { validationMixin } from 'vuelidate';
import { required, email, minLength } from 'vuelidate/lib/validators';
export default {
mixins: [validationMixin],
data: () => ({
email: '',
password: ''
}),
computed: {
emailError() {
const field = this.$v.email;
if (!field.$dirty) return '';
else if (!field.required) return 'Enter your e-mail.';
else if (!field.email) return 'Invalid e-mail format.';
else return '';
},
passwordError() {
const field = this.$v.password;
if (!field.$dirty) return '';
else if (!field.required) return 'Enter password.';
else if (!field.minLength) return 'Password must contain at least 8 characters.';
else return '';
}
},
methods: {
login() {
this.$v.$touch();
if (this.$v.$invalid) return;
// do something
}
},
validations: {
email: {
required,
email
},
password: {
required,
email,
minLength: minLength(8)
}
}
};
</script>
Поиск решения
Решение, на наш взгляд, состояло в переходе от использования Vuex и Vuelidate к использованию моделей с поддержкой валидации. Почему наши поиски закончились ничем, и как мы самостоятельно написали альтернативу - подробно расскажу в одной из следующих публикаций. С точки зрения модели, правило для валидации поля получилось довольно простым:
type ValidationRule = (value: any, data: Data) => boolean | string;
Это - функция, возвращающая булево значение или строку с текстом ошибки, здесь value
- значение проверяемого поля, а data
- объект, содержащий данные. Вызов происходит тогда, когда значение меняется. То есть сама по себе модель не умеет проводить проверку и не имеет каких-либо встроенных правил - это важный осознанный шаг.
Сегодня никто в здравом уме не станет писать достаточно большой проект без использования сторонних библиотек или фреймворков. Однако тенденция "слепить по-быстрому" ужасает. Такие проекты обрастают зависимостями в десятки и сотни мегабайт, и этот салат из неоптимизированного кода и сотен зависимостей вынуждены пережевывать современные производительные компьютеры, в том числе и портативные.
Многим, полагаю, знакома так называемая философия UNIX или UNIX way, которая часто сводится единственному правилу: программа должна выполнять одну задачу, но делать это хорошо. Именно такой хотелось видеть библиотеку валидации.
Таким образом, сформировался некоторый набор требований, которым должен был соответствовать кандидат:
возможность возвращать не только
true/false
, но и строку с текстом ошибки;не иметь лишних зависимостей;
иметь поддержку TypeScript;
не содержать ничего лишнего;
иметь удобный интерфейс для расширения функциональности.
К сожалению, готовое решение найти не удалось. Некоторые варианты можно было адаптировать под наши нужды, но почти все они были ориентированы на работу со схемами, что для нас было излишним. Были попытки использовать tsfv как наиболее подходящую, но решение требовало доработки, да и пункт не содержать ничего лишнего явно не мог быть соблюден. tsfv также пытается предусмотреть варианты на все случаи жизни, что делает ее публичный интерфейс более сложным, чем хотелось бы. В результе, вдоховившись общей архитектурой tsfv я решил написать свою упрощенную версию.
Над названием долго думать не пришлось, если tsfv - аналог v8n с типами, то новое решение получило название v9s по аналогии. Первый вариант был готов буквально через два дня и в основе своей уже содержал цепочки: каждое правило заключалось в свой экземпляр единственного библиотечного класса, содержащий ссылку следующий элемент цепи, а внешние правила можно было указывать при помощи вызова метода use()
этого же класса. Также имелась возможность указать альтернативную цепочку, благодаря методу or()
, которая отрабатывала в случае провала проверки основной, и инвертировать результат вызовом not()
. На выходе можно было получить функцию, которая принимала на вход значение, а возвращала boolean | string
.
Спустя примерно неделю, решение было доработано до приемлемого состояния и начало свою жизнь в качестве части некоторых наших проектов. Однако никакой самостоятельной системы сборки предусмотрено не было, требуемый порядок вызова методов для организации цепочки был контринтуитивен, а какая-либо документация отсутствовала в принципе.
На тот момент создание функции для проверки выглядело примерно так:
import v9s from '@/vendor/v9s';
function integer(value: string) {
return /^[0-9]+$/.test(value);
}
const check = v9s
.use(integer, 'The value must be an integer string.')
.minLength(1, 'Enter a value.')
.check;
check('1234'); // returns: true
check('1.22'); // returns: 'The value must be an integer string.'
check(''); // returns: 'Enter a value.'
Что внешне не отличается от конечного результата. На этом активная разработка самой библиотеки прекратилась и началось ее активное использование в течение 10 месяцев. За это время было произведено тестирование в реальных условиях, устранено большинство ошибок, добавлен новый полезный функционал, а вот времени на выделение четырех файлов в отдельный проект все никак не находилось. И только сейчас, когда стало понятно, что у этой разработки только два варианта: забыться в глубинах собственных проектов или быть представленной на суд общественности - я собрал-таки волю в кулак и пустился во все тяжкие взялся за подготовку проекта к публикации. Здесь стоит отметить, что сразу после создания текущая версия уже была помещена в приватный репозиторий на GitHub (не показывать же такой стыд людям).
Особенности решения
То, как устроена и работает сама библиотека, детально описывать здесь не вижу смысла, так как имеется достаточно подробная документация на двух языках (русский и хромающий английский) с массой примеров. Все же, вкратце обозреть особенности, возможности и ограничения стоит.
Первая и наиболее важная, определяющая особенность: v9s производит проверку единственного поля, а не всей схемы. Да, поле может быть и объектом или массивом, но проверка полей этого объекта (элементов массива) не должна быть задачей конкретной цепочки.
Следующая особенность: минимализм. Билиотека имеет очень простой интерфейс и минимальный набор встроенных правил, освоение и интеграция ее в проект занимает катастрофически малое время.
Последняя, но не менее важная особенность: широкие возможности для расширения. Помимо собственных правил, можно использовать и другие точки внедрения расширений.
Модификаторы
Кроме, собственно, правила, можно передать и функцию-модификатор, которая преобразует значение для последующих правил в цепочке. Проще всего будет разобрать на примере.
Предположим, что значением поля должна быть целочисленная строка, значение числа в которой находится в пределах от 10 до 100. Есть два способа решения данной задачи при помощи v9s:
Решение "в лоб":
import v9s from 'v9s';
function integer(value: string) {
return /^[0-9]+$/.test(value);
}
function min(minimum: number, value: string) {
return Number(value) >= minimum;
}
function max(maximum: number, value: string) {
return Number(value) <= maximum;
}
const check = v9s
.use(max.bind(undefined, 100), 'The value is greater than 100.')
.use(min.bind(undefined, 10), 'The value is less than 10.')
.use(integer, 'The value must be an integer string.')
.minLength(1, 'Enter a value.')
.check;
Решение с использованием модификатора:
import v9s from 'v9s';
function integer(value: string) {
return /^[0-9]+$/.test(value);
}
function stringToNumber(value: string) {
return Number(value);
}
const check = v9s
.lte(100, 'The value is greater than 100.')
.gte(10, 'The value is less than 10.')
.use(integer, 'The value must be an integer string.', stringToNumber)
.minLength(1, 'Enter a value.')
.check;
Во втором случае мы единожды выполнили преобразование значения при помощи модификатора и использовали лишь одно внешнее правило.
Контекст
Большинство форм, имеющих сложность чуть большую, чем форма ввода логина и пароля, содержат в себе и некоторые условия. Скажем, для заполнения анкеты физическому лицу требуется ввести ФИО, тогда как юридическому - наименование организации. Как уже было сказано выше: v9s производит проверку единственного поля, а не всей схемы, но это не значит, что мы не можем использовать состояние всего объекта валидации для корректной проверки единственного поля. Представим следующую схему:
interface Form {
isOrg: boolean;
orgName: string;
fullHumanName: string;
}
В зависимости от состояния флага isOrg
мы должны разрешить или запретить оставить соответствующие поля пустыми.
// старотовое состояние объекта
const formData = {
isOrg: false,
orgName: '',
fullHumanName: ''
};
// проверяем, что поле orgName не пустое, только если это юр. лицо
const checkOrgName = v9s
.use((value: string, context: Form) => context.isOrg && value.length)
.check;
// проверяем, что поле fullHumanName не пустое, только если это физ. лицо
const checkFullHumanName = v9s
.use((value: string, context: Form) => !context.isOrg && value.length)
.check;
checkOrgName(formData.orgName, formData); // returns: false
checkFullHumanName(formData.fullHumanName, formData); // returns: true
По умолчанию в качестве контекста используется пустой объект, так что можно, не передавая его явно, задавать свойства и позволить правилам "общаться". Например, можно передавать промежуточные вычисления от одного правила к другому вверх по цепочке, не модифицируя проверяемое значение.
Интернационализация
Вместо строки с сообщением об ошибке, можно передать функцию, возвращающую строку:
enum Lang {
en,
ru
}
const lang: Lang = Lang.en;
// функция возвращает локализованный текст ошибки
function minLengthError() {
switch (lang) {
case Lang.ru:
return 'Введите значение.';
default:
return 'Enter a value.';
}
}
const check = v9s.minLength(1, minLengthError).check;
check(''); // returns: 'Enter a value.'
lang = Lang.ru;
check(''); // returns: 'Введите значение.'
Подготовка к публикации
Первым шагом стала подготовка системы сборки. Выбор пал на bili как на простую в использовании и дающую, как мне тогда казалось, нужный результат. Теперь настала пора покрыть код тестами. Именно на этом этапе возникли сомнения в правильности публичного интерфейса.
v9s.use(integer).or(v9s.eq(true).boolean()).string().check;
Не слишком очевидно, что or()
относится к string()
, не так ли? Также среди встроенных правил были такие как min()
и max()
- абсолютно идентичные gte()
и lte()
, что противоречило поставленной цели, да и, вообще, не имеет смысла. Подобных недоработок накопилось достаточно, в основном, в силу того, что многие возможности просто не были востребованы нами. В результате, процесс написания модульных тестов вылился еще и в рефакторинг. Кто-то справедливо может заметить, что писать тесты нужно сразу же, но давайте будем честны: тесты отнимают время, и сразу ими покрывают, обычно, только критичные участки, а с v9s больших проблем в процессе эксплуатации у нас не возникало.
Предыдущий пример после рефакторинга; уже не вызывает приступов мигрени:
v9s.use(integer).string().or(v9s.eq(true).boolean()).check;
Далее пришла пора слегка облегчить жизнь пользователям IDE и добавить описания всего и вся: ох и нудная же работа. Многие не раз читали про самодокументирующийся код. Что ж, пока речь идет о внутреннем проекте, где все участники знают общую архитектуру и принципы именования, отсутствие комментариев, описывающих функции/классы/типы/константы/подставить нужное, можно пережить. Но как только вы хотите дать доступ к вашему проекту стороннему разработчику, то очень полезно дать ему подсказки, касающиеся публичного интерфейса, которые могут позволить ему, не открывая страничку с документацией (если она вообще есть), с ходу понять - что делает та или иная часть вашего проекта. И каждый раз, когда в IDE будет всплывать автоматическая подсказка не только с сигнатурой метода, но и с его описанием - разработчик будет мысленно благодарить вас за проделанную работу. По крайней мере, я всегда ценю такую заботу о пользователях. Написание этих комментариев отняло у меня два дня жизни - столько же, сколько я потратил на написание первого прототипа библиотеки.
Тут мне пришла в голову светлая мысль: проверить работоспособность полученного npm-пакета. Разочарование не заставило себя долго ждать: Node.js версии 14+ имеет встроенную поддержку модулей без каких-либо экспериментальных флагов. На выходе bili был ES-модуль, и такой модуль нельзя было напрямую использовать в проекте с CommonJS-модулями. Node.js требовала соответствующего типа модуля в .js
-файле или CommonJS-модуля в файле с расширением .cjs
. Попытка перенастроить bili так, чтобы в .js
-файле лежал CommonJS-модуль привела к тому, что теперь результат нельзя было использовать в проекте с ES-модулями ("type": "module"
). В конечном итоге, решено было заменить bili, который не умеет генерировать файлы с расширениями cjs
и mjs
, на rollup.js, фронтендом для которого и является bili.
Но время для отдыха еще не настало: впереди ждала документация. Благо, в наши дни есть масса готовых движков для документации. Я же воспользовался Vuepress просто потому что уже имел некоторый опыт его использования. Получилась ли документация хорошей - решать не мне, но она есть и позволит любому заинтересовавшемуся ознакомиться с v9s.
В общей сложности подготовка к публикации заняла в два раза больше времени, чем написание самого кода.
Конечно, если делать "по уму", то есть по мере разработки писать тесты, комментарии и документацию, то процесс подготовки занял бы значительно меньше времени, но проблема в том, что никто не планировал изначально тратить на это ресурсы, ведь не было задачи опубликовать, а объяснить коллегам принципы работы библиотеки не составило труда.
Выводы
Если 20 лет назад сообщество было счастливо вообще самому факту наличия исходных текстов под свободной лицензией в открытом доступе (кто видел код XINE, тот над бананами Angular не смеется), то сейчас требования сильно возросли. Программистам-одиночкам стало гораздо сложнее представить собственные разработки общественности. Я уверен, что во время наших поисков готовой библиотеки, где-то там, в закоулках GitHub, было подходящее решение, но его автор не смог или не захотел оформить его подобающим образом. Что касается конкретно мира разработки JavaScript, то разнообразие даже среди таких базовых вещей как сборщики проектов, заставляет меня, как разработчика, слишком много времени тратить на непродуктивные задачи. Конечно, можно подготовить конфиг на все случаи жизни для любимого сборщика и использовать его во всех своих проектах, но, к сожалению, даже эта схема не работает. В один прекрасный момент мой шаблоный конфиг для Webpack просто перестал работать - оказалось, что вышла, на тот момент новая, 4 версия, несовместимая с 3. А потом на горизонте возник весь такой компактный и модный rollup.js... Хотел бы я сказать, что решение лежит в стандартизации, но все знают шутку про стандарты. Однако я точно знаю, что нам пора прекращать писать монструозные комбаны по типу all-in-one и пора начать серьезней относиться к зависимостям наших проектов. Шутки про размер node_modules
- вовсе не шутки. Мы часто не думаем, что происходит под капотом, когда вбиваем npm i <package-name>
и жмем Enter, а потом решаем опубликовать свой пакет. Я считаю, что нужно серьезней относиться к выбору зависимостей для проекта и чаще думать над архитектурой. Возможно, тогда мы сможем переиспользовать, комбинировать и расширять чужие решения с меньшим страхом сотворить чудовище Франкенштейна.
Я не знаю решения описанных выше проблем (может, кто-то знает?), как и не знаю переспектив для v9s. Cложно говорить о каких-либо перспективах столь малого проекта. Мне он не кажется идеальным, там есть над чем подумать в плане внутреннего устройства, но пока работает так. Также имеются мысли собрать в отдельный пакет наиболее полезные правила, как это сделали разработчики Toi. Буду рад любой конструктивной критике как проекта, так и статьи, и посильной помощи в поддержке. Аргументированное мнение на тему нужно/не нужно также приветствуется. Если кто-то захочет внести правки в документацию - также буду крайне благодарен. На этом, пожалуй, все. Сейчас в работе документация к моделям, о которых речь пойдет в другой раз.
U.P.D.: Хочется выразить благодарность пользователю @tzlom за указание на досадную ошибку проектирования, которая существенно ограничивала возможности решения. Теперь можно указать любой тип для сообщения об ошибке, кроме boolean
и Function
:
import v9s from 'v9s';
interface ErrorMessage {
code: number;
critical: boolean;
text: string;
}
const check = v9s
.lte<ErrorMessage>(100, { code: 1, critical: false, text: 'Too big' })
.gte(100, { code: 0, critical: true, text: 'Too small' })
.check;
check(20); // returns: true
check(5); // returns: { code: 0, critical: true, text: 'Too small' }
check(110); // returns: { code: 1, critical: false, text: 'Too big' }