JP Camara, главный инженер Wealthbox, в своём блоге поделился интересным опытом ускорения TanStack Table — новой версии React-библиотеки для создания функциональных таблиц — аж до 10 мс. Делимся с вами переводом его статьи.
JP Camara
Главный инженер Wealthbox — CRM для финансовых консультантов
Несколько месяцев назад я работал над интерфейсом JavaScript для большого набора данных, используя TanStack Table. Ограничения были такие:
максимум 50 тыс. строк контента,
группировка до 3 колонок.
Производительность была хорошей при использовании React и виртуализированного рендеринга для отображения 50 000 строк. Когда же я включил функцию группировки таблиц TanStack, производительность упала уже на нескольких тысячах строк и стала чрезвычайно низкой при 50 000 строк.
Я бы мог и не обратить внимания, если бы всё замедлялось на 100 мс или даже на 500 мс. Но в самых тяжёлых случаях рендеринг строк занимал больше 1 секунды без группировки и до 30–40 секунд с группировкой.
Как я выявил проблему
Сначала я использовал Chrome-профайлер JavaScript, но с ним сложно работать на низкой производительности. Профайлер накладывает на код заметную нагрузку. Исполнение кода уже занимало 30–40 секунд, поэтому профайлер не годился. Оценить производительность при анализе кода React — вообще сложно: некоторые части внутреннего кода используются слишком часто, поэтому результаты трудно расшифровать.
У меня был запасной план, и я обратился к старому доброму console.time
. Таймер помогает увидеть, сколько времени занимает выполнение участка кода:
console.time('expensive code');
thisIsExpensive();
console.timeEnd('expensive code');
// console.time
// expensive code: 1000ms
На будущее учёл, что можно воспользоваться: console.profile
. Он подходит для профилирования небольших блоков кода, а не для расшифровки всего стека рендеринга React. Но, в данном случае, учитывая медленную скорость исходного кода, метод бы не помог. Подробнее о нём можно почитать здесь.
Мы, программисты, полны идей о том, ЧТО нужно оптимизировать, и не умеем делать это правильно. Мы обоснованно предполагаем, ЧТО важно оптимизировать и ГДЕ зарыта проблема. Но, пока мы это не ИЗМЕРИМ, наши суждения ошибочны.
Я начал оборачивать как можно больше кода в тестирование, чтобы убедиться, что верно выбрал части кода для оптимизации. Затем сужал бенчмарк до конкретного куска, который нужно улучшить.
Вот общая того схема, как я нашёл проблему с производительностью:
console.time('everything');
elements.forEach(() => {
console.time('methodCall');
methodCall(() => {
console.time('build');
build();
console.timeEnd('build');
});
console.timeEnd('methodCall');
});
console.timeEnd('everything');
// build 49ms
// methodCall 50ms
// build 51ms
// methodCall 52ms
// everything 102ms
Все мои предположения о потенциальной проблеме с производительностью ДО проведения измерений оказались ошибочными:
С моим кодом всё было нормально. Ошибка была в библиотеке, которую я использовал, — для меня это стало неожиданностью. Я проверил каждую строчку кода, и все они работали хорошо. Тормозил всё код библиотеки. Вся логика для таблицы TanStack в React сосредоточена в хуке
useReactTable
. Проблема с производительностью возникала именно там:
console.time('everything');
customCode();
console.time('useReactTable');
useReactTable(...);
console.timeEnd('useReactTable');
console.timeEnd('everything');
// useReactTable 31500 ms
// everything 31537 ms
Когда пишешь на JavaScript, одним из преимуществ работы с пакетами является то, что в любой момент можно открыть папку
node_modules
и поиграться со сторонним кодом. Я смог изменить исходный код TanStack Table напрямую, добавив информацию о времени загрузки.Включение группировки вызывало заметное замедление, поэтому стоило начать с проверки времени загрузки именно этого участка кода.
Чуть ниже даю сокращённую версию исходного кода сгруппированных строк с первым замером времени загрузки. Я пытался вычислить проблемные части кода, измеряя, сколько времени они выполнялись. Здесь стоит обратить внимание на операторы console.time
.
function getGroupedRowModel<TData extends RowData>() {
console.time('everything');
//...
console.time('grouping filter')
const existing = grouping.filter(columnId =>
table.getColumn(columnId)
)
console.timeEnd('grouping filter')
const groupUpRecursively = (
rows: Row<TData>[],
depth = 0,
parentId?: string
) => {
if (depth >= existing.length) {
return rows.map(row => {
row.depth = depth
//...
if (row.subRows) {
console.time('subRows')
row.subRows = groupUpRecursively(
row.subRows,
depth + 1
)
console.timeEnd('subRows')
}
return row
});
const columnId: string = existingGrouping[depth]!
const rowGroupsMap = groupBy(
rows,
columnId
)
const aggregatedGroupedRows = Array.from(rowGroupsMap.entries()).map(([groupingValue, groupedRows], index) => {
let id = `${columnId}:${groupingValue}`
id = parentId ? `${parentId}>${id}` : id
console.time(
'aggregatedGroupedRows groupUpRecursively'
)
const subRows = groupUpRecursively(
groupedRows,
depth + 1,
id
)
console.timeEnd(
'aggregatedGroupedRows groupUpRecursively'
)
//...
}
}
}
console.timeEnd('everything');
}
Мне казалось, что виновата функция groupUpRecursively
. Логичное предположение, что десятки тысяч рекурсивных вызовов могут снижать производительность (спойлер: как обычно, я ошибся