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, то есть сохраняется единый источник данных.

VInput.vue

<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 валидации мы можем интегрировать валидацию в модель, это упростит тестирование.

Добавляем в модель механизм валидации

useJSVehicleModel.ts

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.