Клиентское веб-приложение monday.com работает на React и Redux. Ядром приложения являются доски (см. Пример ниже), которые представляют собой настраиваемые таблицы данных, в которых пользователи создают структуру таблиц, добавляя столбцы на свои доски, а затем добавляя элементы, которые являются строками таблиц.

В зависимости от структуры доски каждый предмет состоит из нескольких ячеек. Каждая ячейка имеет несколько компонентов React, некоторые из которых подключены к нашему магазину Redux.
Многие ячейки имеют сложные компоненты пользовательского интерфейса с разными состояниями и требуют множества элементов DOM и прослушивателей событий.
Некоторые из наших пользовательских досок содержат более 2000 элементов и более 50 столбцов, что означает более 100 000 ячеек и более 500 000 сложных компонентов.

Проблемы

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

Убедившись, что у нас есть фиксированное количество элементов, используя Windowing

Окно - это механизм, в котором мы визуализируем только то количество элементов, которое видят пользователи. Это означает, что количество отображаемых элементов будет зависеть от размера области просмотра. Так, например, если высота прокручиваемой области списка составляет 1000 пикселей, а каждый элемент - 25 пикселей, у нас будет 40 элементов. Затем, когда пользователи прокручивают, мы вычисляем, какие элементы мы должны отображать, а также добавлять и удалять элементы, обновляя элементы, которые находятся в области просмотра, но сохраняя фиксированное количество элементов в DOM. Сначала мы использовали стороннюю библиотеку (response-window), но в конце концов перешли на наше решение, чтобы иметь возможность лучше настроить его под наши конкретные нужды.

Улучшение нашей прокрутки за счет перехода от Windowing к Recycling

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

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

Ключи помогают React определять, какие элементы были изменены, добавлены или удалены. Ключи должны быть присвоены элементам внутри массива, чтобы придать элементам стабильную идентичность из React docs .

Как вы можете видеть в приведенном ниже примере, при прокрутке с повторным использованием мы отображаем элемент 6 (делаем его активным) вместо элемента 3 (делая его неактивным). Ключ элемента 3 присваивается элементу 6, поэтому React не запускает демонтаж элемента 3 и монтаж элемента 6. Вместо этого он просто обновляет свои свойства, вызывая componentDidUpdate.

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

Основные проблемы с нашим первым решением по переработке

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

Что такое белые пятна?

Эти белые пятна возникают из-за «асинхронной прокрутки» браузеров. Это означает, что поток прокрутки обновляет местоположение прокручиваемого элемента, но приложение работает недостаточно быстро, чтобы поддерживать скорость прокрутки и отображать соответствующие элементы. Мы называем это асинхронной прокруткой, поскольку код приложения не блокирует выполнение потока прокрутки.

Отключение асинхронной прокрутки

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

Поэтому, когда мы перешли на синхронную прокрутку, наш FPS (количество кадров в секунду) упал, и нужно было что-то делать.

Небольшое примечание о FPS:
Обычно мы хотим достичь 60 FPS, чтобы наше приложение выглядело плавно, быстро и гладко. Чтобы достичь 60 кадров в секунду, нам нужно убедиться, что выполнение каждого кадра не займет более ~ 16 мс (1000/60 ~ = 16 мс). Если, например, для выполнения каждого кадра потребуется 100 мс, то наш FPS упадет до 1000/100 = 10 FPS, что не даст пользователю того опыта, к которому мы стремимся.

Как мы справились с медленной прокруткой - Да будет «Свет»

Чтобы предотвратить медленную прокрутку и зависания, рендеринг элементов должен выполняться быстро. Это оказалось трудным, поскольку каждый элемент может включать в себя множество элементов, слушателей и других логических схем, которые усложняют рендеринг. Нашим решением этой тяжелой проблемы рендеринга было создание «облегченной» версии элементов, которая будет использоваться только при прокрутке. Идея состоит в том, чтобы визуализировать менее сложную версию, которая выглядит так же, как исходный элемент.

Это то, что мы сделали в monday.com. При вертикальной прокрутке и вставке элементов в DOM мы фактически визуализируем более легкую версию элемента, которая выглядит так же, как полная и тяжелая. Таким образом, для рендеринга каждого элемента требуется меньше времени, что предотвращает медленную прокрутку и зависания. Когда пользователи прекращают прокрутку - только тогда мы визуализируем «тяжелые» элементы, которые находятся в области просмотра, заменяя «легкие». Наш рендерер «Light» работал в 2 раза быстрее, и нам удалось сократить продолжительность кадров на 54%.

Как мы справляемся с крайними случаями, когда «Света» недостаточно - «Заполнитель» приходит на помощь

Проблема заключалась в том, что время рендеринга компонента «Light» было больше, чем мы ожидали, на некоторых сложных платах и ​​медленных компьютерах. FPS по-прежнему не хватало.

Почему? Скажем, рендеринг каждого компонента Light занял около 10 мс. Когда пользователь быстро прокручивал и 5 новых элементов вошли в область просмотра, что означает, что нам нужно отрендерить 5 элементов, это заняло 50 мс времени кадра. Таким образом, если кадр занимает 50 мс, FPS будет (1000/50) 20. Если больше элементов войдет в область просмотра - FPS упадет еще больше.

Итак, поверх рендерера «Легкий» / «Полный» мы добавили новый режим - «Заполнитель».

Этот «Заполнитель» даже легче, чем «Легкий» режим (т. Е. Отображает только ячейку имени элемента, а остальные ячейки остаются пустыми).

Подробнее о нашем решении «Заполнитель» - Ограничьте время кадра

Идея «Заполнителя» заключается в том, что его рендеринг выполняется очень быстро. Проблема с этим решением заключается в том, что мы меняем взаимодействие с пользователем, а это означает, что при прокрутке пользователи видят разные пользовательские интерфейсы для каждого элемента. Как уже упоминалось, в большинстве случаев достаточно «легкого» решения. Таким образом, решение «Заполнитель» действует как альтернатива «Легкому» решению в случаях, когда компьютеры слишком медленные или платы слишком сложные. Итак, если компьютеры достаточно быстрые, мы будем визуализировать «облегченную» версию, а в этих крайних случаях будет принято решение «заполнитель» в обоих случаях, поддерживающее 60 кадров в секунду.

Решение «Заполнителя» работает так: в событии onScroll мы сначала визуализируем «Заполнители» (очень быстро), а затем ставим в очередь задание для каждого элемента, который изменит свой режим, чтобы отобразить его «Облегченную» версию.

Чтобы поддерживать высокий FPS, переключение на «Легкое» задание будет выполнено в текущем кадре только в том случае, если оно не превысит ограничение по времени. Если это так, он будет перемещен к следующему кадру. Нам удалось достичь вышеупомянутого, разработав очередь для заданий, которая добавляет задания в основной поток, только если срок еще не был достигнут. Это решение для очереди запросов анимации (которое будет обсуждаться в другом сообщении в блоге) знает, когда выполнять задание в текущем кадре или ждать следующего.

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

С "заполнителем"

Без «заполнителя» (обратите внимание на зависания)

Как рендеринг различных режимов выглядит в коде

В этом решении используется усилитель, поэтому при монтировании компонента он запускается как «заполнитель», а затем мы используем очередь анимации запроса для обработки 2 типов заданий - moveToLight и moveToFull. Эти задания обновляют состояние компонента.

Вот базовая реализация этого усилителя (этот код использует React Hooks):

Подведение итогов

Вот основные решения, которые мы рассмотрели:

  • Windowing / Virtualized List - первое решение
  • Recycling List (или Просмотр списка Recycler list) - второе решение (вместо первого)
  • Синхронная прокрутка - вверху списка повторного использования
  • Режимы рендеринга (заполнитель, светлый, полный)
  • Ограничить время сценария кадра анимации (очередь запроса анимации)

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

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

Спасибо за чтение :)