Pull to refresh

Улучшаем производительность vue с помощью selective-object-reuse

Reading time6 min
Views4.7K

Одна из причин тормозов 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. Как это можно исправить:

  1. JSON.stringify наше все. Выглядит не очень, но всегда работает. До недавнего времени критичные места системы у нас буквально пестрели JSON.stringify/parse. Некоторые геттеры сразу отдавали stringify, потому что было известно, все равно все будет превращено в примитивные типы.

  2. Приводить к примитивным типам, но тут нужно быть осторожным. Например, можно отправлять строку userTags.filter(userTag => userTag.userId === userId).join(','), а затем в UsersWithTags парсить строку и извлекать теги из state.tags. Тогда не будет лишнего рендеринга при создании новой связи user<->tag. Однако тогда любое изменение любого тега (переименовали тег, добавили новый, итд) будет вызывать рендеринг всех UsersWithTags даже если измененный тег в нем не используется. Причина та же - ссылка на state.tags в рендер-функции компонента UsersWithTags.

  3. Можно передавать < :first-tag=.., :second-tag=".., :third-tag=".. >, но это совсем по-уродски.

  4. Можно хранить копию массива в переменной и добавить watcher на геттер, который будет сравнивать старый и новый массив и обновлять копию только если есть изменения. Минус в том, что для каждого объектного параметра нужно заводить свою переменную и писать много кода.

  5. И, наконец, можно универсально собирать новые объекты из кусков старых с помощью пары простых функций.

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 на уровне движка.

  1. Враппер работает только на read, т.е. применяется в computed и getters. Не нужно брать объект прямо из data(), оборачивать, а затем менять его свойства.

  2. Враппер работает для примитивных объектов. Например, vue3 оборачивает computed в proxy. Нужно применять враппер до proxy.

  3. Нужно следить, чтобы в памяти не оставалось ссылок на просроченные объекты. Для этого в библиотеке есть метод dispose.

Tags:
Hubs:
Total votes 3: ↑3 and ↓0+3
Comments2

Articles