Vue3, Composition API, Typescript

Представим, что на сайте страховой компании нужна форма для ввода данных о транспортном средстве.
В форме есть поля: тип, марка, модель, номерной знак.
Бизнес-логика:
Формат номера зависит от типа ТС
Варианты марок — от типа ТС
Варианты модели — от типа ТС и марки
Валидация: все поля должны быть заполнены
Номер валидируется на фронте по формату
Номер валидируется сервером на уникальность
Функционал:
Бизнес-логика с зависимыми полями и атрибутами полей — значение одного поля может влиять на значения или атрибуты других полей
Frontend валидация
Backend валидация
Кастомный UI. Кастомные элементы, тексты и отображение ошибок
Требования к архитектуре:
возможность переиспользования модели для расширения, использования внутри других форм
удобство тестирования
Слои приложения:
API service
Модель данных (Model)
Vue-компонент, связывающий модель и представление(View-Model/Controller/Presenter)
UI (View)
Для демонстрации архитектуры создан проект. Проект не отражает всех возможностей архитектуры, но служит для демонстрации подхода. В проекте используются: TypeScript, Nuxt, Composition API, Tailwind, Nuxt UI.
API service
Для отправки запросов используем встроенную в Nuxt 4 oFetch
Важно: Interceptor-ы HTTP клиентов (fetch, axios, …) обычно используются для обработки 500-ых ошибок и авторизации. Для этого Interceptor-ы часто используют функции типа: useAuthStore(), useRouter(), useToast(). Вызовы стора, роутера, composables доступны только внутри setup либо других composables. Поэтому функции отправки запросов храним в composable.
Здесь можно посмотреть определение useApi, которое включает интерцепторы: useApi
Composable для отправки запросов.
useVehicleApi
import type {SaveVehicleRequestBody} from "#shared/types/vehicles"; import {useApi} from "./useApi"; export const useVehicleApi = () => { const {api} = useApi() const fetchVehicleBrands= (type: VehicleType) => { return api<string[]>('/vehicles/brands', {query: {type}}); } const fetchVehicleModels = (type: string, brand: string) => { return api<string[]>('/vehicles/models', {query: {type, brand}}); } /** @throws {Error} if plate is already saved */ const validatePlate = async (plate: string) => { return api('/vehicles/plate',{query: {plate, ignoreResponseError: true }}); } /** @throws {Error} if plate is already saved */ const saveVehicle = (vehicle: SaveVehicleRequestBody) => { return api('/vehicles', { method: 'POST', body: vehicle }) } return { fetchVehicleBrands, fetchVehicleModels, validatePlate, saveVehicle, } }
Модель данных
Можно поместить модель и бизнес логику в
1 Pinia store (или composable), в котором будет:
8 переменных, 2 computed и 7 функций
И это +- нормально.
Но, если форма становится больше (например будут блоки водителей и страхователя), тогда добавляются новые сторы, которые приобретают вложенность, простыня кода и переменных растет, код быстро становится трудночитаемым.
Решение проблемы в том, чтобы данные объекта (ТС) хранить в объекте.
useVehicleModel.ts
import {useVehicleApi} from "~/composables/useVehicleAPI"; import { VehicleType } from '../../shared/types/vehicles' import {plateConfig, plateMask} from "~/utils/plateMask"; export const useVehicleModel = () => { const { fetchVehicleBrands, fetchVehicleModels, saveVehicle, validatePlate, } = useVehicleApi() return { type: { value: <VehicleType | undefined> undefined, options: Object.values(VehicleType), }, brand: { value: <string | undefined> undefined, options: <string[]> [], async updateOptions(vehicleType: VehicleType) { this.options = await fetchVehicleBrands(vehicleType) ?? [] }, }, model: { value: <string | undefined> undefined, options: <string[]> [], async updateOptions(type: string, brand: string) { this.options = await fetchVehicleModels(type, brand) ?? [] }, }, plate: { value: '', placeholder: '', mask: plateMask, setMask(vehicleType: VehicleType) { const config = plateConfig.get(vehicleType) this.mask.mask= config?.mask this.placeholder = config?.placeholder ?? '' }, error: <string | undefined> undefined, async validate() { try { await validatePlate(this.value) this.error = undefined; } catch (err) { const error = err as {message?: string} this.error = error?.message ?? 'error'; } }, resetError() { this.error = undefined } }, async onTypeSet(){ if (this.type.value) { this.brand.value = undefined this.model.value = undefined this.model.options = [] this.plate.value = '' this.plate.setMask(this.type.value) await this.brand.updateOptions(this.type.value) } }, async onBrandSet(){ if (this.type.value && this.brand.value) { this.model.value = undefined; this.model.updateOptions(this.type.value, this.brand.value) } }, async onSubmit() { if (this.type.value && this.brand.value && this.model.value && this.plate.value) { return await saveVehicle({ type: this.type.value, brand: this.brand.value, model: this.model.value, plate: this.plate.value }) } } } }
модель можно использовать в setup компонента, обернув в reactive()
<script setup lang="ts"> const vehicle = reactive(useJSVehicleModel()) </script>
либо в Pinia store (composition API)
export const useVehicleStore = defineStore('vehicle', () => { return reactive(useVehicleModel()); })
Важно: в функции НЕ ИСПОЛЬЗУЕМ ref, reactive, watcher, computed, lifeсycle-hooks. Возвращаем обычный JS объект. К нашему composable относимся как к обычной функции-фабрике.
Такой подход позволяет использовать модель в парадигме object composition, это делает ее гибкой и переиспользуемой.
Реактивность и связанный с ней функционал добавим позже.
Важно: Модель объекта получаем из composable (функции-фабрики), а не из константы JS модуля. Это позволяет подключать другие composables и получать всегда новый объект, а не ссылку.
Важно: Разумно хранить в модели только изменяемые поля (которые станут реактивными), либо для статичных вызывать markRaw()
Преимущества:
Четкая структура, нейминг.
Любая вложенность (блоки/разделы формы).
Расширение модели. Переиспользование кода.(композиция из объектов других фабрик/классов)
Возможность динамической конфигурации (функция, которая изменяет состав и наполнение полей)
Работа обычными инструментами JS.(spread operator, Object.assign, Object.create, прототипное наследование)
Переиспользование функций в качестве методов объектов. (неявное связывание this)
Передача объекта через props дочерним компонетам Vue.(дочерние компоненты могут вызывать методы напрямую)
Использование в Pinia store.
Unit-тестирование модели в любом контексте(mock HTTP-клиента и тестирование без Vue контекста)
Недостатки:
В Nuxt в режиме SSR при использовании Pinia store с вложенными объектами ломается гидрация. (Nuxt производя глубокое копирование ломает функции)
Классы: Для создания объектов модели можно использовать классы (c вызовом composables в конструкторе).
Преимущества: самодокументуруемая структура. (типизация объектов). Наследование getter-ов, setter-ов. (их использование — отдельный вопрос*)
Недостатки: переиспользование кода с помощью наследования создает иерархичную, менее гибкую структуру (SOLID)Рекомендация: классы оправданы для создания модели схожих полей. В остальных случаях (модели форм, блоков) предпочтительнее функции-фабрики.
Кстати: useVehicleModel в чистом виде можно использовать в React + MobX (MobX как и Vue создает реактивные объекты с помощью Proxy)Vue composables — то же что custom hooks в React.
Frontend валидация
Всего во Vue можно реализовать 3 типа front валидации.
(В React также доступен стандартный hook useActionState)
HTML Validation API
Библиотеки для управления формами (Vee-Validate, Nuxt UI, …)
JS валидация (js-native, YUP, ZOD …)
Комбинирование разных технологий валидации — плохая идея.
А выбор технологии влияет на архитектуру формы.
Поэтому рассмотрим все три варианта.
HTML Validation API
Как работает HTML Validation API: строго связана с структурой HTML nodes. Внутри <form> проверяются элементы <input/>, <selector> и их атрибуты. Производит валидацию под капотом HTML. Получить данные о валидации можно через readonly проперти объектов элементов.При невалидном значении элемент получает псевдокласс :invalid.Можно добавить кастомные ошибки (серверной валидации) через setCustomValidityСпойлер: не использовать, если есть кастомные/библиотечные selector-ы. То есть лучше не использовать нигде сложнее формы входа/регистрации.
При использовании HTML Validation ответственность за валидацию неизбежно утекает в UI компоненты.
Мы хотим использовать свои собственные тексты ошибок и добавить серверную валидацию.
Для этого напишем composable, который сможем переиспользовать в UI компонентах ввода.
useHTMLValidation
/** Composable for HTML5 validation handling. For use inside UI elements. * Synchronizes customError with HTML. * Handles custom error messages for HTML5 validation. * @param el templateRef of an input with HTML validation * @param customError custom error which comes from server-side validation or other sources */ export function useHTMLValidation( el: Ref<HTMLInputElement | HTMLSelectElement | null>, customError: Ref<string | undefined> ) { /** Synchronizes custom error with HTML5 validation */ watch(() => customError.value, (newVal) => { console.log(customError.value) el.value?.setCustomValidity(newVal ?? '') }, { immediate: true }) /** Collection of errors custom messages. * Can be used to customize messages by changing values */ const errorsMessages: Partial<Record<keyof ValidityState, string>> = { badInput: 'The value entered is not valid', patternMismatch: 'The value does not match the required pattern', rangeOverflow: 'The value exceeds the maximum allowed', rangeUnderflow: 'The value is below the minimum allowed', stepMismatch: 'The value does not match the allowed step', tooLong: 'The value is too long', tooShort: 'The value is too short', typeMismatch: 'The value is of the wrong type', valueMissing: 'This field is required', customError: 'A custom validation rule has been violated' } /** Function for HTML5 validation of the provided element. * Can be called in a template to update messages on component render. * @returns array of errors custom messages based on the HTML validation result */ const getErrorsCustomMessages = () => { const validity = el.value?.validity if (validity) { const keys = Object.getOwnPropertyNames(Object.getPrototypeOf(validity)) as (keyof ValidityState)[]; errorsMessages.customError = customError.value return keys .filter(key => !['valid', 'constructor'].includes(key) && validity[key]) .map((key) => errorsMessages[key]) } return []; } return { errorsMessages, getErrorsCustomMessages } }
Важно: el.value?.validity — геттер, отражающий состояние валидции. Мы никак не можем им управлять, он readonly.
Использование в компоненте HInput:
<template> <div class="element-wrapper"> <label v-if="label" class="element_label">{{ label }}</label> <input ref="el" v-maska="mask" class="element_input_native peer" :value="modelValue" v-bind="$attrs" @input="onInput" > <div class="element_error peer-user-invalid:block hidden"> {{ getErrorsCustomMessages().join('; ') }} </div> </div> </template> <script setup lang="ts"> import { vMaska } from 'maska/vue' import { useHTMLValidation} from "~/features/htmlValidationForm/useHTMLValidation"; import type {MaskOptions} from "~/utils/plateMask"; const props = defineProps<{ modelValue?: string; customError?: string; label?: string; mask?: MaskOptions; }>() const emits = defineEmits<{ (e: 'update:modelValue', value: string): void; (e: 'input', value: Event): void; }>() const el = useTemplateRef('el') const { customError } = toRefs(props); const { getErrorsCustomMessages } = useHTMLValidation(el, customError) const onInput = (event: Event) => { emits('update:modelValue', (event.target as HTMLSelectElement).value); emits('input', event) } </script>
Важно: псевдокласс :invalid будет включен, если поле невалидно, в том числе при монтаже компонента, когда пользователь никак не взаимодействовал с формой. Проблема решается использованием псевдокласса :user-invalid
И компонент в котором модель встречается с элементами
VehicleFormHTML.vue
<template> <form ref="form" novalidate class="w-full" @submit.prevent="onSubmit"> <HSelect v-model="vehicle.type.value" :options=" vehicle.type?.options" label="Type" required @change="() => { vehicle.onTypeSet(); checkFormErrors(); }" /> <HSelect v-model="vehicle.brand.value" :options="vehicle.brand?.options" :disabled="!vehicle.type.value" label="Brand" required @change="() => { vehicle.onBrandSet(); vehicle.plate.resetError(); checkFormErrors(); }" /> <HSelect v-model="vehicle.model.value" :options="vehicle.model?.options" :disabled="!vehicle.brand.value" label="Model" required @change="() => { vehicle.plate.resetError(); checkFormErrors(); }" /> <HInput v-model="vehicle.plate.value" :custom-error="vehicle.plate.error" :placeholder="vehicle.plate.placeholder" :mask="vehicle.plate.mask" label="Plate" required @blur="async () => { await vehicle.plate.validate(); checkFormErrors() }" /> <UButton type="submit">Submit</UButton> </form> </template> <script setup lang="ts"> import HSelect from "~/features/htmlValidationForm/elements/HSelect.vue"; import HInput from "~/features/htmlValidationForm/elements/HInput.vue"; import {useVehicleStore} from "~/features/htmlValidationForm/useVehicleStore"; const toast = useToast() const vehicle = useVehicleStore() const form = useTemplateRef('form') const isFormValid = ref(true) const checkFormErrors = () => { isFormValid.value = form.value?.checkValidity() ?? true } async function onSubmit() { try { checkFormErrors() await vehicle.onSubmit() toast.add({ title: 'Vehicle saved', color: 'success' }) } catch { toast.add({ title: 'Failed to save the vehicle', color: 'error' }) } } onMounted(() => { vehicle.type.value = VehicleType.car vehicle.onTypeSet() checkFormErrors(); }) </script> <style scoped> input:focus-visible { outline: none; } </style>
Достоинства: очень простой интерфейс при работе без кастомизации. Недостатки: может не работать вместе с кастомными (UI-kit) селекторами (под капотом обычно используются списки, они не воспроинимаются HTML как поле ввода)Сложная кастомизация текстов ошибок.Сложная кастомизация отображения ошибок.
Vee-validate
Как и в случае с HTML-validation, валидация происходит под капотом стороннего модуля, но отличие в том, что API VEE-Validate гораздо более удобный и гибкий.
Мы используем ту же модель что и для предыдущего варианта
Внутри UI компонента используем useField(.. , .. , { syncVModel: true})
Это не синхронизация двух переменных, полученный value хранит ту же ссылку, что и props.modelValue, то есть сохраняется единый источник данных.
<template> <div class="element-wrapper"> <label v-if="label" class="element_label">{{ label }}</label> <UInput v-model="value" v-bind="$attrs" v-maska="mask" :type="type" :color="errorMessage ? 'error' : 'success'" :highlight="!!errorMessage" :data-testid="`select-${label}`" class="w-full" @focus="$emit('focus', $event)" @blur="$emit('blur', $event)" /> <div v-if="errorMessage" class="element_error"> {{ errorMessage }} </div> </div> </template> <script setup lang="ts"> import {useField} from "vee-validate"; import { vMaska } from 'maska/vue' import type {MaskOptions} from "~/utils/plateMask"; const props = defineProps<{ name: string modelValue?: string | number error?: string label?: string type?: string mask: MaskOptions }>() defineEmits<{ (e: 'update:modelValue', value: string): void; (e: 'input' | 'blur' | 'focus', value: Event): void; }>() const {name} = toRefs(props) const { value, errorMessage, setErrors } = useField<string>(name, undefined, { syncVModel: true, }) watch(() => props.error, () => { if (props.error) { setErrors(props.error) } }, {immediate: true}) </script>
И компонент формы VehicleFormUI.vue
<template> <Form :validation-schema="schema" class="w-full" @submit="onSubmit"> <VSelect v-model="vehicle.type.value" name="type" :options="vehicle.type.options" label="Type" placeholder="Select type" @update:model-value="() => vehicle.onTypeSet()" /> <VSelect v-model="vehicle.brand.value" :options="vehicle.brand.options" name="brand" label="Brand" placeholder="Select brand" :disabled="!vehicle.type.value" @update:model-value="() => vehicle.onBrandSet()" /> <VSelect v-model="vehicle.model.value" :options="vehicle.model.options" name="model" label="Model" placeholder="Select model" :disabled="!vehicle.brand.value" /> <VInput v-model="vehicle.plate.value" name="plate" label="Plate" :error="vehicle.plate.error" :placeholder="vehicle.plate.placeholder" :mask="vehicle.plate.mask" @blur="() => vehicle.plate.validate()" /> <UButton type="submit">Submit</UButton> </Form> </template> <script setup lang="ts"> import { Form } from "vee-validate"; import * as yup from "yup"; import { reactive, onMounted } from "vue"; import VSelect from "~/features/UIValidationForm/elements/VSelect.vue"; import VInput from "~/features/UIValidationForm/elements/VInput.vue"; import {useVehicleModel} from "~/composables/useVehicleModel"; const toast = useToast() const vehicle = reactive(useVehicleModel()); const schema = yup.object({ type: yup.string().required("Vehicle type is required"), brand: yup.string().required("Vehicle brand is required"), model: yup.string().required("Vehicle model is required"), plate: yup.string().required("Vehicle plate is required"), }); const onSubmit = async () => { try { await vehicle.onSubmit() toast.add({ title: 'Vehicle saved', color: 'success' }) } catch { toast.add({ title: 'Failed to save the vehicle', color: 'error' }) } } onMounted(() => { vehicle.type.value = VehicleType.car; vehicle.onTypeSet(); }); </script>
Достоинства: Удобный и гибкий API. Совместим с любыми UI-kit и библиотеками валидации.
Недостатки: Размер библиотеки 500+ kb. Местами путанная документация.
JS validation
При JS валидации мы можем интегрировать валидацию в модель, это упростит тестирование.
Добавляем в модель механизм валидации
import * as yup from 'yup'; import {useVehicleApi} from "~/composables/useVehicleAPI"; import {HttpError} from "~/composables/useApi"; import {useVehicleModel} from "~/composables/useVehicleModel"; export interface ValidatedField<T> { value?: T; schema: yup.Schema<T> error?: string, validate: () => void, resetError: () => void } export const useJSVehicleModel = () => { const baseModel = useVehicleModel(); const { validatePlate } = useVehicleApi(); const requiredField: ValidatedField<string>= { error: <string | undefined> undefined, schema: yup.string().required('Field is required'), resetError() { this.error = undefined }, async validate() { try { await this.schema.validate(this.value); this.error = undefined; } catch (error) { this.error = (<yup.ValidationError> error).message; } } } return { ...baseModel, type: { ...baseModel.type, ...requiredField }, brand: { ...baseModel.brand, ...requiredField, }, model: { ...baseModel.model, ...requiredField }, plate: { ...baseModel.plate, ...requiredField, async validate() { try { await this.schema.validate(this.value); await validatePlate(this.value) this.error = undefined; } catch (err) { if (err instanceof yup.ValidationError) { this.error = err.message return } if (err instanceof HttpError && err.response.status === 422) { this.error = err.response._data return } throw err; } } }, validateForm() { return Promise.all([ this.type.validate(), this.brand.validate(), this.model.validate(), this.plate.validate(), ]) }, }; }
и компонент VehicleFormJS.vue
<template> <div> <form class="w-full" @submit.prevent="onSubmit"> <JSelect v-model="vehicle.type.value" :error="vehicle.type?.error" :options="vehicle.type.options" label="Type" placeholder="Select type" @change="async () => { await vehicle.onTypeSet(); vehicle.type.validate(); }" @focus="() => vehicle.type.resetError()" /> <JSelect v-model="vehicle.brand.value" :error="vehicle.brand?.error" :options="vehicle.brand.options" label="Brand" placeholder="Select brand" @change="async () => { await vehicle.onBrandSet(); vehicle.brand.validate(); }" @focus="() => vehicle.brand.resetError()" /> <JSelect v-model="vehicle.model.value" :error="vehicle.model?.error" :options="vehicle.model.options" :disabled="!vehicle.brand.value" label="Model" placeholder="Select model" @change="() => vehicle.model.validate()" @focus="() => vehicle.model.resetError()" /> <JInput v-model="vehicle.plate.value" :error="vehicle.plate?.error" :mask="vehicle.plate.mask" label="Plate" :placeholder="vehicle.plate.placeholder" @blur="() => vehicle.plate.validate()" @focus="() => vehicle.plate?.resetError()" /> <UButton type="submit">Submit</UButton> </form> </div> </template> <script setup lang="ts"> import JSelect from "~/features/JSValidationForm/elements/JSelect.vue"; import JInput from "~/features/JSValidationForm/elements/JInput.vue"; import {useJSVehicleModel} from "~/features/JSValidationForm/composables/useJSVehicleModel"; const vehicle = reactive(useJSVehicleModel()) const toast = useToast() async function onSubmit(): Promise<void> { try { await vehicle.validateForm() await vehicle.onSubmit() toast.add({ title: 'Vehicle saved', color: 'success' }) } catch { toast.add({ title: 'Failed to save the vehicle', color: 'error' }) } } onMounted(() => { vehicle.type.value = VehicleType.car vehicle.onTypeSet() }) </script>
Компоненты полей (JSelect, JInput) глупые, они просто отображают ошибки, если они есть, и пробрасывают events родителю.
Достоинства: Полное управление валидацией полей и формы в целом. Интеграция состояния валидации в модель данных (тестирование валидации вместе с моделью).
Недостатки: Необходимость обработки event-ов для проведения валидации.Необходимость написать функции валидации и сброса ошибок.
Кстати: эти недостатки можно нивелировать путем создания компонентов оберток над инпутами. Они смогут инкапсулировать поведение валидации, но это будет шаг в сторону создания собственной библиотеки.
Заключение
Вывод: мы получили, по сути, универсальную архитектуру для форм любой сложности с четким делением зон ответственности и гибкой, масштабируемой структурой. Для front валидации сложных форм предпочтительно использовать Vee-validate либо JS библиотеки (YUP, ZOD...)
P.S. Подобная архитектура успешно применялась в проекте с более чем 30-ю сложными формами, в каждой из которых была сложная логика, вложенные блоки и множество полей.
* Вопрос об использовании геттеров и сеттеров в модели данных. Геттеры и сеттеры могут быть удобны в модели, но важно помнить, что они не копируются через spread operator и Object.assign. Потребуется использовать прототипное наследование (Object.create) либо классы. О геттерах также следует помнить, что их функции будут выполняться при каждом ререндере компонента, они не кешируются в отличие от computed.
