Веб-формы на Vue

статья

Реализация веб-формы на Vue. Валидация пользовательского ввода. Отладка реактивного состояния.

Содержание

В этой заметке я описываю пример решения типовой задачи по реализации веб-формы на Vue. Есть несчетное количество способов решить эту задачу, и я привожу один из множества. Цель моей заметки - обратить внимание на детали, которые могут быть неочевидны начинающим пользователям Vue. Опытным пользователям эти детали также могут быть полезны.

Простая форма

Начнем с самого простого варианта. Несколько полей для ввода данных. Валидация отсутствует. При «отправке» формы данные выводятся в консоль браузера.

Приведу полный пример кода компонента нашей веб-формы. Живой пример ее работы можно увидеть здесь.

<template>
  <div>
    <h2>Простая веб-форма</h2>
    <form @submit.prevent="onSubmit">
      <label> {{ presentation.title.label }}: <input type="text" v-model="data.title" /> </label>
      <label>
        {{ presentation.section.label }}:
        <select v-model="data.section">
          <option v-for="opt of presentation.section.options" :key="opt.value" :value="opt.value">
            {{ opt.label }}
          </option>
        </select>
      </label>
      <fieldset>
        <legend>{{ presentation.tags.label }}:</legend>
        <label v-for="opt of presentation.tags.options" :key="opt.value">
          {{ opt.label }}: <input type="checkbox" v-model="data.tags" :value="opt.value" />
        </label>
      </fieldset>

      <label
        >{{ presentation.description.label }}:
        <textarea cols="30" rows="10" v-model="data.description"></textarea>
      </label>

      <button>Отправить</button>
    </form>
  </div>
</template>

<script setup lang="ts">
import { reactive } from 'vue'

const data = reactive({
  title: '',
  section: '',
  tags: [],
  description: '',
})

type PresentationField = { label: string; options?: { label: string; value: string }[] }
const presentation: Record<keyof typeof data, PresentationField> = {
  title: {
    label: 'Заголовок',
  },
  section: {
    label: 'Раздел',
    options: [
      { label: '', value: '' },
      { label: 'Учебная', value: 'educational' },
      { label: 'Художественная', value: 'fiction' },
      { label: 'Научно-популярная', value: 'popular-science' },
    ],
  },
  tags: {
    label: 'Теги',
    options: [
      { label: 'Закрученный сюжет', value: 'twisting' },
      { label: 'Эпическое противостояние', value: 'epic' },
      { label: 'Простое изложение', value: 'easy' },
    ],
  },
  description: {
    label: 'Описание',
  },
}

const onSubmit = () => {
  const dataObject = JSON.stringify(data, null, 2)
  console.log(dataObject)
}
</script>

<style scoped>
form {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
</style>

Заметим, что значения полей ввода хранятся в отдельном реактивном объекте data.

const data = reactive({
  title: '',
  section: '',
  tags: [],
  description: '',
})

А лейблы и опции хранятся в другом объекте presentation.

const presentation: Record<keyof typeof data, PresentationField> = {
  title: {
    label: 'Заголовок',
  },
  section: {
    label: 'Раздел',
      options: [
        { label: '', value: '' },
        { label: 'Учебная', value: 'educational' },
        { label: 'Художественная', value: 'fiction' },
        { label: 'Научно-популярная', value: 'popular-science' },
      ],
    },
  },
  // и так далее
};

Это решение не случайно. Здесь мы отделили данные, которые склонны меняться (под воздействием пользовательского ввода), от данных, которые меняться не будут никогда. В результате, вместо одного большого объекта, мы получили два маленьких объекта, каждый из которых имеет определенную ограниченную ответственность.

Обратите также внимание на то, что объект data содержит только строки и списки строк. Этот объект остается в неведении о конкретной реализации полей ввода данных в html. А Vue предоставляет нам возможность удобно подключать поля объекта данных к html элементам, сглаживая различия между разными html элементами.

<!-- Оба эти поля хранятся в одинаковом виде в объекте данных, не смотря на различия в html -->
<input type="text" v-model="data.title" />
<select v-model="data.section">
  <option v-for="opt of presentation.section.options" :key="opt.value" :value="opt.value">
    {{ opt.label }}
  </option>
</select>
<!-- Посмотрите, как подключены и другие поля в полном примере -->

Я не стал включать в пример input type="file", так как работа с ним существенно отличается от работы с большинством полей ввода, которые можно реализовать на html.

Добавляем валидацию

Продолжим работать с формой из предыдущего примера. Единственным отличием будет наличие валидации пользовательского ввода. Реализация валидации - не такая простая задача, как может показаться на первый взгляд. Существует множество особых случаев, про которые легко забыть. Поэтому для этой задачи мы будем использовать библиотеку Regle.

Приведу полный пример кода компонента нашей веб-формы с Regle. Живой пример ее работы можно увидеть здесь.

<template>
  <div>
    <h2>Веб-форма с валидацией ввода</h2>
    <form @submit.prevent="onSubmit">
      <label :class="{ error: r$.title.$error }">
        {{ presentation.title.label }}: <input type="text" v-model="data.title" />
      </label>
      <div v-for="err of r$.$errors.title" :key="err" class="error">{{ err }}</div>

      <label :class="{ error: r$.section.$error }">
        {{ presentation.section.label }}:
        <select v-model="data.section">
          <option v-for="opt of presentation.section.options" :key="opt.value" :value="opt.value">
            {{ opt.label }}
          </option>
        </select>
      </label>
      <div v-for="err of r$.$errors.section" :key="err" class="error">{{ err }}</div>

      <fieldset>
        <legend>{{ presentation.tags.label }}:</legend>
        <label v-for="opt of presentation.tags.options" :key="opt.value">
          {{ opt.label }}: <input type="checkbox" v-model="data.tags" :value="opt.value" />
        </label>
      </fieldset>

      <label
        >{{ presentation.description.label }}:
        <textarea cols="30" rows="10" v-model="data.description"></textarea>
      </label>

      <button>Отправить</button>
    </form>
  </div>
</template>

<script setup lang="ts">
import { reactive } from 'vue'
import { useRegle } from '@/composables/useRegl.ts'
import { required } from '@regle/rules'

const data = reactive({
  title: '',
  section: '',
  tags: [],
  description: '',
})

type PresentationField = { label: string; options?: { label: string; value: string }[] }
const presentation: Record<keyof typeof data, PresentationField> = {
  title: {
    label: 'Заголовок',
  },
  section: {
    label: 'Раздел',
    options: [
      { label: '', value: '' },
      { label: 'Учебная', value: 'educational' },
      { label: 'Художественная', value: 'fiction' },
      { label: 'Научно-популярная', value: 'popular-science' },
    ],
  },
  tags: {
    label: 'Теги',
    options: [
      { label: 'Закрученный сюжет', value: 'twisting' },
      { label: 'Эпическое противостояние', value: 'epic' },
      { label: 'Простое изложение', value: 'easy' },
    ],
  },
  description: {
    label: 'Описание',
  },
}

const { r$ } = useRegle(data, {
  title: { required },
  section: { required },
})

const onSubmit = async () => {
  const { valid, data: dataObject } = await r$.$validate()
  if (!valid) {
    return
  }

  console.log(dataObject)
}
</script>

<style scoped>
form {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.error {
  color: red;
}
</style>

Эта форма повторяет предыдущий пример. Обратите внимание, как просто добавляется валидация. Объекты data и presentation не изменились, но добавились несколько строк кода:

const { r$ } = useRegle(data, {
  title: { required },
  section: { required },
})

После чего в шаблоне можно использовать объект r$ для отображения специальных стилей для полей с ошибками и для отображения самих ошибок.

<label :class="{ error: r$.title.$error }">
    {{ presentation.title.label }}: <input type="text" v-model="data.title" />
</label>
<div v-for="err of r$.$errors.title" :key="err" class="error">{{ err }}</div>

У Regle обширный api поэтому я не буду углубляться в эту тему, а просто предлагаю вам почитать документацию этой библиотеки.

Наблюдаемость данных формы

Закончив реализацию формы мы можем столкнуться с тем, что данные, отправляемые на сервер не соответствуют нашим ожиданиям. Поэтому далее я опишу схему решения этой проблемы.

Первое, что мы можем проверить - это реактивный объект data, потому что данные хранятся в нем и берутся из него перед отправкой на сервер. Для этого воспользуемся инструментами разработчика Vue. Ими можно пользоваться как в виде расширения для браузера, так и в виде специального виджета, который включен в dev режиме по умолчанию в последних версиях Vue.

Виджет инструментов разработчика Vue

Откроем вкладку Components инструментов разработчика Vue. Посмотрим на текущие значения в объекте data. Видим, что здесь значение поля title уже неправильное.

Неправильное значение

Это значит, что кто-то мутировал поле title в объекте data. Осталось найти того, кто это делает. Для этого воспользуемся watch с параметром onTrigger. Для нашего удобства создадим вот такой хелпер, который мы будем использовать всегда, когда надо найти виновника испорченного состояния реактивного объекта.

import { type Reactive, watch } from 'vue'

export const useDebug = <T>(source: Reactive<T>) => {
  watch(source, () => {}, {
    onTrigger: (event) => {
      console.table({
        type: event.type,
        key: event.key,
        oldValue: event.oldValue,
        newValue: event.newValue,
        target: JSON.stringify(event.target),
      })
      console.trace()
    },
  })
}

Используем этот хелпер следующим образом, сразу после объявления реактивного объекта, за которым мы хотим следить.

const data = reactive({
  title: '',
  section: '',
  tags: [],
  description: '',
})
+ useDebug(data)

Заполняем форму и смотрим в консоль разработчика.

Вывод в консоли разработчика

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

Виновник

Виновник найден. Вот код, который мы добавили, чтобы «неожиданно» испортить объект data.

+ const makeUnexpectedUpdate = () => {
+   data.title = 'Неожиданное значение'
+ }

const onSubmit = async () => {
  const { valid, data: dataObject } = await r$.$validate()
  if (!valid) {
    return
  }

  console.log(dataObject)
+   setTimeout(makeUnexpectedUpdate, 3000)
}

Таким образом можно отыскивать все случаи записи в реактивный объект.

Выводы

Мы увидели, что Vue берет большую часть работы по управлению состоянием формы на себя с помощью директивы v-model, которая умеет работать с большинством видов полей ввода, и реактивного объекта, созданного с помощью reactive. Мы также научились добавлять валидацию и отлаживать некорректное поведение формы с помощью инструментов разработчика Vue, watch и onTrigger.