Меня зовут Виталий Павленко, я фулстек-разработчик в Профи. Расскажу, как мы построили универсальный сервис по созданию форм в продукте.

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

Допустим, мы можем сделать простые компоненты вроде инпутов или составные, типа окна с формой. Но всё равно бы пришлось постоянно собирать такие композиции во всех местах, не говоря уже о разном бэкенде для всех форм.

На самом деле есть другое решение. И об этом моя статья.

Что хотим получить

Наша главная цель — ускорить внедрение форм в продуктовых командах и сократить количество написанного кода (и на фронтенде, и на бэкенде). 

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

Фронтенд. На фронтенде храним внешний вид элементов формы, т.е. дёргаем type элемента и рисуем как надо, бэкенд при этом ничего не знает о внешнем виде. Приложение просто получает список элементов, рисует, а потом отправляет введённые значения пользователей обратно на бэк.

Бэкенд. На бэкенде формируем интерфейсы шагов и элементов для фронтенда. Бэкенд записывает введённое пользователем значение в базу данных. Если пользователь уже заполнял поле ранее, то бэкенд берёт значение из базы данных и отдаёт предзаполненное поле на фронт.

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

Выше на схеме идеальная картинка, к которой мы стремимся. Сюда ещё можно добавить админку для составления таких форм. Как обычно, ответственность может переползать на соседние зоны: вместо базы данных формы могут собираться сразу на бэкенде, а с бэкенда отдавать некоторые стили в параметрах элементов на фронт.

Как устроены наши формы

Для примера возьмём форму регистрации психологов и попробуем выделить модули. В реальности форма немного сложнее, но нам этого хватит для понимания подхода.

Какие элементы мы видим: 

  • RadioGroup (блок с радиокнопками), 

  • Text (простой текст «Чтобы откликаться…»), 

  • Row (обёртка, где несколько элементов подряд), 

  • Input (поле ввода).

Эти элементы условно можно разделить на несколько категорий по предназначению:

Категория

Элементы

Назначение

Статические элементы

Text

Отображают неизменяемую информацию, обычно дополняют изменяемые элементы

Элементы разметки

Row

Формируют разметку, добавляют отступы

Изменяемые элементы

Input, RadioGroup

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

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

Ещё в форме есть заголовок и кнопки управления («Назад», «Вперёд», «Отправить»). Но эти вещи нам не нужны для составления композиции, так как они плюс-минус статичные. Но мы будем получать их с бэкенда, чтобы максимально освободить фронтенд от бизнес-логики.

Делаем красиво

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

Создаём универсальный компонент Element, который будет подхватывать нужный компонент, в зависимости от типа:

// Element.tsx

import {FC} from 'react';
import {Text, Input, Row, RadioGroup} from '../elements';
import {ElementVariant, ElementType} from '../types';

export type ElementProps = {
  element: ElementVariant;
};

export const Element: FC<ElementProps> = ({element}) => {
  switch (element.type) {
    case ElementType.input:
      return <Input {...element} />;
    case ElementType.text:
      return <Text {...element} />;
    case ElementType.radioGroup:
      return <RadioGroup {...element} />;
    case ElementType.row:
      return <Row {...element} />;
    default:
      return null;
  }
};

Добавляем типы. Скажем, что у каждого элемента будут обязательны id и type, а дополнительные поля опциональны:

// types.ts

export type ElementBase<Type, ExtraProps> = {
  type: Type;
  id: string;
} & ExtraProps;

export enum ElementType {
  text = 'Text',
  input = 'Input',
  radioGroup = 'RadioGroup',
  row = 'Row',
}

export type ElementText = ElementBase<
  ElementType.text,
  {
    text: string;
  }
>;

export type ElementInput = ElementBase<
  ElementType.input,
  {
    value?: string;
    placeholder: string;
  }
>;

export type ElementRadioGroup = ElementBase<
  ElementType.radioGroup,
  {
    activeId?: string;
    items: {
      id: string;
      value: string;
    }[];
  }
>;

export type ElementRow = ElementBase<
  ElementType.row,
  {
    element: ElementVariant;
  }
>;

export type ElementVariant =
  | ElementText
  | ElementInput
  | ElementRadioGroup
  | ElementRow;

Обратите внимание, что ElementRow содержит вложенный элемент, который будет вкладываться рекурсивно. Эта опция очень полезна для компонентов обёрток или более высокоуровневых компонентов, которые создают композиции особым образом.

Теперь нужно сделать кнопки переключения шагов.

// ProfiForm.tsx

// Навигация приходит с бэкенда примерно в таком виде:
// navigations: [
// {
//   type: 'secondary',
//   text: 'Назад',
//   stepId: 'specialization',
// },
// {
//   type: 'primary',
//   text: 'Продолжить',
//   stepId: 'experience',
// },
// ],


 data.navigations.map(button => {
   return (
     <Button
       dataType={button.type}
       onClick={() => getData(button.stepId)}
     >
       {button.text}
     </Button>
   );
 });

Подразумевается, что по клику на кнопку навигации мы просто перезапрашиваем данные для текущей страницы с новым id шага.

Управляем состоянием

На этом этапе главная задача — собрать всё состояние на верхнем уровне формы. Конечно, можно сделать это через обычный реактовский контекст, но быстрее и удобнее взять библиотеку react-use-form.

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

// ProfiForm.tsx

import React, {useEffect, useState} from 'react';
import {useForm, FormProvider} from 'react-hook-form';

export const ProfiForm = () => {
  const form = useForm();

  return (
    <FormProvider {...form}>
      {/* здесь все элементы, навигация и т.д. */}
    </FormProvider>
  );
};

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

// Input.tsx

import {FC} from 'react';
import {useFormContext} from 'react-hook-form';
import {ElementInput} from '../../types';

export const Input: FC<ElementInput> = ({placeholder, value, id}) => {
  const {register} = useFormContext();
  return <input placeholder={placeholder} {...register(id, {value})} />;
};

Состояние формы можно будет достать в любой момент. Например, если id нашего инпута будет выше university и написать в инпуте «Какой-то универ», то получим такое состояние:

// ProfiForm.tsx

import React, {useEffect, useState} from 'react';
import {useForm, FormProvider} from 'react-hook-form';

export const ProfiForm = () => {
  const form = useForm();

	console.log(form.getValues()); // { university: 'Какой-то универ', ... }

  return (
    <FormProvider {...form}>
      {/* здесь все элементы, навигация и т.д. */}
    </FormProvider>
  );
};

А дальше понятно, что с этим делать ????. К примеру, отправлять на сервер текущее состояние в нужные моменты. При этом вы сами решаете, когда отправлять изменения на бэкенд:

  • каждый раз при вводе символа;

  • при расфокусе инпута;

  • после переключения на следующий шаг;

  • или ещё какие-то варианты.

Мы решили сохранять изменения после переключения на следующий шаг.

Идём в бэкенд

Мы используем GraphQL на бэкенде, он хорошо подходит под такой тип задач, но сейчас не будем усложнять пример.

Одна из основных задач сервиса — формировать нужную структуру данных, отдавая такой ответ на фронт:

const response = {
  title: 'Какое у вас высшее образование?',
  elements: [
    {
      id: 'education-info-row',
      type: 'Row',
      element: {
        id: 'education-info',
        type: 'Text',
        text: 'Чтобы откликаться на заказы по психологии, нужно психологическое или медицинское высшее образование.',
      },
    },
    {
      id: 'university-row',
      type: 'Row',
      element: {
        id: 'university',
        type: 'Input',
        placeholder: 'Вуз',
      },
    },
    {
      id: 'speciality-row',
      type: 'Row',
      element: {
        id: 'speciality',
        type: 'Input',
        placeholder: 'Специальность',
      },
    },
  ],
  navigations: [
    {
      type: 'secondary',
      text: 'Назад',
      stepId: 'specialization',
    },
    {
      type: 'primary',
      text: 'Продолжить',
      stepId: 'experience',
    },
  ],
};

Можно по-разному сформировать такой объект. Например, сделать базовый класс и классы для каждого элемента. Посмотрим на примере того же инпута:

// server side

class BaseElement {
  constructor(id) {
    this.id = id;
  }

  setValue(value) {
    this.value = value;
    return this;
  }
}

class InputElement extends BaseElement {
  type = 'Input';

  setPlaceholder(placeholder) {
    this.placeholder = placeholder;
    return this;
  }
}

Тогда можно формировать предыдущий ответ так (предварительно получив контент из базы данных или из другого сервиса):

const response = {
  title: 'Какое у вас высшее образование?',
  elements: [
    new RowElement('education-info-row').setElement(
      new TextElement('education-info').setText(
        'Чтобы откликаться на заказы по психологии, нужно психологическое или медицинское высшее образование.',
      ),
    ),
    new RowElement('university-row').setElement(
      new InputElement('university').setPlaceholder('Вуз').setValue('МГУ'),
    ),
    new RowElement('speciality-row').setElement(
      new InputElement('speciality').setPlaceholder('Специальность'),
    ),
  ],
  navigations: [
    {
      type: 'secondary',
      text: 'Назад',
      stepId: 'specialization',
    },
    {
      type: 'primary',
      text: 'Продолжить',
      stepId: 'experience',
    },
  ],
}

Обратите внимание на setValue(): значение «МГУ» пойдёт как предзаполненное на фронтенд. Сюда можно подложить значение, предварительно получив его из базы данных (табличка state в примере).

Используем сервис на фронтенде

Теперь собираем всё вместе:

// ProfiForm.tsx

import React, {useEffect, useState} from 'react';
import {useForm, FormProvider} from 'react-hook-form';
import {Form, Title, Elements, Button, Footer} from './styled';
import {ElementVariant} from './types';
import {Element} from './Element';

type Button = {
  type: 'secondary' | 'primary';
  text: string;
  stepId: string;
};

type FormData = {
  title: string;
  elements: ElementVariant[];
  navigations?: Button[];
};

export const ProfiForm = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [data, setData] = useState<FormData | null>(null);
  const form = useForm();

  const getData = (stepId: string | null) => {
    setIsLoading(true);
    fetch('/api/form', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        stepId: stepId,
        values: form.getValues(),
      }),
    })
      .then(response => response.json())
      .then(data => setData(data))
      .finally(() => setIsLoading(false));
  };

  useEffect(() => {
    getData(null);
  }, []);

  if (isLoading) {
    return <div>Lading...</div>;
  }

  if (!data) {
    return <div>Нет данных</div>;
  }

  return (
    <FormProvider {...form}>
      <Form>
        <Title>{data.title}</Title>
        <Elements>
          {data.elements.map(element => (
            <Element key={element.id} element={element} />
          ))}
        </Elements>
        {data.navigations && (
          <Footer>
            {data.navigations.map((button, index) => {
              return (
                <Button
                  key={index}
                  dataType={button.type}
                  onClick={() => getData(button.stepId)}
                >
                  {button.text}
                </Button>
              );
            })}
          </Footer>
        )}
      </Form>
    </FormProvider>
  );
};

В итоге у нас получился универсальный компонент ProfiForm, который сам ходит в сервис и рисует любую форму по полученной структуре данных! Достаточно добавить новый параметр в эндпойнт (formType) и обрабатывать любые формы в любом месте приложения, просто вызвав компонент <ProfiForm type=”psychology” /> или <ProfiForm type=”registration” />. Главное, не забыть добавить этот пропс в эндпойнт сервиса.

Ниже демка нашего примера. Или вот проект на гитхабе, если хотите запустить демо локально.

Спасибо за внимание. Надеюсь, наш опыт будет полезен для вас!

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


  1. agalakhov
    08.08.2022 17:13

    Этот подход уже давно используется в конфигураторе ядра Linux — система Kconfig.


  1. 13luck
    08.08.2022 18:14

    Интересно посмотреть на живой пример/ну или gif, когда сами поля строятся динамично. Например для поля `ru_passport` есть поле `ru_passport_id` (с масками и валидацией, и т. д.), но при переключении на `foreign_passport` выводим уже `foreign_passport_id` (уже другая маска, валидация...) плюс `foreign_passport_expire-date`.


    1. yroman
      08.08.2022 19:19
      +3

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


    1. vital_pavlenko Автор
      08.08.2022 21:53

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


      1. yroman
        09.08.2022 10:11
        +1

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


        1. vital_pavlenko Автор
          09.08.2022 10:48

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


          1. yroman
            09.08.2022 11:48

            Спорить можно бесконечно. Как вы думаете, многочисленные ToDo лист реализации, коих полно в интернете, кого-нибудь научили писать сложные приложения?


            1. vital_pavlenko Автор
              09.08.2022 12:16

              Кого-нибудь научило конечно. Но у меня штука все-таки посложнее туду листа будет, и такого точно не полно в интернете ????


  1. karambaso
    08.08.2022 19:19
    -1

    А в чём принципиальное отличие от генерации html на сервере? Ну кроме ненужности реакта вместе с type-script-ом.

    Автор предлагает сделать компонент для фронта, просто свалив всю работу на бэк. Разве не так? Но я щё подскажу - такой компонент в случае с html давно есть, называется браузер. Так зачем же нам новый фреймворк, заменяющий собой всё то, что и так уже делает браузер? Зачем снова и снова делать велосипед, если есть давно отработанное и надёжное авто марки "браузер"?

    Да, это не относится ко всем проблемам фронта. Это лишь указание на проблему конкретной идеи конкретного автора.


    1. vital_pavlenko Автор
      08.08.2022 21:29

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

      А вообще звучит как "зачем нам реакт с тайпскриптом, если есть браузер")


  1. MZinchenko
    11.08.2022 09:17
    +1

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

    В некоторых случаях они были действительно супер сложными. Помню в одной конторе у нас все приложение описывалось xml. Это была трехзвенка и все три звена работали по этому xml. Веселее всего было с миграциями для БД, для нетривиальных случаев приходилось в этом xml прописывать миграции с предыдущих версий.

    И вместо html использовался свой язык общения между фронтом и бэком тоже почти везде тогда. Совсем тонких клиентов в таких системах действительно не бывает, а с толстым клиентом зачем общаться через html, это неудобно.

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

    А сейчас вот новая волна пошла :) Снова про lowcode разговоры и про то, что программисты не очень-то нужны :)