Одна из причин тормозов 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.