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.
