Изолируем состояние формы во Vue 3 с помощью паттерна "Отложенный v-model"

Изолируем состояние формы во Vue 3 с помощью паттерна "Отложенный v-model"

Сегодня я хочу поделиться простым, но очень полезным паттерном для работы с формами во Vue 3, который я часто использую в своих проектах. Суть его заключается в том, чтобы изолировать внутреннее состояние формы и не передавать каждое изменение немедленно в родительский компонент через v-model. Вместо этого мы накапливаем изменения внутри компонента формы и отправляем их "наверх" только по определённому событию, например, по нажатию кнопки "Отправить" и после успешной валидации.

Проблема стандартного v-model

Стандартное поведение v-model на компонентах форм во Vue — это двустороннее связывание данных. Как только пользователь вводит символ в поле ввода, это изменение тут же отражается в переменной, связанной через v-model в родительском компоненте. Это удобно во многих случаях, но что если нам нужно выполнить какую-то логику перед тем, как обновлённые данные попадут к родителю?

Самый частый пример — валидация. Мы хотим проверить корректность введённых данных (например, что email действительно похож на email, а возраст — положительное число) и только если все проверки пройдены, передать данные дальше. Другой пример — трансформация данных: возможно, нужно привести строки к определённому регистру или отформатировать число перед отправкой.

С нативным v-model такая промежуточная обработка становится неудобной, так как родитель получает "сырые" и потенциально невалидные данные при каждом изменении.

Решение: Паттерн "Отложенный v-model"

Идея проста:

Компонент формы принимает данные через v-model (или пропс modelValue и событие update:modelValue).

Внутри компонента мы создаём локальную копию этих данных (например, с помощью ref).

В нашем примере я делаю копию данных наиболее простым способом, через JSON.parse. Учтите, что JSON.stringify/parse имеет ограничения (теряет Date, Function, undefined и т.д.). Для сложных случаев рассмотрите structuredClone (в современных средах) или библиотеки типа lodash/cloneDeep.

Все поля формы (<input>, <select> и т.д.) связываются с этой локальной копией, а не с v-model напрямую.

Когда пользователь инициирует отправку формы (например, кликает на кнопку "Submit"):

  • Мы выполняем всю необходимую логику: валидацию, трансформацию данных и т.д., используя локальную копию.
  • Если все проверки успешны, мы обновляем оригинальный v-model (вызываем update:modelValue), передавая ему обработанные данные из локальной копии.
  • Мы также используем watch для отслеживания изменений входного modelValue, чтобы обновлять нашу локальную копию, если данные изменятся извне (например, при загрузке данных для редактирования).

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

Пример реализации (Vue 3 + TypeScript)

Давайте перепишем изначальный пример на TypeScript и разберем его.

Компонент формы UserForm.vue:

<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

const modelValue = defineModel<UserFormData | null>()

function clone<T>(obj: T): T {
  if (obj === undefined || obj === null) {
    return JSON.parse(JSON.stringify({ name: "", email: "", age: null }));
  }
  return JSON.parse(JSON.stringify(obj))
}

const form: Ref<UserFormData> = ref(clone(modelValue.value))

watch(modelValue, (newValue) => {
  form.value = clone(newValue)
}, { deep: true }) // deep: true необходимо для отслеживания изменений внутри объекта

function handleSubmit() {
  // const isValid = validateForm(form.value);
  // if (!isValid) return;

  modelValue.value = clone(form.value)
  console.log('Form submitted and model updated:', modelValue.value);
}
</script>

<template>
  <form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
    <label class="input input-bordered flex items-center gap-2">
      Name
      <input class="flex-grow" type="text" v-model="form.name" required />
    </label>
    <label class="input input-bordered flex items-center gap-2">
      Email
      <input class="flex-grow" type="email" v-model="form.email" required />
    </label>
    <label class="input input-bordered flex items-center gap-2">
      Age
      <input class="flex-grow" type="number" v-model="form.age" min="3" required />
    </label>
    <button type="submit" class="btn btn-primary">
      {{ modelValue ? 'Edit' : 'Create' }} User
    </button>
  </form>
</template>

<style scoped>
.input {
  display: flex;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.input input {
  border: none;
  outline: none;
  background-color: transparent;
}
.btn {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.btn-primary {
  background-color: #007bff;
  color: white;
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.gap-3 { gap: 0.75rem; } /* 12px if base is 16px */
.items-center { align-items: center; }
.gap-2 { gap: 0.5rem; } /* 8px */
.flex-grow { flex-grow: 1; }
</style>

Пример использования в родительском компоненте:

<script setup lang="ts">
import { ref } from 'vue'
import UserForm from './UserForm.vue'
import type { Ref } from 'vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

const user: Ref<UserFormData | null> = ref(null)

// const user: Ref<UserFormData | null> = ref({ name: "John Doe", email: "john@example.com", age: 30 });

</script>

<template>
  <div>
    <h1>User Management</h1>
    <UserForm v-model="user" />

    <div class="mt-4">
      <h2>Current User Data (Parent Scope):</h2>
      <pre>{{ JSON.stringify(user, null, 2) }}</pre>
    </div>
  </div>
</template>

<style scoped>
.mt-4 { margin-top: 1rem; }
pre {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  white-space: pre-wrap;
  word-wrap: break-word;
}
</style>

И заметьте, тестировать этот компонент будет очень просто.

Предположим, у нас настроено окружение для тестирования с Vitest. Мы создадим файл UserForm.spec.ts рядом с нашим компонентом.

// UserForm.spec.ts

import { describe, it, expect, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import UserForm from './UserForm.vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

type UserFormWrapper = VueWrapper<InstanceType<typeof UserForm>>

describe('UserForm.vue', () => {
  let wrapper: UserFormWrapper;

  describe('Create Mode (modelValue is null)', () => {
    beforeEach(() => {
      wrapper = mount(UserForm, {
        props: {
          'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
        }
      }) as UserFormWrapper;
    });

    it('initialise form with empty values', () => {
      expect(wrapper.vm.form).toEqual({
        name: "",
        email: "",
        age: null
      });
      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe('');
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe('');
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe('');
    });

    it('updates local state after form updates', async () => {
      const nameInput = wrapper.find('input[type="text"]');
      const emailInput = wrapper.find('input[type="email"]');
      const ageInput = wrapper.find('input[type="number"]');

      await nameInput.setValue('Test User');
      await emailInput.setValue('test@example.com');
      await ageInput.setValue(25);

      expect(wrapper.vm.form).toEqual({
        name: "Test User",
        email: "test@example.com",
        age: 25
      });
    });

    it('Do not emits update:modelValue after user input', async () => {
      await wrapper.find('input[type="text"]').setValue('Testing');
      expect(wrapper.emitted('update:modelValue')).toBeUndefined();
    });

    it('emits update:modelValue with form data after (submit)', async () => {
      await wrapper.find('input[type="text"]').setValue('New User');
      await wrapper.find('input[type="email"]').setValue('new@example.com');
      await wrapper.find('input[type="number"]').setValue(30);

      await wrapper.find('form').trigger('submit.prevent');

      expect(wrapper.emitted('update:modelValue')).toBeTruthy();
      expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
      expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{
        name: "New User",
        email: "new@example.com",
        age: 30
      }]);
    });

     it('Shows button "Create User"', () => {
       expect(wrapper.find('button[type="submit"]').text()).toContain('Create User');
     });
  });

  describe('Edit Mode (modelValue is provided)', () => {
    const initialData: UserFormData = {
      name: "Initial Name",
      email: "initial@example.com",
      age: 42
    };

    beforeEach(() => {
      wrapper = mount(UserForm, {
        props: {
          modelValue: initialData,
          'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
        }
      }) as UserFormWrapper;
    });

    it('initialise form with data from modelValue', () => {
      expect(wrapper.vm.form).toEqual(initialData);
      expect(wrapper.vm.form).not.toBe(initialData);

      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe(initialData.name);
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe(initialData.email);
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe(initialData.age?.toString());
    });

    it('updates local state after change values', async () => {
      await wrapper.find('input[type="text"]').setValue('Updated Name');
      expect(wrapper.vm.form.name).toBe('Updated Name');
      expect(wrapper.props('modelValue')).toEqual(initialData);
    });

     it('emits update:modelValue with updated values after sends data', async () => {
       const updatedName = 'Updated Name';
       await wrapper.find('input[type="text"]').setValue(updatedName);

       await wrapper.find('form').trigger('submit.prevent');

       expect(wrapper.emitted('update:modelValue')).toBeTruthy();
       expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{
         ...initialData,
         name: updatedName
       }]);
     });

    it('updates local form, if modelValue changes from parent', async () => {
      const newData: UserFormData = {
        name: "External Update",
        email: "external@example.com",
        age: 99
      };

      await wrapper.setProps({ modelValue: newData });

      expect(wrapper.vm.form).toEqual(newData);
      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe(newData.name);
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe(newData.email);
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe(newData.age?.toString());
    });

    it('displays button "Edit User"', () => {
       expect(wrapper.find('button[type="submit"]').text()).toContain('Edit User');
     });
  });
});

Преимущества этого подхода

  • Контролируемые обновления: Родительский компонент получает данные только тогда, когда форма прошла валидацию и готова к отправке. Это предотвращает обработку невалидных или промежуточных состояний.
  • Инкапсуляция логики: Вся логика, связанная с состоянием формы, включая валидацию и возможные трансформации данных, сосредоточена внутри компонента формы. Родительский компонент остается чистым.
  • Простота и фокус: Компонент формы занимается только своим прямым делом – отображением полей и управлением своим внутренним состоянием. Он не взаимодействует с API напрямую, что делает его более переиспользуемым. Отправка данных на сервер — задача родительского компонента или сервисного слоя.
  • Улучшенная тестируемость: Такой компонент легко тестировать изолированно (unit-тесты). Можно проверить логику валидации и правильность обновления v-model без необходимости мокать API-запросы или сложное окружение родительского компонента.

Недостатки и соображения

  • Дополнительный код (Boilerplate): Необходимо создавать локальную копию (ref), использовать watch для синхронизации с modelValue и функцию-обработчик для обновления v-model. Это немного больше кода, чем при прямом использовании v-model.
  • Клонирование: Используемый метод JSON.parse(JSON.stringify(obj)) прост, но может быть неэффективным для очень больших и сложных объектов. Также он имеет ограничения (не копирует функции, Date преобразует в строки, undefined заменяет на null в массивах или пропускает в объектах). В современных браузерах и Node.js можно использовать structuredClone(), который лучше справляется с различными типами данных. Для поддержки старых сред или большей гибкости можно использовать библиотеки вроде lodash/cloneDeep.
  • Синхронизация состояний: Нужно внимательно следить за синхронизацией между modelValue и локальной копией form, особенно при инициализации и внешних обновлениях. watch с { deep: true } решает эту задачу для большинства случаев.

Заключение

Паттерн "Отложенный v-model" — это отличный инструмент для создания надежных и хорошо инкапсулированных компонентов форм во Vue 3, особенно когда требуется валидация или предварительная обработка данных перед их передачей родительскому компоненту. Хотя он добавляет немного больше кода по сравнению с прямым v-model, выгоды в виде контроля, инкапсуляции и тестируемости часто перевешивают этот небольшой недостаток. Я настоятельно рекомендую попробовать его в ситуациях, где стандартное поведение v-model не совсем подходит.

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

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

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js, Wordpress. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235
Email