Одна из причин тормозов vue приложения - излишний рендеринг компонентов. Разбираемся, с чем это обычно связано в vue2 и vue3, а затем применяем одну простую технику, которая лечит все эти случаи и не только их. Данная техника уже пол года хорошо работает в продакшене.
Примеры этой статьи собраны в двух репозиториях (один для vue2, другой для vue3), с идентичным исходным кодом.
Выявление рендеринга
Собственно, рендеринг компонента - это вызов функции render, которая отдает виртуальный dom. Для однофайловых компонентов с шаблоном функция render создается на этапе компиляции. Каждый раз при рендеринге вызывается хук updated. Простейший способ отследить рендеринг - использовать этот хук. Начнем тестирование на примере такого компонента:
<template> <div> Dummy: {{ showProp1 ? prop1 : 'prop1 is hidden' }} </div> </template> <script> export default { props: { prop1: Object, showProp1: Boolean }, updated () { console.log('Dummy updated') } } </script>
Шаблон этого компонента компилируется в такую функцию render:
render(h) { return h('div', [ `Dummy: #{this.showProp1 ? this.prop1 : 'prop1 is hidden'}` ]) }
Здесь можно поиграть с онлайн-компилятором. В vue2 данная функция работает как computed. На этапе dry run определяется дерево зависимостей. Если showProp1=true, рендеринг запускается при изменении showProp1 и prop1, в другом случае - не зависит от prop1. Для vue3 ситуация немного иная, рендеринг запускается при изменении prop1 даже если showProp1=false.
Пример 1 (+vue2, +vue3)
<template> <div> <button @click="counter++"> {{ counter }} </button> <Dummy :prop1="{a: 1}" /> </div> </template> <script> export default { data () { return { counter: 0 } } } </script>
Интуиция говорит, что при изменении counter компонент Dummy не должен обновляться, ведь <Dummy :prop1="{a: 1}" /> не зависит от counter. В данном случае это так и есть, в vue2 и vue3.
Пример 2 (-vue2, +vue3)
Пусть теперь prop1 отрисовывается в Dummy, для этого добавим флаг show-prop1:
<Dummy :prop1="{a: 1}" show-prop1 />
Тест для vue2 показывает, что теперь каждое изменение counter вызывает рендеринг Dummy. Рендер-функция данного компонента выглядит следующим образом:
render(h) { return h('div', {}, [ h('button', {on: {click: ...}}, [this.counter]), h(Dummy, {props: {prop1: {a: 1}, showProp1: true}}) ]) }
Эта функция запускается при изменении counter для того, чтобы отрисовать новое значение на кнопке. При этом в компонент Dummy создается и отправляется новый объект {a: 1}. Он такой же как старый, но Dummy не сравнивает объекты поэлементно. {a: 1} !== {a: 1}, рендеринг Dummy теперь зависит от prop1, поэтому Dummy тоже запускается. Данный пример работает в vue3 правильно.
Пример 3 (+vue2, -vue3)
Добавим немного динамики в prop1:
<Dummy :prop1="{a: counter ? 1 : 0}" />
Как и в первом примере, vue2 работает правильно, поскольку prop1 не используется в рендер-функции Dummy. Однако теперь лажает vue3. Даже если обернуть каждое свойство, отправляемое в Dummy, в свой computed, изменение counter пересоздает объект {a: counter ? 1 : 0}, запускается рендеринг.
Пример 4 (-vue2, -vue3)
<Dummy :prop1="{a: counter ? 1 : 0}" show-prop1 />
Работает неправильно в vue2 по той же причине, что и пример 2. Работает неправильно в vue3 по той же причине, что и пример 3.
Пример 5: Массивы в props
Надеюсь, что предыдущие примеры хорошо объясняют ситуацию. Но они слишком синтетические, можно сказать сам идиот, нечего параметры передавать объектами, создаваемыми налету в шаблоне. Рассмотрим реальный пример. Юзеры связаны с тегами через many-to-many. Хотим вывести список юзеров и у каждого подписать его теги. Пусть все хранится в красивом нормализованном виде:
export interface IState { userIds: string[] users: { [key: string]: IUser }, tags: { [key: string]: ITag }, userTags: {userId: string; tagId: string}[] }
Напишем геттер, который собирает все как надо:
export const getters = { usersWithTags (state) { return state.userIds.map(userId => ({ id: userId, user: state.users[userId], tags: userTags .filter(userTag => userTag.userId === userId) .map(userTag => state.tags[userTag.tagId]) })) } }
Каждый раз, когда запускается getter, он создает новый массив, состоящий из новых объектов, у которых есть свойство tags, являющееся новым массивом.
Сначала выведем список пользователей, где у каждого покажем только первый тег:
<UserWithFirstTag v-for="usersWithTags in usersWithTags" :key="usersWithTags.id" :user="usersWithTags.user" :tag="usersWithTags.tags[0]" />
Это работает правильно в vue2 и vue3. При создании новой связи между юзером и тегом геттер перестраивается, однако его части, которые уходят в компонент UserWithFirstTag, являются теми же объектами, что и раньше. Поэтому излишнего рендеринга компонентов UserWithFirstTag не происходит.
Теперь выведем у каждого пользователя список всех его тегов, то есть отправим в компонент массив, тот самый, который каждый раз новый при каждой перестройке usersWithTags:
<UserWithTags v-for="usersWithTags in usersWithTags" :key="usersWithTags.id" :user="usersWithTags.user" :tags="usersWithTags.tags" />
Теперь при создании новой связи user<->tag происходит рендеринг всех компонентов UserWithTags, в vue2 и в vue3. Как это можно исправить:
JSON.stringify наше все. Выглядит не очень, но всегда работает. До недавнего времени критичные места системы у нас буквально пестрели JSON.stringify/parse. Некоторые геттеры сразу отдавали stringify, потому что было известно, все равно все будет превращено в примитивные типы.
Приводить к примитивным типам, но тут нужно быть осторожным. Например, можно отправлять строку
userTags.filter(userTag => userTag.userId === userId).join(','), а затем в UsersWithTags парсить строку и извлекать теги из state.tags. Тогда не будет лишнего рендеринга при создании новой связи user<->tag. Однако тогда любое изменение любого тега (переименовали тег, добавили новый, итд) будет вызывать рендеринг всех UsersWithTags даже если измененный тег в нем не используется. Причина та же - ссылка на state.tags в рендер-функции компонента UsersWithTags.Можно передавать < :first-tag=.., :second-tag=".., :third-tag=".. >, но это совсем по-уродски.
Можно хранить копию массива в переменной и добавить watcher на геттер, который будет сравнивать старый и новый массив и обновлять копию только если есть изменения. Минус в том, что для каждого объектного параметра нужно заводить свою переменную и писать много кода.
И, наконец, можно универсально собирать новые объекты из кусков старых с помощью пары простых функций.
Selective Object Reuse
Давайте в геттере перед тем, как отдавать результат (новый объект), сохраним ссылку на него. Тогда при следующем вызове того же геттера можно взять старый объект по сохраненной ссылке и сравнить. В нашем случае мы будем сравнивать два массива вида {id: string; user: IUser, tags: ITag[]}[]. Допустим, создалась новая связь user<->tag. Тогда при сравнении старого и нового геттеров user и tag будут теми же самими объектами, что и раньше, и их не надо сравнивать поэлементно (т.е. это быстрее чем полностью рекурсивное сравнение типа isEqual из lodash):
function entriesAreEqual (entry1, entry2) { if (entry1 === entry2) { return true } if (!isObject(entry1) || !isObject(entry2)) { return false } const keys1 = Object.keys(entry1) const keys2 = Object.keys(entry2) if (keys1.length !== keys2.length) { return false } return !keys1.some((key1) => { if (!Object.prototype.hasOwnProperty.call(entry2, key1)) { return true } return !entriesAreEqual(entry1[key1], entry2[key1]) }) }
Если объекты разные, но состоят из одинаковых элементов (на первом уровне, без рекурсии, не важно, являются ли одним и тем же объектом, либо же равны как примитивные типы), то заменяем новый объект на старый:
function updateEntry (newEntry, oldEntry) { if (newEntry !== oldEntry && isObject(newEntry) && isObject(oldEntry)) { const keys = Object.keys(newEntry) keys.forEach((key) => { if (Object.prototype.hasOwnProperty.call(oldEntry, key) && isObject(newEntry[key]) && isObject(oldEntry[key])) { if (entriesAreEqual(newEntry[key], oldEntry[key])) { newEntry[key] = oldEntry[key] } else { updateEntry(newEntry[key], oldEntry[key]) } } }) } return newEntry }
Осталось обернуть эти функции в какой-нибудь класс, и получится selective-object-reuse.
Если теперь взять геттер из 5-ого примера и обернуть результат в SelectiveObjectReuse, лишний рендеринг пропадет в vue2 и vue3.
Так же враппер можно использовать прямо в шаблоне или в computed, например из примера 4:
<template> <div> <button @click="counter++"> {{ counter }} </button> <Dummy :prop1="sor.wrap({a: counter ? 1 : 0})" show-prop1 /> </div> </template> <script> import SelectiveObjectReuse from 'selective-object-reuse' export default { data () { return { counter: 0, sor: new SelectiveObjectReuse() } } } </script>
Работает правильно в vue2 и vue3.
Минусы SelectiveObjectReuse
SelectiveObjectReuse - это продвинутая техника, которая хорошо себя зарекомендовала в решении узкой задачи. Собственно, какое-то время у меня не было других способов избежать избыточного рендеринга кроме уродливого JSON.stringify всего и вся. Тем не менее, этот враппер нельзя применять бездумно, будет неправильно оборачивать все object-like свойства в vue на уровне движка.
Враппер работает только на read, т.е. применяется в computed и getters. Не нужно брать объект прямо из data(), оборачивать, а затем менять его свойства.
Враппер работает для примитивных объектов. Например, vue3 оборачивает computed в proxy. Нужно применять враппер до proxy.
Нужно следить, чтобы в памяти не оставалось ссылок на просроченные объекты. Для этого в библиотеке есть метод dispose.
