Предисловие
На момент написания статьи я готовился к диплому и писал дипломный проект для нужд Московского Политеха. Моя задача - перенести существующий функционал из 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. Что-то обещают множество улучшений, может, часть костылей из этой статьи даже пропадет.
rpsv
Неплохо бы сначала подумать, чтобы хотя бы правильный запрос в гугл отправить :)
И парочка вопросов:
1. зачем выводить по 100 строк на 1 страницу, если можно разбить на более мелкую пагинацию и все будет ок?
2. Судя по 14К компонентам, у вас 100 строк и 140 столбцов. Не кажется ли слишком большим число столбцов, и проще/лучше было подумать про «горзинтальную пагинацию» чем грузить все подряд?
3. Не знаю как в Nuxt (не работал с ним), но во Vue есть функциональные компоненты, которые как раз для таких случаев и нужны: внутри ячейки у вас легкий функциональный компонент, который при нажатии на кнопки (видимо удаление, редактирование, добавление) пушат в store информацию и отображается компонент ВНЕ таблицы? Это не поможет с количеством компонентов внутри таблицы, но явно облегчит саму таблицу.
4. Можно было сделать авто-пагинацию, когда доскролил до нижней границы текущей страницы, подгружать автоматом след страницу, когда докрутил до верхней границы пред-страницы
daniluk4000 Автор
1. Требование заказчика. Каждое из 70 направлений может быть у нашего проекта
2. Я вообще сделал вертикальную пагинацию и возможность отображать вместо 70 направлений только те, которые пришли с проектов. Условно, пришло 25 проектов с 10 суммарно направлениями — ура, 250 элементов
3. В Nuxt всё так же. Кнопки если что показывают всплывающее окно, поэтому так просто это не сделать, но идея хорошая
4. Внутри таблицы Vuetify не получилось это сделать по нормальному. Поэтому второе решение и спасло
ЗЫ: про 140 столбцов. Их 70. Компонентов 14к было, потому что v-lazy генерирует на верхнем уровне 2 компонента (он вроде как как раз функциональный). Вообще элементов 7к: 70 направлений и 100 проектов
rpsv
1. Странное требование (скорее всего не так сформулировано), но тем не менее вы фактически показываете только ограниченное число элементов (те что входят на страницу) и для конченого пользователя без разницы загружено все 100 элементов, а видит он только 20 или загружено 20 элементов и он эти 20 элементов видит.
2. не понял о чем речь, не хватает контекста, но не суть
3. эээ а в чем проблема? Компонент выводит кнопку, при нажатии вызывается что-то вроде
parent.$store.dispatch('viewEditModal', item);
. Компонент всплывающего окна находится вне таблице и «слушает» $store. Сеньор помидор в азбуке вкуса должен знать такие простые конструкции :)daniluk4000 Автор
Тут нужно много вдаваться в детали, я этого сделать не могу, не суть важно: «задача поставлена, её надо решить» (с)
Долго объяснять, да, но в общем пришли не к горизонтальной сортировке, а к снижению кол-ва столбцов, чтобы не показывать то, что не нужно. Это на усмотрение пользователя таблицы, потому что у заказчика есть кейсы, когда ему нужно видеть всё
Всё немного сложнее, чем кажется. Дело не в том, что я этого не знаю — я из-за ограничений Vuetify уже писал функциональный компонент с интересной логикой для вывода пунктов меню в List Vuetify (долго рассказывать, но там даже ишью была на гитхабе не решенная с просьбой добавить то, что я делал «вручную» функциональным)
А компоненты всплывающих окон и так лежат отдельно, просто слушают не стор, разумеется, стор добавить не было бы проблемой. Проблема в рендере всяких VButton, VIcon и остальных компонентов, из-за чего это всё и пихалось под плашку «загрузка...» в первом случае и v-lazy во втором. Ни одна из этих кнопок не является инлайн редактированием, а триггерит глобальное (вне элемента) всплывающее окно
Плюс там есть штуки типа множественных действий, которые вместо окна действиями создают формочку или галочку. Задача большая, сроки сжатые. Проблема, собственно, именно в рендере тяжелых компонентов. Ближе всего тут решение Almatyn в комменте неподалеку, когда нужно по нажатию подгружать компонент редактора с кнопочками действия вместо чего-то очень простого, но заказчика такой вариант не устраивает.
Kezhich
4й вариант недавно реализовывал. Тоже жирные таблицы с кучей кастомизации в слотах и тултипами. Создал враппер, закинул в него таблицу и компонент на базе intersection observer и v-progress-linear, который при пересечении эмитил эвент и шла подгрузка следующего чанка данных. Вроде полет нормальный. Ещё и в vue-scroll все это обернул, но таблица должна быть фиксированной высоты (хотя бы в абсолютных величинах).
daniluk4000 Автор
А без него похоже вообще не реализуется ничего в нашем случае =)