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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Одна из причин тормозов vue приложения - излишний рендеринг компонентов. Разбираемся, с чем это обычно связано в vue2 и vue3, а затем применяем одну простую технику, которая лечит все эти случаи и не только их. Данная техника уже пол года хорошо работает в продакшене.

Примеры этой статьи собраны в двух репозиториях (один для 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.

Источник: https://habr.com/ru/post/587946/


Интересные статьи

Интересные статьи

Развитие радиотехники и средств перехвата чужих трансляций шли всегда рука об руку. Подслушать, что происходит в эфире у противника, было разумным желанием любой армии. Но появление новых видов радиот...
Приветствую жителей Хабра! Задался тут вопросом, как можно обойтись без статического IP для экспериментов в домашних условиях. Наткнулся на вот эту статью. Если вы хотите развернуть...
Многие разработчики начинают разработку многопользовательского онлайн сервера на основе библиотеки socket.io. Эта библиотека позволяет очень просто реализовать обмен данными между клиетом...
Привет, Хабр! Сегодня мы расскажем, как решать с помощью Azure задачи, которые обычно требуют человеческого участия. Операторы тратят много времени, чтобы отвечать на одни и те же воп...
Качественные тесты обеспечивают скорость и стабильную работу мобильных приложений, но разнообразие устройств, операционных систем и их версий раздувает тестовые фермы, увеличивает сто...