1. Обзор
При работе с большими объемами данных способ обработки и отображения полученных данных на экране иногда может сильнее влиять на производительность, чем само получение данных.
В одном из реальных проектов я разработал экран, который должен был обрабатывать десятки тысяч записей и отображать их в Grid. Экран должен был не просто выводить полученные с сервера данные как есть, а связывать отдельные наборы данных по определенным критериям и отображать результат обработки нескольких значений в одной Cell.
На ранних этапах разработки проблемы с производительностью были не особенно заметны, поскольку тестирование выполнялось на относительно небольшом объеме данных. Однако по мере увеличения фактического объема данных до десятков тысяч записей и более время, необходимое для отображения данных в Grid, увеличивалось, а экран иногда зависал при выполнении других действий во время загрузки данных.
Сначала я предположил, что причина заключается в производительности самого рендеринга Grid. Однако после изучения потока обработки я обнаружил, что повторяющийся поиск и обработка данных, выполняемые для создания данных Cell, также требовали значительных затрат.
В этой статье, основываясь на данном опыте, я расскажу, как устранил проблемы с производительностью, возникавшие при обработке больших объемов данных, и как эти проблемы связаны с Main Thread Blocking и Long Task.
2. Требования и проблемная ситуация
Требования и проблемная ситуация были следующими.
Требования
-
Отображать данные с прокруткой вместо пагинации
-
Требовалась функция фильтрации более чем по нескольким десяткам столбцов (требовались все данные)
-
Отображать разные цвета фона в зависимости от значений Cell
Проблемная ситуация
-
После получения данных требовалось много времени на их обработку и отображение
-
Экран зависал при выполнении других действий во время загрузки
3. Повторяющиеся операции с большими объемами данных
В первоначальной реализации Related Data, которые хранились отдельно от Main Data, объединялись на уровне Cell.
В упрощенном виде это выглядело следующим образом.
예시)
const columnDefs = [
{
headerName: 'Related Data',
valueGetter: params => {
const relatedItems = relatedData.filter(
item => item.targetId === params.data.id
);
return relatedItems
.map(item => item.name)
.join(', ');
},
},
];
При небольшом объеме данных это не было проблемой, однако по мере увеличения Main Data и Related Data количество поисковых операций, необходимых для создания значений Cell, также неоднократно возрастало.
В частности, в Grid значения Cell могут пересчитываться в зависимости от таких операций, как обновление данных, сортировка или фильтрация.
В конечном счете проблема заключалась не просто в том, что «в Grid было много данных».
Проблема заключалась в том, что с увеличением объема данных также повторялись поиск и обработка, необходимые для отображения каждого элемента на экране.
4. Исследование проблемы с точки зрения Main Thread
В Main Thread браузера выполняются различные задачи, необходимые не только для выполнения JavaScript, но и для обработки пользовательских событий и рендеринга.
Поэтому, если операции JavaScript надолго занимают Main Thread, это также может повлиять на ввод пользователя и последующий рендеринг.
В то время для получения данных и их отображения на фактическом экране требовалось выполнить следующие задачи.
Ответ API
↓
Поиск данных
↓
Объединение и преобразование данных
↓
Формирование данных Grid
↓
Рендеринг Grid
↓
Разметка / отрисовка
Хотя каждая операция сама по себе не была крупной, их повторение для десятков тысяч записей увеличивало общие затраты на выполнение JavaScript.
Эта проблема также связана с Long Task. В браузерах задача, выполняющаяся в Main Thread более 50 мс, классифицируется как Long Task.
Если во время выполнения Long Task происходит взаимодействие с пользователем, обработка события может быть отложена до завершения этой задачи.
Поэтому проблемы с производительностью при работе с большими объемами данных нужно было рассматривать не только с точки зрения сокращения общего времени обработки, но и с точки зрения уменьшения объема работы, выполняемой за один раз в Main Thread.
5. Предварительная обработка данных
Сначала я попытался сократить количество повторных поисковых операций, выполняемых при формировании данных Cell.
Ранее при каждом вычислении значения Cell для поиска данных, связанных с текущим Row в Related Data, использовался filter().
Если Main Data и Related Data содержат соответственно N и M записей, Related Data многократно просматриваются при вычислении каждого значения Cell, что в итоге может привести к общей стоимости поиска O(N × M).
Для улучшения ситуации я предварительно обработал Related Data по targetId и создал структуру Lookup.
예시)
const relatedNameMap = new Map<string, string[]>();
relatedData.forEach( item => {
const names = relatedNameMap.get(item.targetId) ?? [];
names.push(item.name);
relatedNameMap.set(item.targetId, names);
});
6. Передача обработанных данных в Grid
После предварительной обработки Related Data я изменил реализацию так, чтобы объединять результат с Main Data и передавать его в Grid.
Ранее необходимые данные искались, а значения объединялись в момент создания Cell.
Создание ячеек
↓
Исследование связанных данных
↓
Объединение и преобразование данных
↓
Отрисовка ячеек
При таком подходе значения ячеек можно было пересчитывать всякий раз, когда данные таблицы обновлялись, сортировались или фильтровались.
После улучшения мы включили необходимые значения в основные данные перед их передачей в таблицу, используя ранее созданный relatedNameMap.
예시)
const gridData = mainData.map( row => ({
...row,
relatedNames: relatedNameMap.get(row.id)?.join(“, ”) ?? “",
}));
const columnDefs = [ {
field: 'relatedNames',
headerName: 'Related Data',
},];
Это позволило исключить повторное исследование и обработку данных из процесса отрисовки при каждом создании ячейки.
В частности, даже при обновлении, сортировке или фильтрации данных больше не требовалось заново исследовать связанные данные для формирования значений ячеек. Таблица могла сосредоточиться на отображении уже рассчитанных значений на экране.
В результате значимость этого изменения заключалась не просто в повышении скорости отрисовки ячеек, а в отделении повторяющихся операций от процесса отрисовки и повторном использовании результатов вычислений.
7. Проблемы, которые невозможно решить только с помощью виртуализации
При отображении в таблице десятков тысяч записей данных создание каждой строки в виде фактического элемента DOM неэффективно. Использовавшаяся нами AG Grid также отрисовывала с помощью виртуализации только строки, необходимые вокруг текущей области просмотра, поэтому диапазон элементов, создаваемых в фактическом DOM, был ограничен.
Однако применение виртуализации не решает все проблемы производительности, возникающие при обработке больших объёмов данных. Виртуализация в первую очередь сокращает диапазон DOM, отрисовываемого на экране; она не уменьшает количество операций JavaScript, связанных с формированием данных, передаваемых в таблицу, или вычислением значений, отображаемых в ячейках.
В то время обработка десятков тысяч записей данных требовала исследования и объединения данных, разделённых между разными источниками. Поэтому ключевым направлением улучшения было сокращение затрат на повторное исследование и обработку при формировании данных таблицы или вычислении значений ячеек, а не уменьшение диапазона отрисовки самой таблицы.
8. Итоги
Оглядываясь на этот опыт с точки зрения главного потока, можно сказать, что применённые нами улучшения в конечном счёте были направлены на сокращение объёма работы, которую должен был выполнять главный поток.
|
Проблема |
Направление улучшения |
|---|---|
|
Повторное исследование данных |
Создание структуры поиска |
|
Обработка данных на уровне ячеек |
Предварительное вычисление |
|
Повторное выполнение одних и тех же вычислений |
Повторное использование результатов вычислений |
Даже после применения этих методов, если сами вычисления по-прежнему остаются ресурсоёмкими, можно также рассмотреть разделение задачи или использование веб-воркера.
Однако вместо того чтобы с самого начала применять сложное решение, я считал важным прежде всего определить, какие задачи многократно выполнялись в текущем главном потоке.
Это связано с тем, что простое устранение ненужного исследования и дублирующихся вычислений может сократить общий объём работы, которую должен выполнять главный поток.
9. Заключение
Этот опыт научил меня, что при работе с большими объёмами данных необходимо анализировать не только производительность отрисовки, но и то, какие операции повторяются до отображения данных на экране. Я также понял, что оптимизация производительности заключается не только в сокращении времени выполнения конкретной функции; важно спроектировать процесс обработки данных так, чтобы ненужные вычисления изначально не выполнялись повторно.
Jung