Наверно все работали с формами и понимают как это сложно. В свое время я смотрел разные решения и одним из лучших был Vuetify. Сейчас решений стало больше, но все они однотипны (я не буду брать во внимание форм генераторы, тк они должны быть на чем то основаны). В чем то это связано с ограничением самого Vue и его философией. Но для меня все таки странно, что время идет, а прогресса нет. Странно что люди вокруг пытаются меня убедить что это нормально.

Небольшая оговорка. Я не фанатик Vuetify и стараюсь не использовать его совсем, так как в моих проектах он вызывает больше проблем в будущем, чем их решает в начале. Но мне приходиться работать в проектах где он есть и от него никуда не деться (его невозможно , просто взять и выпилить).

Обертки

Начнем с того, что мало кто создает обертки. Действительно зачем когда это и так выглядит нормально.

<v-autocomplete
  auto-select-first
  chips
  clearable
  deletable-chips
  dense
  filled
  multiple
  rounded
  small-chips
  solo
  solo-inverted
></v-autocomplete>

Я конечно утрирую, но обычно 4-5 свойства присутствует всегда и это копипаститься по всем элементам формы. Создайте 5-6 оберток в начале и дополняйте их по мере необходимости (дальше их можно просто копипастить из проекта в проект). Если вам не хочется этого делать - то переопределите стили (они все равно глобальные).

На такое замечание мне обычно отвечают, что в этом нет ничего страшного и это не как не мешает пониманию/написанию кода и если что мы можем просто сделать глобальный реплейс или поправить через стили. Ну ок ....

Создание своих элементов формы

При рассмотрении библиотек, я смотрю на то, как можно сделать свой компонент, который будет интегрирован с Vuetify или другой системой. Увы в документации Vuetify я не видел примеров как создать свое кастомное поле (иногда это бывает очень полезным), но есть другие источники, которые об этом рассказывают.

В рамках Vuetify я не создавал кастомные поля (только помогал), но делал это в рамках другого решения. Для многих достаточно записать данные в input и сделать обертку, но если сложность задачи усложняется (надо общаться объектами), то обертки может быть не достаточно.

Для многих это не критично, но стоит понимать, что такая ситуация может возникнуть.

Выносите формы в отдельный компонент

В основном все пишут в один файл. Рассмотрим на примере страницы с переключателем "хотите заполнить форму Ф1 или Ф2". Вся эта логика будет написана в один файл (логика переключения, логика формы Ф1 и логика формы Ф2). Файл начинает распухать, а логика становиться совсем не прозрачной. Вынеся формы в отдельные файлы, вы пишете чуть больше кода, но код становиться гораздо прозрачней. В дальнейшем некоторый функционал уйдет в общий mixin, который вы будете подключать ко всем своим формам.

В итоге ваш компонент будет работать с данными, а вы писать простой код. И это стоит делать даже если у вас 1 простая форма (это дело привычки). Логике должно быть все равно, как были получены данные и корректно ли они прошли валидацию, какие там были анимации и интерфейсы.

Одна форма для редактирования и добавления.

Начнем с того что форма, должна иметь возможность получить данные, а при их отсутствие использовать свои дефолтные (бывают случаи когда делают 2 практически одинаковые формы, только одна имеет дефолтные значения, а вторая обязательно должна принять эти значения). Если формы добавления и редактирования не отличаются или отличаются не значительно (добавлены/скрыты какие то поля), то не стоит создавать преждевременные клоны. С другой стороны, если вы не понимаете что будет дальше или поддержка универсальной формы начинает превращаться в сплошные if, то стоит разделить.

Да да это очень очевидные вещи! Но многие про это забывают. Хотя разделение/склеивание форм занимает не больше получаса времени.

Отсутствие Submit

В Vuetify отсутствует метод Submit (есть reset/validate). Этот метод нужен для отдачи чистых/сконвертированных данных. Обычно все это делается перед отправкой самих данных, но все таки за это стоило бы отвечать форме. Напомню про mixin для формы, часть логики можно поместить туда

Бесполезное DTO

Старайтесь избежать лишней конвертации данных. Это значит что если вам бек присылает full_name и ожидает full_name, то не надо в форме использовать поле fullName (так как вы к этому привыкли). Это становиться очень накладно, когда у вас более 10 полей. Для 10 полей - вам придется написать 2 функции по 10 строк, отступы между функциями, название функций, вызовы этих функций, обработку ошибок (ошибка в поле full_name => ошибка в поле fullName). Итого порядка +50 строк. Даже если вам не нравиться 2-3 названия, вам все равно придется пройти все шаги (будет ток чуть меньше кода).

Если у вас кривой бек или другие небесные силы, которые используют где то full_name, а где то fullName, тогда сочувствую (да да, бывает и такое). Постарайтесь делать конвертацию в 1 сторону (если это возможно) и наверно стоит ориентироваться на отправляемые данные, те при получении мы конвертируем в структуру которую надо будет отправить.

Формы для разных сущностей не должны пересекаться

Обычно в начале, все формы однотипны, а может даже похожи и хочется использовать одну и туже форму. К примеру есть форма новости и форма акции (и в самом начале они одинаковы). Но это обманчивое ощущение. Лучше сразу сделать 2 разные формы и не вздрагивать когда в новость добавиться 1 поле, потом другое...

Копипаста? да именно так. Не забывайте, что формы в начале очень простые и у вас не будет миллион копий (максимум 5, а обычно это 2-3 копии).

Что взять

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

Рассмотрим простую задачу создания и редактирования списка клиентов. Соответственно будут файлы "PageClientAdd.vue", "PageClientEdit.vue" и "ClientEditForm.vue".

PageClientAdd.vue
<template>
  <div class="container">
    <h1 class="page-title">Добавить пользователя</h1>
    <ClientEditForm ref="clientEditForm" />
    <button @click="save">Сохранить</button>
  </div>
</template>
import ClientEditForm from "./ClientEditForm";

export default {
  name: "PageClientAdd",
  components: {
    ClientEditForm,
  },
  methods: {
    async save() {
      let formData = this.$refs.clientEditForm.formSubmitGetData();
      if(!formData) { return; }
      // Тут логика (в оригинале ее чуть больше)
      RequestManager.Client.addClient(formData).then( (res) => {
        this.$router.push({name: this.$routeName.CLIENT_LIST });
      });
    }
  }
};
PageClientEdit.vue
<template>
  <!-- тут упростим -->
  <div class="container" v-if="client">
    <h1 class="page-title">Редактировать пользователя</h1>
    <ClientEditForm 
       ref="clientEditForm"
       :formData="client"
    />
    <button @click="save">Сохранить</button>
  </div>
</template>
import ClientEditForm from "./ClientEditForm";

export default {
  name: "PageClientEdit",
  props: { clientId: String },
  data() {
    return {
      client: false
    }
  },
  components: {
    ClientEditForm,
  },
  methods: {
    async save() {
      let formData = this.$refs.clientEditForm.formSubmitGetData();
      if(!formData) { return; }
      
      RequestManager.Client.updateClientById({
        id     : this.clientId,
        client : formData
      }).then( (res) => {
        this.$router.push({name: this.$routeName.CLIENT_LIST });
      });
    }
  },
  mounted() {
    RequestManager.Client.getClientById({
      id: this.clientId
    }).then((client) => {
      this.client = client
    });
};
ClientEditForm.vue
<template>
  <form>
    <fieldset>
      <legend>Данные для входа</legend>
      <FvePhone
        label="Номер телефона"
        name="mobile"
        required
        v-model="form.mobile"
      />
      <FveEmail
        label="E-mail"
        name="email"
        v-model="form.email"
      />
    </fieldset>

    <fieldset>
      <legend>Личные данные</legend>
      <FveFileImageCropperPreview
        label=""
        name="avatar"
        v-model="form.avatar"
      />
      <FveText
        label="ФИО"
        name="name"
        required
        v-model="form.fio"
      />
      <FveDatePicker
        label="Дата рождения"
        name="birthday"
        v-model="form.birthday"
      />
      <FveTextarea
        label="О себе"
        name="about"
        v-model="form.about"
      />
    </fieldset>
  </form>
</template>
// import полей будет опущен (будем считать что они глобальные)
import FveFormMixin from "FveFormMixin";

export default {
  mixins: [
    FveFormMixin
  ],
  // components: { FveText, FveEmail, FvePhone, ... },
  methods: {
    formSchema() {
      return {
        mobile       : { type: String, default: () => { return ''; } },
        email        : { type: String, default: () => { return ''; } },
        // это кастомное поле которое общается классом FileClass
        avatar       : { type: FileClass, default: () => { return null; } },
        fio          : { type: String, default: () => { return ''; } },
        birthday     : { type: String, default: () => { return ''; } },
        about        : { type: String, default: () => { return ''; } },
      };
    }
  },
};

Это мой не идеальный идеал. Возможно кто то не поверит, что это вообще работает. Кто то скажет что FveFormMixin - заточен под эту форму. От себя скажу, что у меня так работают любые формы и да там происходит отправка изображения на сервер. Изначально было FveFileImagePreview, но потом его заменили на FveFileImageCropperPreview (добавили кроп изображения), те компоненты формы могут быть взаимозаменяемыми.

Возможно кто то добавит еще пару дельных советов, я же постарался перечислить основные ошибки и дать рекомендации как такого не допустить.

Комментарии (16)


  1. Aketca
    25.07.2021 21:22
    +1

    Ну вот я вижу, в примере формы используются компоненты fvephone, fvetext итд. Почему не сделать фабричный инпут и передавать пропс типа поля? Всё равно на компоненте 3-4 пропса уже висит, но не будет такого умножения одинаковых компонентов. Глобальные изменения так же проще сделать в одной фабрике, чем в череде компонентов.

    Vuetify и аналоги не вижу смысла использовать если это не какой-то ранний прототип, проблем порождается больше, чем преимуществ


    1. oploshka Автор
      25.07.2021 22:39

      Давайте наверно начнем с того что fvephone это обертка для vue-phone-mask-input. По этому не очень корректно их сравнивать. Корректнее наверно будет ответить на FveText и к примеру FveUrl (FveText + детали по валидации). Собственно приведу как выглядит реализация FveUrl:

      import FveText from "./FveText";
      export default {
        mixins: [ FveText ],
        methods: {
          validateFunction(str) {
            // насчет корректности регулярки не уверен.
            const urlRegex = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/;
            if( !urlRegex.test(str) ){ return 'Не верный формат ссылки'; }
            return 'SUCCESS'; // это не круто, но как есть 
          },
        }
      };

      поля email, password, time, login, number - строятся примерно таким же образом. Тут больше вопрос как это реализовано под капотом. И теперь вы можете спокойно использовать FveUrl где угодно без боязни что надо будет поменять валидацию. Отдельный момент это понимание какой тип ожидает v-model, дебаг + мне удобнее так).

      В целом можно указывать дополнительно тип, подтип, передавать настройки, указывать дополнительную валидацию, но на практике мне это не пригождалось.


      1. oploshka Автор
        25.07.2021 22:55

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


  1. MikUrrey
    25.07.2021 22:58

    Я конечно утрирую, но обычно 4-5 свойства присутствует всегда и это копипаститься по всем элементам формы.

    Мало того, в дизайн-концепции Vuetify свойства должны присутствовать от поля к полю, чтобы сохранять стройность и единообразие формы, и зачастую копипаста из 4-5 пропсов расползается по всему проекту.

    Когда пришлось собирать более-мене серьёзную форму, ужаснулся от этого и сделал генератор форм "налету". Не являюсь экспертом в Vue и не уверен, что это производительное решение, но оно работает и помогает создавать формы быстрее.

    Выносите формы в отдельный компонент

    А так же в обязательном порядке диалоги (v-dialog) и карточки (v-card), если они содержат что-то длиннее трех строк.

    А еще рекомендую вспоминать, что компоненты можно создавать локально в виде объектов со свойством template или функцией render, и это бывает удобно для маленьких элементов, применяемых многоразово, но только в рамках одного компонента.


    1. oploshka Автор
      25.07.2021 23:06

       и зачастую копипаста из 4-5 пропсов расползается по всему проекту.

      Да да, я про эту боль). По поводу форм генераторов у меня двоякое мнение (разработка с ними гораздо быстрее), но как только надо сделать что то за рамками - наступает боль (обычно такое бывает редко, но это очень не приятно).


      1. MikUrrey
        25.07.2021 23:14
        +1

        И с этим успел столкнуться тоже) Просто сделал так, чтобы генератор принимал в слот кастомную разметку. Слоты Vue - это вообще потрясающее средство от головной боли))


  1. flancer
    26.07.2021 09:51

    Мне видится некоторое противоречие в пунктах "Бесполезное DTO" и "Формы для разных сущностей не должны пересекаться". В первом пункте возникает зацепление между фронтом и бэком как раз таки по имени атрибута (full_name). IMHO, в общем случае это всё-таки разные сущности (DTO фронта и DTO бэка). Первое - для ввода-вывода информации, второе - для хранения (или транспортировки). Во втором пункте сказано: "Обычно в начале, все формы однотипны, а может даже похожи и хочется использовать одну и туже форму." Я бы подобную логику применял бы и к DTO: "Обычно в начале все DTO однотипны, а может даже похожи...".


    1. oploshka Автор
      26.07.2021 17:24
      +1

      Чтоб всем было понятно по поводу DTO.
      Бек отдал { first_name : "" , last_name : "" } , фронт это получил и говорит мне так не удобно и конвертирует в { fName : "" , lName : "" } (имеется в виду такой кейс). Я не против преобразования данных полей (было {create_at: "string"} стало {create_at: Date} ) - так стоит делать, но переименовывать по причине того, что так привычнее - это плохо. Рано или поздно вы пойдете к беку и спросите, а что там с полем fName (а бек это делал месяц назад и f_name у него ассоциируется совсем с другим).

      Дальше вы хотите отдать { fName : "" , lName : "" } на сервер и преобразуете в { first_name : "" , last_name : "" }. Бек говорит ошибка в поле last_name . Мы идем и конвертируем поле с ошибкой last_name => lName.

      Если есть причины, по которым вы готовы это делать - то ок.

      Возможно не корректен заголовок про DTO и стоит назвать по мягче - минимизация конвертаций...


      1. StonedCatt
        23.08.2021 08:50

        Часто бывает так, что на фронте принят camelCase, на бэке snake_case. В частности, в таких случаях используют преобразователи названий свойств. Не составит труда в таком кейсе юзать какие нибудь готовые преобразователи из lodash. Перед отправкой данных из запроса в компонент фронта и обратно. Кейс с удобным названием fName вместо firstName, который вместо first_name действительно лучше избегать вовсе ​


  1. DEamON_M
    26.07.2021 11:32

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


    1. DEamON_M
      26.07.2021 13:59

      Конкретно тут об этом написано


    1. oploshka Автор
      26.07.2021 17:57

      Я не совсем понял, где вы это увидели, но могу предположить что это адресовано к этой строке

      <ClientEditForm
       :formData="client"
      />

      Отдается formData. работа внутри формы происходит с form. Изменений props нет, ошибок нет. Стоит заметить что в PageClientAdd.vue - formData вообще не передается.


  1. eshimischi
    03.08.2021 15:01

    Для ознакомления formvuelate


    1. oploshka Автор
      03.08.2021 17:35

      Спасибо, выглядит не плохо, возможно кому то пригодиться. По общавшись с народом, я понял что мне не нужен форм генератор (в 99%). Лишь только в одном моем проекте есть реальная нужда в нем. Он самописный и заточен под проект, а количество полей на 1 форму 50+ (и эти поля взаимодействуют между собой, используют данные других полей для ajax, используют фильтрацию и тд). Готовым решением я б не обошелся... Но каждый решает сам, на сколько достаточно того или иного решения.


  1. webben
    03.08.2021 17:35

    async save() {
        let formData = this.$refs.clientEditForm.formSubmitGetData();
    }

    вызов формы по рефу выглядит костыльно, достаточно перенести кнопку внутрь формы и слушать @submit

    зачем в форме миксин - не понятно, идея "давайте напишем читаемый код" к концу статьи опять вернулась к "давайте добавим еще больше магии", композиция компонентов бы решила проблему намного лучше

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


    1. oploshka Автор
      03.08.2021 18:20

      Я не настаиваю использовать $refs (мне так удобно), можно и перенести, ток что делать если вам надо 2 кнопки к примеру или кнопка должна быть за пределами формы.

      Холевар про миксины.... давайте не будем использовать ничего и напишем тонну кода) Вы берете тот же форм генератор или vuetify или другое решение и вообще не смотрите что там под капотом - знаете что это работает, примерно как и этого достаточно.

      В целом есть жизненный пример "чистой архитектуры" - когда 1 простой метод api пишется часов 8 (без тестов). Нет магии, везде интерфейсы, все по феншую (Только есть одно но, метод нужен для теста и завтра может поменяться). И рядом с магией, но за пол часа. Тут каждый сам выбирает грань магии.

      композиция компонентов бы решила проблему намного лучше

      Я не против увидеть пример лучшего кода (если это не вкусовщина)...

      По последнему пункту, уже давал пояснения в коментах выше "Чтоб всем было понятно по поводу DTO. ..."