Предисловие
На момент написания статьи я готовился к диплому и писал дипломный проект для нужд Московского Политеха. Моя задача - перенести существующий функционал из PHP-таблицы во что-то современное с кучей проверок, после чего дополнить данный функционал. Движок - Nuxt, материал-фреймворк: Vuetify.
После написания первичного кода, я, довольный, окинул взглядом свою таблицу и пошел спать. На следующий день мне предстояло импортировать 150+ проектов заказчика в свою таблицу. После импорта, я удивился тому, что браузер повис. Ну, бывает, просто переоткрою вкладку. Не помогло. Я впервые столкнулся с проблемой, что я рендерю слишком много, как для движка, так и для самого браузера. Пришлось начать думать.
Первые попытки
Что делает разработчик, когда сталкивается с проблемой? Идет гуглить. Это было первое, что я сделал. Как оказалось, проблема медленного рендера таблицы Vuetify встречается с куда меньшим числом элементов, чем у меня. Что советуют:
Рендерить элементы по частям через
setInterval
Ставить условие, чтобы не рендерить элементы, пока не сработает хук жизненного цикла
mounted()
Использовать
v-lazy
для последовательной отрисовки
При этом было предложение использовать компонент Virtual Scroller, позволяющий отрисовывать элементы по мере скролла, а предыдущие разрендеривать. Но этот компонент Vuetify не работает с таблицами Vuetify -_-
С "радостью" прочитав, что в Vuetify 3 (релиз через ~полгода) производительность улучшится на 50%, я стал пробовать решения. Рендер элементов по частям ничего не дал, так как на условном тысячном элементе отрисовка начинала лагать, а к семи тысячам снова всё висло. Рендер элементов на mounted не дал вообще ничего, всё зависало, но зато после того, как страница загрузится (эээ, ура?). v-lazy хоть и рендерился быстрее, но рендерить 14 тысяч компонентов (Vuetify и Transition от Vue) тоже грустное занятие.
Даже после того, как элементы отрендериваются, скроллить по таблице невыносимо. Элементов много, логики много, браузер просто не выдерживает такого. Сдача всего этого стояла на следующий день. Нужно было выкручиваться, учитывая опыт StackOverflow, который хоть мне и не помог, но натолкнул на то, что делать на следующем шаге.
Решение 1. Intersection Observer
Итак, что мы имеем. v-lazy отрисовывать невозможно, это 14 тысяч компонентов. Vuetify Virtual Scroller не поддерживается в Vuetify Data Table из-за его структуры. Выходит, нужно писать свою реализацию. Кто умеет определять, докрутил ли пользователь до элемента? Intersection Observer.
Internet Explorer нам не нужен, так что можем приступать.
Первая логичная попытка: использовать директиву v-intersect
от самого Vuetify. И 7 тысяч директив также привели к длительному рендеру страницы =(. Значит, выбора нет и придется работать руками.
mounted() {
//Цепляем его на таблицу с overflow: auto
//Требуемая видимость элемента для триггера: 10%
this.observer = new IntersectionObserver(this.handleObserve, { root: this.$refs.table as any, threshold: 0.1 });
//Почему нельзя повесить observe на все нужные элементы? Ну ладно
for (const element of Array.from(document.querySelectorAll('#intersectionElement'))) {
this.observer.observe(element);
}
},
Теперь взглянем на сам handleObserve:
async handleObserve(entries: IntersectionObserverEntry[]) {
const parsedEntries = entries.map(entry => {
const target = entry.target as HTMLElement;
//Предварительно задали data-атрибуты
const project = +(target.dataset.projectId || '0');
const speciality = +(target.dataset.specialityId || '0');
return {
isIntersecting: entry.isIntersecting,
project,
speciality,
};
});
//Чтобы точно было реактивно
this.$set(this, 'observing', [
//Не добавляем дубликаты
...parsedEntries.filter(x => x.isIntersecting && !this.observing.some(y => y.project === x.project && y.speciality === x.speciality)),
//Убираем пропавшие
...this.observing.filter(entry => !parsedEntries.some(x => !x.isIntersecting && x.project === entry.project && x.speciality === entry.speciality)),
]);
//Иначе функция стриггерится несколько раз
Array.from(document.querySelectorAll('#intersectionElement')).forEach((target) => this.observer?.unobserve(target));
//Даем Vuetify перерендериться
await this.$nextTick();
//Ждем 300мс, чтобы не триггернуть лишний раз, отрисовка тоже грузит браузер
await new Promise((resolve) => setTimeout(resolve, 500));
//Вновь обсервим элементы
Array.from(document.querySelectorAll('#intersectionElement'))
.forEach((target) => this.observer?.observe(target));
},
Итак, мы имеем 7 тысяч элементов, на которых смотрит наш Intersection Observer. Есть переменная observing, в которой содержатся все элементы с projectId и specialityId, по которым мы можем определять, нужно ли показывать нужный элемент в таблице. Осталось всего-лишь повесить v-if на нужный нам элемент и отрисовывать вместо него какую-нибудь заглушку. Ура!
<template #[`item.speciality-${speciality.id}`]="{item, headers}" v-for="speciality in getSpecialities()">
<div id="intersectionElement" :data-project-id="item.id" :data-speciality-id="speciality.id">
<ranking-projects-table-item
v-if="observing.some(
x => x.project === item.id && x.speciality === speciality.id
)"
:speciality="speciality"
:project="item"
/>
<template v-else>
Загрузка...
</template>
</div>
</template>
А на саму таблицу вешаем v-once. Таблице будет запрещено менять свой рендер без $forceUpdate
. Не очень красивое решение, но Vuetify непонятно чем занимается при скролле, запретим ему это делать.
<v-data-table
v-bind="getTableSettings()"
v-once
:items="projects"
@update:expanded="$forceUpdate()">
Итоги:
Рендер занимает около секунды
Элементы разрендериваются вне их зоны видимости
Идет ререндер на каждое действие внутри таблицы
Но при этом работать с большой таблицей невыносимо. 7 тысяч событий на каждой клетке таблицы, бесконечный набор сообщений "Загрузка...". Таблица была создана, чтобы выполнять действия быстро, и с этим решением мы релизнулись.
Однако было ясно (и заказчик это подчеркнул), что работать с таблицей тяжело. Нужно было что-то решать, но на момент тогда я зашел в тупик: Vuetify Data Table имеет ужасные проблемы с производительностью, которые признают даже разработчики фреймворка.
Не переписывать же мне их таблицу и создавать свой аналог?
Решение 2. Переписать таблицу и создать свой аналог
Решение, приведённое выше, может подойти для таблиц с куда меньшим количеством компонентов, содержимого и прочего. Но у нас с нашей логикой это позволило работать с таблицей, пренебрегая комфортом пользователей. Попробуем добавить немного комфорта.
Что мы поняли из первого решения:
Таблицы Vuetify лагучий отстой
Если показывать элементы только когда пользователь их увидит, рендер будет быстрее
Нужно как-то сократить время на первичную отрисовку, чтобы пользователь не видел бесконечную "Загрузку"
Можно заставить Intersection Observer реагировать чаще, чтобы рендерить элементы по мере скролла, а не когда скролл остановится или среагирует событие (300мс задержка)
Возвращаемся к Virtual Scroller. Он не работает в таблице Vuetify, так? А что если мы напишем свою таблицу с блекджеком и display: grid
? Зачем-то же его придумали.
Что нужно для Virtual Scroller? Фиксированная высота каждого элемента. Что нужно для Grid'ов? Фиксированная ширина каждого элемента и информация о количестве элементов. Бахнем CSS-переменные для последующего использования в CSS:
<div class="ranking-table" :style="{
'--projects-count': getSettings().projectsCount,
'--specialities-count': getSettings().specialitiesCount,
'--first-column-width': `${getSettings().firstColumnWidth}px`,
'--others-columns-width': `${getSettings().othersColumnsWidth}px`,
'--cell-width': `${getSettings().firstColumnWidth + getSettings().othersColumnsWidth * getSettings().specialitiesCount}px`,
'--item-height': `${getSettings().itemHeight}px`
}">
Потом пишем гриды по типу аля
display: grid;
grid-template-columns:
var(--first-column-width)
repeat(var(--specialities-count), var(--others-columns-width));
И так далее для элементов внутри. Класс! А еще мы, зная ширину и количество элементов, можем задать ширину нашему Virtual Scroller (он сам по умолчанию туповат), а заодно и всем нашим элементам, чтобы не дай боже кто-то вышел за доступные ему границы
.ranking-table_v2__scroll::v-deep {
.v-virtual-scroll {
&__container, &__item, .ranking-table_v2__project {
width: var(--cell-width);
}
}
}
Примечание: если вы сделаете просто <style>
без scoped
и решите, что будет хорошей идеей редактировать глобальные стили вне окружения компонента, то у меня для вас плохие новости: лучше так не делать вне каких-то App.vue, и стоит ознакомиться с тем, что это за v-deep.
Поехали: добавляем Virtual Scroller, пихаем в него проекты, после чего выводим последние. Сразу скажу: поддержки Expandable Items у нас тут нет, я вынес информацию о проекте во всплывающее окно. Жаль, конечно, что нельзя это отображать прямо в таблице, как делал Vuetify, но тогда придется помучаться с их скроллером, а он и так не особо хорошо работает. В общем, к делу:
<v-virtual-scroll
class="ranking-table_v2__scroll"
:height="getSettings().commonHeight"
:item-height="getSettings().itemHeight"
:items="projects">
<template #default="{item}">
<div class="ranking-table_v2__project" :key="item.id">
<!-- ... -->
Итого: на страницу, допустим, помещается 6 проектов (высота же у всех максимальная по факту), итого рендерится 6 строк + шапка. Колонок 50. Итого рендерится около 300 сложных компонентов. А вот это уже задача не уровня мстителей, 300 мы рендерить умеем.
Вспоминаем про лучший инструмент всех времен и народов v-lazy: он позволяет отрендерить элемент один раз и потом не перерендеривать его. Раньше мы пытались отрендерить 14 тысяч компонентов, сейчас 600 простых. Ну и оборачиваем все наши колонки (кроме шапки) в v-lazy. При горизонтальном скролле элементы подгружаются, и остаются отрендеренными до тех пор, пока сама строка таблицы не пропадет из области видимости и не разрендерится.
<v-lazy class="ranking-table_v2__item ranking-table_v2__item--speciality"
v-for="(speciality, index) in specialities"
:key="speciality.id">
<!-- Колонка в строке таблицы -->
</v-lazy>
Видео с разницей, можно сравнить:
Плюсы такого решения:
Элементы перестали скакать как ненормальные
Нет нужды писать свою реализацию логики рендера/отрендера
Можем отказаться от v-once и всяких принудительных ререндеров аля $forceUpdate
Куда больше гибкости при верстке таблицы
Минусы:
Если используются выпадающие элементы (expand), нужно писать свою реализацию
Нужно верстать таблицу самому, без инструментов движка
Нет средств сортировки/группировки и прочего (мне было не нужно)
Для того, чтобы шапка всегда была отрендеренной, но скроллилась вместе со всеми, мне пришлось отслеживать событие Scroll и подгонять скролл шапки к скроллу основных элементов
Чтобы отображать таблицу в нужном мне проценте от высоты страницы, мне пришлось смотреть на window.innerHeight и применять его в CSS переменных и в значении высоты у VirtualScroll
Несмотря на то, что минусов визуально больше, значительное улучшение UX и скорости отрисовки значительно компенсирует затраты времени и изучения инструментов для реализации этой штуки.
Заключение
Я привел 2 варианта решения проблем с производительностью отрисовки таблиц в Vuetify. Оба решения достаточно сложные, просто сложные в разных аспектах. Оба так или иначе помогли мне решить мою проблему, и у обоих есть свои ограничения. Например, второй вариант мне нравится больше, но отсутствие средств Vuetify (сортировка/группировка и пр.) может оттолкнуть тех, кому это позарез нужно.
Зачем я это пишу? Я перегуглил половину интернета и не нашел простых решений своей проблемы. Наверное, как раз таки потому, что этих решений нет. Эти таблицы не предназначались для сложной логики, даже в условных гугловских таблицах элементов отрисовывается меньше, потому что там буквально один инпут, изредка с каким-то контентом, здесь же - гигантская куча логики.
И да: я пользовался дебагером производительности от Vue и смотрел, кто её потребляет. Зачастую там был буквально один-два компонента, и, заменив их на какой-то другой с похожей логикой, проблема не решалась - дело в их количестве, а не сложности (не считая таблицу Vuetify - там передается множество props'ов из компонента в компонент).
Надеюсь, что приведенные мной варианты натолкнут кого-то на решение его проблемы, а кто-то просто узнает что-то новое =). Будем вместе ждать стабильный Vue 3 со всей его экосистемой, как минимум Nuxt 3. Что-то обещают множество улучшений, может, часть костылей из этой статьи даже пропадет.