☕ 5 продвинутых советов по повышению производительности Vue

Рассмотрим советы и рекомендации по оптимизации памяти и уменьшению повторного рендеринга, которые могут быть применены к любому приложению на vue2/vue3.

Перевод публикуется с сокращениями, автор оригинальной статьи Alexey Kuznetsov.

Есть два созданных специально для этой статьи репозитория (для vue2 и vue3):

(Deep) наблюдатели за объектами

Правило простое: не используйте глубокий модификатор и не следите за переменными отличного от примитивного типа. Рассмотрим некоторые элементы массива из хранилища vuex, каждый из которых может иметь статус checked. Сохраним отдельно связанное с клиентом свойство IsChecked. Предположим, что есть геттер, который объединяет данные элемента и свойство IsChecked:

export const state = () => ({
  items: [{ id: 1, name: 'First' }, { id: 2, name: 'Second' }],
  checkedItemIds: [1, 2]
})

export const getters = {
  extendedItems (state) {
    return state.items.map(item => ({
      ...item,
      isChecked: state.checkedItemIds.includes(item.id)
    }))
  }
}

Элементы могут быть переупорядочены, и полученный порядок должен быть отправлен в бекенд:

export default class ItemList extends Vue {
  computed: {
    extendedItems () { return this.$store.getters.extendedItems },
    itemIds () { return this.extendedItems.map(item => item.id) }
  },
  watch: {
    itemIds () {
      console.log('Saving new items order...', this.itemIds) 
    }
  }
}

Затем checking/unchecking элемента организует неправильное срабатывание вызова itemIds watcher. Это происходит, поскольку каждый раз, когда элемент становится checked, геттер создает новый объект для каждого элемента. По этой причине произойдет вычисление itemIds для пересоздания нового массива, несмотря на то, что он уже имеет те же значения в том же порядке: [1, 2, 3] !== [1, 2, 3].

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

Например, если нужно пронаблюдать за массивом элементов со свойствами {id, name, userId}, можно поступить следующим образом:

computed: {
  itemsTrigger () { 
    return JSON.stringify(items.map(item => ({ 
      id: item.id, 
      title: item.title, 
      userId: item.userId 
    }))) 
  }
},
watch: {
  itemsTrigger () {
    // Не используйте здесь JSON.parse 
            – дешевле использовать исходный массив this.item;
  }
}

Очевидно, что более точное условие для запуска наблюдателя дает более точный триггер. С этой точки зрения, глубокий наблюдатель хуже, чем наблюдатель обычных объектов. Использование глубокого модификатора – явный признак того, что разработчик не заботится об объектах, за которыми наблюдает.

Демонстрационные материалы для vue2 и vue3.

Ограничение реактивности с помощью Object.freeze

Этот совет эффективен для приложения vue2 и способен уменьшить использование памяти на 50%. Иногда vuex содержит много редко изменяющихся данных, особенно если там хранится кэш ответов api (актуально для приложений ssr). По умолчанию vue рекурсивно наблюдает за каждым свойством объекта. Иногда лучше потерять реактивность свойств объекта, сохранив вместо этого немного памяти:

// Вместо этого:
state: () => ({
  items: []
}),
mutations: {
  setItems (state, items) {
    state.items = items
  },
  markItemCompleted (state, itemId) {
    const item = state.items.find(item => item.id === itemId)
    if (item) {
      item.completed = true
    }
  }
}

// Делаем так:
state: () => ({
  items: []
}),
mutations: {
  setItems (state, items) {
    state.items = items.map(item => Object.freeze(item))
  },
  markItemCompleted (state, itemId) {
    const itemIndex = state.items.find(item => item.id === itemId)
    if (itemIndex !== -1) {
      // Здесь невозможно обновить item.completed = true
      // потому что объект заморожен. 
      // Нам нужно пересоздать весь объект.
      const newItem = {
        ...state.items[itemIndex],
        completed: true
      }
      state.items.splice(itemIndex, 1, Object.freeze(newItem))
    }
  }
}

Примеры для vue2 и vue3. Поскольку vue3 имеет другую систему реактивности, эффект такой оптимизации менее релевантен. Кроме того общее использование памяти в vue3 намного ниже, чем в vue2 (это 80 Мб для vue2 и 15 Мб для примеров vue3). К сожалению, у vuex4 есть проблемы с очисткой (вы можете увидеть это данном примере), но разработчики обещают исправить ситуацию.

Функциональные геттеры

Зачастую это упускается из виду в документации: функциональные геттеры не кэшируются. Этот код будет запускать state.items.find каждый раз, когда он вызывается:

// Vuex: 
getters: {
  itemById: (state) => (itemId) => state.items.find(item => item.id === itemId)
}
...
// Некоторый <Item :item-id="itemId" /> component:
computed: {
  item () { return this.$store.getters.itemById(this.itemId) }
}

Такой код будет создавать объект itemsByIds при первом вызове, а затем повторно использовать:

getters: {
  itemByIds: (state) => state.items.reduce((out, item) => {
    out[item.id] = item
    return out
  }, {})
}
// Некоторый <Item :item-id="itemId" /> component:
computed: {
  item () { return this.$store.getters.itemsByIds[this.itemId] }
}

Примеры для vue2 и vue3.

Распределение компонентов

Компонент – ключевая особенность всей экосистемы vue, относящаяся к производительности. Понимание жизненного цикла компонента и правил обновления имеет важное значение для построения эффективного приложения. Когда начинаем работать с vue, мы пытаемся распределить код по компонентам. Например, если есть список повторяющихся записей, то хорошо бы переместить связанный с записью код в отдельный компонент.

Распределение компонентов обеспечивает мощный механизм, дающий точный контроль над степенью детализации обновления. Это основная связанная с производительностью функция. Рассмотрим следующий код:

// Store:
export const getters = {
  extendedItems (state) {
    return state.items.map(item => ({
      ...item,
      isChecked: state.checkedItemIds.includes(item.id)
    }))
  },
  extendedItemsByIds (state, getters) {
    return getters.extendedItems.reduce((out, extendedItem) => {
      out[extendedItem.id] = extendedItem
      return out
    }, {})
  }
}

// App.vue:
<ItemById for="id in $store.state.ids" :key="id" :item-id="id />

// Item.vue:
<template>
  <div>{{ item.title }}</div>
</template>

<script>
export default {
  props: ['itemId'],
  computed: {
    item () { return this.$store.getters.extendedItemsByIds[this.itemId] }
  },
  updated () {
    console.count('Item updated')
  }
}
</script>

Это демо-версия: vue2, vue3. Попробуйте переименовать, отметить или изменить порядок элементов. Любое предназначенное только для одного элемента обновление приведет к повторному отображению других элементов в списке. Причина в том, что компонент <Item> ссылается на объект extendedItemsByIds, который перестраивается при изменении любого свойства элемента.

Каждый компонент vue – это функция, которая предоставляет некоторый виртуальный DOM и кэширует результат в зависимости от его аргументов. Список аргументов определяется на этапе запуска и состоит из реквизитов и некоторых переменных в $store. Если аргумент является объектом, который перестраивается после обновления, кэширование не работает.

В первоначальной структуре используется подход normalizr, а наш геттер extendedItems копирует свойства элемента вместо того, чтобы просто ссылаться на исходный объект. Давайте это исправим:

// Store:
export const state = () => ({
  ids: [],
  itemsByIds: {},
  checkedIds: []
})

export const getters = {
  extendedItems (state, getters) {
    return state.ids.map(id => ({
      id,
      item: state.itemsByIds[id],
      isChecked: state.checkedIds.includes(id)
    }))
  }
}

export const mutations = {
  renameItem (state, { id, title }) {
    const item = state.itemsByIds[id]
    if (item) {
      state.itemsByIds[id] = Object.freeze({
        ...item,
        title
      })
    }
  },
  setCheckedItemById (state, { id, isChecked }) {
    const index = state.checkedIds.indexOf(id)
    if (isChecked && index === -1) {
      state.checkedIds.push(id)
    } else if (!isChecked && index !== -1) {
      state.checkedIds.splice(index, 1)
    }
  }
}

// Items.vue:
<ItemWithRenameById2
  v-for="id in ids"
  :key="id"
  :item-id="id"
  @set-checked="setCheckedItemById({ id, isChecked: $event })"
  @rename="renameItem({ id, title: $event })"
/>


// Item.vue:
computed: {
  item () {
    return this.$store.state.itemsByIds[this.itemId]
  },
  isChecked () {
    return this.$store.state.checkedIds.includes(this.itemId)
  }
}

Этот код корректно работает для переименования элемента (примеры vue2, vue3), но checking/unchecking элемента по-прежнему приводит к повторному рендерингу каждого, а переупорядочение элементов работает в vue2, но не в vue3.

Последняя описанная ситуация – это предполагаемое поведение, но оно не задокументировано. Причина заключается в ссылке на scope-переменную (id) в обработчиках событий @set-checked и @rename. В vue3 scope-переменная в обработчике событий не может быть закэширована, что означает – каждое обновление создает новую функцию события, вызывающую обновление компонента. Это можно исправить, отправив подготовленное событие, содержащее scope-переменную (id в нашем случае). Действие демонстрируется в примере для vue3.

Еще один способ исправить вызванный ссылкой на scope-переменную рендеринг – указать все события, которые компонент может обработать в новом свойстве vue3, называемом emits. Наш компонент ItemWithRenameById2 может работать с двумя событиями: @set-checked и @rename. Если добавить оба emits: ['set-checked', 'rename'], то переупорядочение элементов начнет работать правильно. Вот подтверждение для vue3.

Последнее, что нужно исправить – это IsChecked. Он ссылается на весь массив $store.state.checkedIds (пытается найти там значение). Состояние элемента «checking» изменяет этот массив, поэтому каждый <Item> должен повторить поиск. Это можно исправить, отправив IsChecked как логическое свойство каждому компоненту <Item> вместо поиска значения внутри:

// Items.vue:
<Item
  v-for="extendedItem in extendedItems"
  :key="extendedItem.id"
  :item="extendedItem.item"
  :is-checked="extendedItem.isChecked"
  @set-checked="setCheckedItemById"
  @rename="renameItem"
/>

Примеры корректно работающего решения для vue2 и vue3.

Директива IntersectionObserver

Иногда DOM сам по себе большой и медленный. Можно использовать несколько методов для уменьшения его размера. Например, диаграмма Ганта вычисляет положение и размер каждого элемента, и легко пропустить элементы за пределами viewport-а. Есть один простой трюк IntersectionObserver для случая, когда размеры неизвестны. Кстати, vuetify имеет директиву v-intersect из коробки, но последняя создает отдельный экземпляр IntersectionObserver, поэтому она не подходит для случая, когда нужно мониторить много узлов.

Давайте рассмотрим примеры (vue2, vue3), которые мы собираемся оптимизировать. Есть 100 записей (только 10 отображены на экране). Каждая содержит тяжелый svg, который мигает каждые 500 мс. Вычислим задержку между расчетным и реальным миганием, создадим один экземпляр IntersectionObserver и пробросим его на каждый узел, который необходимо наблюдать:

export default {
  inserted (el, { value: observer }) {
    if (observer instanceof IntersectionObserver) {
      observer.observe(el)
    }
    el._intersectionObserver = observer
  },
  update (el, { value: newObserver }) {
    const oldObserver = el._intersectionObserver
    const isOldObserver = oldObserver instanceof IntersectionObserver
    const isNewObserver = newObserver instanceof IntersectionObserver
    if (!isOldObserver && !isNewObserver) || (isOldObserver && (oldObserver === newObserver)) {
      return false
    }
    if (isOldObserver) {
      oldObserver.unobserve(el)
      el._intersectionObserver = undefined
    }
    if (isNewObserver) {
      newObserver.observe(el)
      el._intersectionObserver = newObserver
    }
  },
  unbind (el) {
    if (el._intersectionObserver instanceof IntersectionObserver) {
      el._intersectionObserver.unobserve(el)
    }
    el._intersectionObserver = undefined
  }
}

Теперь мы знаем, какие записи не видны – нам нужно их упростить. Можно определить некоторую переменную в vue и заменить тяжелую часть упрощенным заполнителем. Важно понимать, что инициализация сложных компонентов – та еще задачка. Может случиться, что страница будет лагать во время быстрой прокрутки, потому что делается слишком много тяжелых инициализаций. Практические эксперименты говорят, что переключение на уровне css происходит быстро и незаметно. Можно просто использовать css display: none для каждого тяжелого svg за пределами viewport, что увеличит производительность:

<template>
  <div 
    v-for="i in 100" 
    :key="i" 
    v-node-intersect="intersectionObserver"
    class="rr-intersectionable"
  >
    <Heavy />
  </div>
</template>

<script>
export default {
  data () {
    return {
      intersectionObserver: new IntersectionObserver(this.handleIntersections)
    }
  },
  methods: {
    handleIntersections (entries) {
      entries.forEach((entry) => {
        const className = 'rr-intersectionable--invisible'
        if (entry.isIntersecting) {
          entry.target.classList.remove(className)
        } else {
          entry.target.classList.add(className)
        }
      })
    }
  }
}
</script>

<style>
.rr-intersectionable--invisible .rr-heavy-part
  display: none
</style>

Заключение

Мы рассмотрели методы оптимизации работы кода в приложениях Vue, которые можно и нужно применять в ваших текущих проектах. Правильная работа с памятью при использовании компонентов и увесистых элементах viewport-а значительно улучшит эффективность использования приложения и поднимет UX. Удачи в обучении!

Дополнительные материалы:

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

eFusion
01 марта 2020

ТОП-15 книг по JavaScript: от новичка до профессионала

В этом посте мы собрали переведённые на русский язык книги по JavaScript – ...
admin
10 июня 2018

Лайфхак: в какой последовательности изучать JavaScript

Огромный инструментарий JS и тонны материалов по нему. С чего начать? Расск...