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

Spread the love

Перевод: Alexey Kuznetsov5 Advanced Tips for Vue Performance

В последние годы я в основном работаю над проектом TeamHood.com. Мы приложили много усилий для оптимизации его интерфейса созданного на Vue. Это связано с тем, что на наших экранах нет разбивки на страницы, а у некоторых клиентов на одной доске kanban/Gantt отображается более тысячи карточек.

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

Специально для этой статьи создано два репозитория: один для vue2, другой для vue3:

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

Простое правило – не используйте модификатор deep в ватчерах и вообще не используйте ватчеры (watch) для переменных не примитивного типа. Давайте рассмотрим массив элементов в хранилище vuex, где каждый элемент нужно контролировать. Давайте выделим свойство 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) 
    }
  }
}

Тогда установка / снятие отметки с элемента приведет к ненужному запуску ватчера itemIds. Это происходит потому, что каждый раз, когда элемент проверяется, геттер будет создавать новый объект для каждого элемента, что вызовет вычисление идентификаторов элементов для перестроения нового массива, и, несмотря на то, что этот массив имеет те же значения в том же порядке, [1, 2, 3] ! == [1, 2, 3] в javascript. В итоге каждый раз запустится ватчер.

Решение состоит в том, чтобы избегать использования ватчеров для переменных объектного типа. Каждый раз, когда нам нужен какой-то нетривиальный ватчер, мы должны создавать отдельный примитивный тип, вычисляемый (computed) специально для этого случая. Например, если нам нужно просмотреть массив элементов со свойствами {id, name, userId}, мы можем просмотреть следующую строку:

computed: {
  itemsTrigger () { 
    return JSON.stringify(items.map(item => ({ 
      id: item.id, 
      title: item.title, 
      userId: item.userId 
    }))) 
  }
},
watch: {
  itemsTrigger () {
    // Don't use JSON.parse here - it's cheaper to use initial this.item array;
  }
}

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

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

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

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

// Instead of:
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
    }
  }
}

// Do this:
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) {
      // Here it's not possible to update item.completed = true
      // because the object is frozen. 
      // We need to recreate the entire object.
      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)
}
...
// Some <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
  }, {})
}
// Some <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. Если аргумент – это объект, который перестраивается после обновления, кеширование не работает.

Проблема в том что наша первоначальная структура store плохая. Мы начали использовать подход normalizr, разделенный элемент и свойство isChecked, но мы еще не закончили. Наш метод получения 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), но установка / снятие отметки с элемента по-прежнему вызывает повторную визуализацию каждого элемента. И, что удивительно, переупорядочение элементов работает в vue2, но не в vue3.

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

Upd. 2020–02–25: Еще один способ исправить повторную визуализацию, вызванную ссылкой на переменную области видимости в обработчике событий, – это указать все события, которые компонент может генерировать в новом свойстве vue3, называемом emits. Наш компонент ItemWithRenameById2 может генерировать 2 события – @set-checked и @rename – если мы добавим оба likeemits: [‘set-checked’, ‘rename’], переупорядочение элементов начнет работать правильно. Вот пример example4p3-with-emits для vue3.

Последнее, что нужно исправить, это вычисляемое свойство isChecked – в настоящее время он относится ко всему массиву $store.state.checkedIds (он пытается найти там значение). Проверка элемента изменяет этот массив, поэтому каждый <Item> должен повторять поиск. Это можно исправить, отправив isChecked в качестве boolean prop каждому компоненту <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 может быть большой и медленной сама по себе. Мы используем несколько методов для уменьшения размера DOM. Например, диаграмма Ганта вычисляет положение и размер каждого элемента, и можно легко пропустить элементы за пределами области просмотра. Есть одна простая уловка 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 и заменить тяжелую часть упрощенным placeholder. Но важно понимать, что инициализация сложных компонентов – это долгий процесс. Может случиться так, что страница будет задерживаться во время быстрой прокрутки, потому что последняя запускает слишком много тяжелых инициализаций. Практические эксперименты показывают, что переключение на уровне css выполняется быстро и легче. Мы можем просто использовать css display: none для каждого тяжелого SVG вне области просмотра, что значительно повысит производительность:

<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>

Links

Была ли вам полезна эта статья?
[3 / 5]

Spread the love
Подписаться
Уведомление о
guest
1 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Александр
Александр
2 лет назад

и вообще используйте ватчеры (watch) для переменных непримитивного типа.

В первой же части сразу ошибка перевода. Автор оригинала говорит “не используйте”.
Сразу стало страшно читать остальной перевод и я ушёл на оригинал.