Борьба за производительность по-настоящему больших форм на React

    На одном из проектов мы столкнулись с формами из нескольких десятков блоков, которые зависят друг от друга. Как обычно, мы не можем рассказать о задаче в деталях из-за NDA, но попробуем описать свой опыт “укрощения” производительности этих форм на абстрактном (даже немного не жизненном) примере. Расскажу, какие выводы мы сделали из проекта на React с Final-form.

    image

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

    Итак, на нашем проекте мы столкнулись с формой из множества блоков, обладающих определенными свойствами:

    • Среди полей есть окна для ввода, множественный выбор, поля с автозаполнением.
    • Блоки связаны между собой. Предположим, в одном блоке вам надо указать данные внутреннего паспорта, а чуть ниже будет блок с данными заявителя на визу. Договор с визовым центром при этом тоже оформляется на внутренний паспорт.
    • В каждом блоке надо реализовать свои проверки – на адекватность номера паспорта, корректность ввода электронной почты, возраст человека (в 10 лет загранпаспорт получить можно, а заказчиком по договору быть нельзя) и многое другое.
    • От данных, введенных в одни блоки, может зависеть видимость и автоматически введенные данные в других блоках. В примере выше, если загранпаспорт оформляется на 10-летнего школьника, нужно отобразить блок с данными родителей. Зависимости не тривиальны: одно поле может зависеть от пяти и более других полей.
    • Заполнение формы разделено на два шага. В первом шаге мы показываем лишь малую часть полей. Но введенную информацию мы должны помнить во втором шаге.

    Итоговая форма занимала порядка 6 тыс. пикселей по вертикали – это примерно 3-4 экрана, в общей сложности более 80 разных полей. В сравнении с этой формой заявления на Госуслугах кажутся не такими уж и большими. Ближе всего по обилию вопросов, наверное, анкета службы безопасности в какую-нибудь большую корпорацию или скучный социологический опрос о предпочтениях видеоконтента.

    В реальных задачах большие формы встречаются не так часто. Если пытаться реализовать такую форму “в лоб” – по аналогии с тем, как мы привыкли работать с небольшими формами, – то результатом будет невозможно пользоваться.

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

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

    Как мы развернули под себя Final-form


    На проекте использовались React и TypeScript (по мере реализации своих задач мы полностью перешли на TypeScript). Поэтому для реализации форм мы взяли библиотеку React Final-form от создателей Redux Form.

    На старте проекта мы разбили форму на отдельные блоки и использовали подходы, описанные в документации к Final-form. Увы, это приводило к тому, что ввод в одном из полей кидал изменение всей большой формы. Поскольку библиотека сравнительно свежая, документация там также пока “молода”. В ней не описаны оптимальные рецепты, позволяющие улучшить производительность больших форм. Как я понимаю, с этим просто мало кто сталкивается на проектах. А для маленьких форм несколько лишних перерисовок компонента не оказывают никакого влияния на производительность.

    Зависимости


    Первая неясность, с которой нам пришлось столкнуться, – как именно реализовать зависимость между полями. Если работать строго по документации, разросшаяся форма начинает тормозить из-за большого количества связанных между собой полей. Суть тут в зависимостях. Документация предлагает положить рядом с полем подписку на внешнее поле. У нас на проекте так и было – адаптированные варианты react-final-form-listeners, которые отвечали за связь полей, лежали там же, где и компоненты, то есть валялись в каждом углу. Уследить за зависимостями было трудно. Это раздуло объем кода – компоненты стали просто гигантскими. Да и работало все медленно. А чтобы что-то изменить в форме, приходилось тратить много времени, используя поиск по всем файлам проекта (в проекте порядка 600 файлов, из них более 100 – компоненты).

    Мы сделали несколько попыток улучшить ситуацию.

    Нам пришлось реализовать собственный selector, который выбирает только данные, необходимые какому-то конкретному блоку.

    <Form onSubmit={this.handleSubmit} initialValues={initialValues}>
       {({values, error, ...other}) => (
          <>
          <Block1 data={selectDataForBlock1(values)}/>
          <Block2 data={selectDataForBlock2(values)}/>
          ...
          <BlockN data={selectDataForBlockN(values)}/>
          </>
       )}
    </Form>
    

    Как вы понимаете, пришлось придумывать свой memoize pick([field1, field2,...fieldn]).

    Все это в связке с PureComponent (React.memo, reselect) привело к тому что блоки перерисовываются только тогда, когда меняются данные, от которых они зависят (да, мы ввели в проект библиотеку Reselect, которая ранее не использовалась, с ее помощью выполняем почти все запросы данных).

    В итоге перешли на один listener, где описаны все зависимости для формы. Саму идею этого подхода мы взяли из проекта final-form-calculate (https://github.com/final-form/final-form-calculate), допилив под свои нужды.

    <Form
       onSubmit={this.handleSubmit}
       initialValues={initialValues}
       decorators={[withContextListenerDecorator]}
    >
    
       export const listenerDecorator = (context: IContext) =>
       createDecorator(
          ...block1FieldListeners(context),
          ...block2FieldListeners(context),
          ...
       );
    
       export const block1FieldListeners = (context: any): IListener[] => [
          {
          field: 'block1Field',
          updates: (value: string, name: string) => {
             // Когда изменеяется поле block1Field срабатывает эта функция и мы зависимые поля...
             return {
                block2Field1: block2Field1NewValue,
                block2Field2: block2Field2NewValue,
             };
          },
       },
    ];
    

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

    Валидация


    По аналогии с зависимостями мы разобрались и с валидацией.

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

    • валидатор для паспортных данных,
    • валидатор для данных о поездке,
    • для данных о предыдущих выданных визах,
    • и т.п.

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

    Переиспользование кода


    Начинали мы с одной большой формы, на которой и обкатывали свои идеи, но со временем проект разросся – появилась еще одна форма. Естественно, на второй форме мы использовали все те же идеи, да еще и код переиспользовали.

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

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

    Вместо итогов


    Логичен вопрос: почему мы не использовали другую библиотеку для форм, раз уж с этой возникли сложности. Но с большими формами в любом случае будут свои проблемы. В прошлом я и сам работал с Formik. С учетом того, что решения для своих вопросов мы все-таки нашли, Final-form оказался удобнее.

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

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

    Авторы статьи: Олег Трошагин, Максилект.

    P.S. Мы публикуем наши статьи на нескольких площадках Рунета. Подписывайтесь на наши страницы в VK, FB, Instagram или Telegram-канал, чтобы узнавать обо всех наших публикациях и других новостях компании Maxilect.
    Maxilect
    Умные решения для вашего бизнеса

    Комментарии 17

      0
      Есть ещё вот такая библиотека и там переливов меньше.
      react-hook-form.com
        +2
        У меня в 3 проектах 29 форм. Самая крутая 52 поля (без учета полей в вызываемых диалогах). Я относительно просто решил аналогичные проблемы. Сделал код объектно-ориентированным (redux-free-zone): форма — объект, контрол (поле) — объект. Контролы и формы генерят команды. Обработчики команд в подгружаемых скриптах на ванильном js. Валидация аналогично. Скрытие аналогично. Все летает. Формы делаются на питоне, реакторную часть месяцами не трогаю.
        0
        Просто используйте mobx. Эта очень крутая штука по управлению зависимостями между данными и позволяет точечно перерисовывать только то, что реально изменилось. Redux можете выкинуть на свалку.
          0

          Давно не слежу. А есть для mobx либа/фреймворк для форм приличный?

            0
            Использовал такой github.com/QuantumArt/mobx-form-validation-kit#doc_rus
            и final-form.
            Final-form + mobx — показалось идеальным для меня.
              +1

              Final-form имеет, по сути, свой реактивный (?) стейт-менеджер. насколько оправдано интегрировать его с mobx?

                +1
                Нормально вроде уживаются вместе. Главное на забывать что в методе render компонента Form наблюдаемые значения не будут видны.
                Необходимо дополнительно оборачивать кусок разметки, нуждающийся в каком-то наблюдаемом значении, в observer (nesting-caveat).
              0
              Использую react-bootstrap. На них сделаны часто используемые самописные компоненты, типа поля ввода у которого есть лейбл и подсвечивается ошибка валидации под компонентом. Реакт используется чисто как рисовалка, состояние же хранится в самописных классах использующих mobX, и в этих самописных классах реализованы все нужные валидации. В целом благодаря mobX все эти велосипеды пишутся очень легко, тут даже ничего стороннего тащить в проект не надо.
              Но самое важное в mobx, что логику можно пытаться писать в декларативном виде. Т.е. не как обычно принято, что можно назвать событийное программирование, когда например пишем обработчик обработки события изменения текста в поле ввода, и в этом обработчике вызываем десяток функций которые что-то вычисляют и меняют в других полях, в итоге получается лапша. А более декларативно, например что данное поле на форме является функцией вот этих трех полей. И если значение любого их этих трех полей поменяется, то перевычислится и значение данного поля. Причем не важно как оно поменяется, пользователь поменяет, или оно само зависит от 4го поля.
            –1
            Вижу слова Redux, Redux-From, Final-Form, Formik это прямо комбо, сборище самого ущербного и бестолкового мусора… Неужели всё так туго и по сторонам смотреть не хотим? Redux головного мозга до сих пор по инерции никак не отпускает людей.
            С MobX же любые вопросы в том числе и этот элементарно на раз два и никаких проблем с производительность вне зависимости от того, какой размер формы.
              0
              Основная проблема заключается в том, что при вводе каждой буквы в соответствующих полях вся форма будет перерисовываться, что влечет за собой проблемы с производительностью, особенно на мобильных устройствах.

              Вы серьезно? Знаете что я делаю когда мне нужно отобразить сложные формы как в вашем случае когда у нас "в общей сложности более 80 разных полей"?
              Я не использую никакие стейт-менеджеры или библиотеки для форм я просто использую обычный реакт и в каждом обработчике поля onChange пишу примерно такое


              <input onChange={(e)=>{
                 AppState.form.someField = e.target.value;
                 //валидация или другая логика
                 actualize() //однострочный хелпер который вызывает ReactDOM.render(<App/>, el)
              }}/>

              Увидев вызов ReactDOM.render(<App/>), вы наверное подумаете "о боги, это же перерисовка всего приложения при вводе каждой буквы!". Поэтому позвольте мне напомнить что такое "перерисовка" в терминах реакта. В реакте "перерисовка" это просто рекурсивное сравнение двух деревьев js-объектов чтобы изменить в html/dom только то что отличается. Сравнение очень быстрое — никаких медленных обращений к дом-элементам, алгоритм линейный (просто спуск по дереву объектов и сравнение свойств с соответствующим объектом от предыдущего вызова перерисовки), сам javascript умеет компилироваться в ассеблер и его скорость на уровне с++ или даже быстрее (https://www.youtube.com/watch?v=UJPdhx5zTaw). В общем за 1мс можно "перерисовать" или точнее сравнить дерево из 50к объектов. И на этом фоне ваши желания оптимизировать вызов "перерисовки" отдельных компонентов для формы из всего лишь 100 полей выглядят забавно

                0

                хотя бы раз нужно читать документацию…
                чтобы форма не ререндерилась каждый раз при вводе символа в какой-либо инпут, нужно убрать подписку на values у всей формы
                https://final-form.org/docs/react-final-form/examples/subscriptions


                и, если нужно, использовать доступ только к определенным полям ближе к коду, где это необходимо (через Field или FormSpy)

                  +1
                  Redux не лучшее решение для хранения состояний при работе с формами. Лучше хранить состояние локально в компоненте. Отличное решение работать с формой, как одним объектом, это с кастомными компонентами без библиотек. Новый проект делаю с react-hook-form. Выглядит лучше чем Formik или Redux-forms. Очень удобная валидация в компоновке с yup. Для новых проектом рекомендовал бы.
                    +1
                    final-form — это написанная с нуля redux-forms (тот же автор, то же api), только без привязки к redux & react. yup также можно использовать без проблем.

                    в целом все «живые» библиотеки для форм плюс/минус одинаковые, я работал с redux-form 2 года, переехал на final-form.
                    причем у нас на проектах формы бывают и по 200 инпутов, и более. и где надо каждый инпут пересчитывать относительно значений в других (по типу гугл таблиц). final-form отлично справляется с производительностью. redux-form не справлялась
                    0

                    Мне одному кажется, что 80 полей для одной формы это признак не очень хорошего UX? Мне трудно представить случай, в котором такого монстра нельзя разбить на несколько маленьких форм по 3-8 полей и показывать пользователю по очереди (e.g. wizard). Это также решит проблему с перерисовкой и перевалидацией огромной формы.

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

                    Самое читаемое