Улучшение производительности vue приложения

от автора

У нас в TeamHood есть wiki. Там собралась коллекция рекоммендаций, в том числе, по улучшению производительности тяжелого фронтенда на vue.js. Улучшать производительность понадобилось, потому что в силу специфики наши основные экраны не имеют пагинации. Есть клиенты, у которых на одной kanban/gantt доске больше тысячи вот таких вот карточек, все это должно работать без лагов.

В статье разобрано несколько редко упоминаемых техник из нашей wiki, которые помогут сократить излишний рендеринг компонентов и улучшить производительность.

Примеры статьи собраны в отдельном репозитории. Это vue2 приложение, хотя все проверено и продолжает быть актуальным для vue3. Пока что не вся экосистема vue3 production-ready. В vuex4 утекает память, исследовать соответствующие оптимизации там пока бессмысленно (что обнадеживает, затраты памяти там в разы меньше чем в vue2+vuex3). Примеры написаны на минимальном простейшем javascript, было искушение воткнуть vue-class-component, typescript, typed-vuex и остальную кухню реального проекта, но удержался.

1. (Deep) Object Watchers

Правило — не использовать deep модификатор, использовать watch только для примитивных типов. Расмотрим пример. Некий массив items приходит с сервера, сохраняется в vuex store, отрисовывается, возле каждого item есть чекбокс. Свойство isChecked относится к интерфейсу, хранится отдельно от item, однако есть getter, который собирает их вместе:

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

Допустим, items могут быть отсортированы, будем сохранять порядок. Что-то вроде:

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

Здесь переключение чекбокса у любого item вызывается излишнее срабатывание сохранение порядка. Конструирование новых объектов — настолько естественный процесс, что даже в этом тривиальном примере мы делаем это дважды. Изменение checkedItemIds вызвает пересоздание массива extendedItems (и пересоздание каждого элемента этого массива), затем идет пересоздание объекта itemIds. Это может казаться контра-интуитивным, ведь создается массив, состоящий из тех же самых элементов в том же самом порядке. Однако, это природа javascript, [1,2,3] != [1,2,3]. Демо здесь: example1.

Решение — полный отказ от использования watcher для объектов и массивов. Для каждого сложного watcher создается отдельный computed примитивного типа. Например, если требуется отслеживать свойства {id, title, userId}в массиве items, можно отслеживать строку:

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

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

Это что-то уровня.. (был у меня и такой проект).. в компоненте не срабатывала реактивность, и вместо того, чтобы найти ошибку, был повешен $emit(‘reinit’), по которому родительский компонент убивал данный и создавал его заново в $nextTick. Все это забавно мигало.

2. Ограничение реактивности через Object.freeze

Использование Object.freeze на проекте TeamHood сократило потребление памяти в 2 раза. Однако оно больше относится к моему второму основному проекту, StarBright, где используется nuxt и серверный рендеринг. Nuxt подразумевает, что некоторые запросы будут отрабатываться на сервере заранее. Ответы сохраняются в vuex store (и потом используются на клиенте). Таким образом, всю логику работы с запросами и кешированием данных удобнее держать в vuex. Компонент делает this.$store.dispatch(‘fetch’, …), а vuex отдает кеш или делает запрос.

Следовательно, в vuex может содержаться большой объем данных. Например, пользователь вводил адрес, autocomplete загрузил массив городов, который был закеширован в store с целью избежать повторной загрузки. Данные статичны, однако 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))     }   } }

Пример здесь: example2. Замечу, что замерять расход памяти нужно на build-версии (не в development).

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

Иногда это пропускают в документации. Функциональные геттеры не кешируются. Вот это будет делать 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] } }

Пример здесь: example3.

4. Грамотное распределение на компоненты

Компоненты — ключевая часть экосистемы vue. Понимание жизненного цикла и критериев обновления (shouldComponentUpdate) необходимо для строительства эффективного приложения. Первое знакомство с компонентами проходит на интуитивно-логическом уровне: есть список каких-то однотипных контейнеров, тогда наверное для контейнера лучше сделать отдельный компонент.

Однако, кроме смыслового значения, компоненты — это мощный механизм, дающий контроль над гранулярностью обновлений, это штука, напрямую влияющая на производительность. Рассмотрим такой (ужасный) код:

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

Пример работы здесь: example4p1. Обновление любого свойства одного item вызывает обновление всех компонентов <Item>. Причина в том, что технически <Item> ссылается на объект extendedItemsByIds, который пересоздается заново при изменении любого свойства любого item.

Каждый vue компонент — это функция, которая отдает virtual DOM и кеширует его (memoization). Входные аргументы функции — зависимости — отлеживаются на этапе dry run и состоят из ссылок на переменные в props и глобальные объекты типа $store. Если аргумент — поэлементно равный предыдущему новый объект, кеширование не срабатывает.

Изначальная структура данных в store неудачная. Мы начали применять normalizr подход, но не доделали. Удобнее хранить сортировку в отдельном массиве ids. Так же, вместо копирования всех свойств объекта в getter, лучше просто хранить ссылку на весь объект. Например, так:

// 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)     }   } }  // Item.vue: computed: {   item () {     return this.$store.state.itemsByIds[this.itemId]   },   isChecked () {     return this.$store.state.checkedIds.includes(this.itemId)   } }

Заметим, что мутация renameItem не перестраивает state.itemsByIds, а обновляет только один элемент оттуда. Поэтому rename работает правильно: example4p2. Однако isChecked все равно ссылается на весь state.checkedIds (ищет там значение), поэтому чекбокс по-прежнему вызывает полный ререндеринг всех <Item>.

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

<Item   v-for="extendedItem in extendedItems"   :key="extendedItem.id"   :item="extendedItem.item"   :is-checked="extendedItem.isChecked" />

Пример здесь: example4p3.

5. Применение IntersectionObserver

Отрисовка большого DOM-дерева тормозит сама по себе. Мы применяем несколько техник для оптимизации. Например, на gantt схемах размеры и положения блоков заранее расчитаны, поэтому известно, что попадает в viewport. Невидимые элементы не отрисовываются. В других случаях размеры заранее неизвестны, тогда можно применить этот простой прием с intersection observer. В vuetify есть v-intersect директива, которая работает из коробки, однако она создает отдельный IntersectionObserver на каждый свой биндинг, поэтому не подходит для случая, когда объектов много.

Вот пример, который будем оптимизировать: example5. Там 100 элементов (на экране помещается 10), в каждом мигает тяжелая картинка, замеряется задержка между реальным миганием и расчетным. Создадим один экземпляр IntersectionObserver и пробросим его через директиву во все узлы, которые он будет отслеживать.Все, что нужно от директивы — зарегистрироваться в переданном 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:

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

Ссылки

ссылка на оригинал статьи https://habr.com/ru/post/543298/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *