← Курс/Оптимизация Vue приложений#240 из 257+30 XP

Оптимизация Vue приложений

v-memo

Директива v-memo позволяет пропустить обновление поддерева если переданный массив зависимостей не изменился — аналог React.memo:

<!-- Этот элемент обновится только если item.id или selected изменился -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, selected === item.id]">
  <p>{{ item.name }}</p>
  <p>{{ item.description }}</p>
  <!-- ...много тяжёлого контента... -->
</div>

v-memo="[]" полностью заморозит элемент — он никогда не будет обновляться.

shallowRef и shallowReactive

Обычные ref и reactive делают объект реактивным рекурсивно — каждое вложенное свойство отслеживается. Для больших объектов это дорого:

import { shallowRef, shallowReactive, triggerRef } from 'vue'

// Только верхний уровень реактивен
const bigList = shallowRef([])

// Мутация вложенных данных не триггерит обновления...
bigList.value.push({ id: 1 })

// ...нужно явно сообщить Vue об изменении
triggerRef(bigList)

// shallowReactive: только собственные свойства реактивны
const state = shallowReactive({
  count: 0,        // реактивно
  user: { name: 'Alice' }  // НЕ реактивно внутри
})

Виртуализация списков

Рендерить 10 000 элементов одновременно — катастрофа для производительности. **Виртуализация** рендерит только видимые элементы:

// С vue-virtual-scroller или @tanstack/virtual
import { useVirtualList } from '@vueuse/core'

const { list, containerProps, wrapperProps } = useVirtualList(items, {
  itemHeight: 50,  // высота каждого элемента
})
<div v-bind="containerProps" style="height: 400px; overflow-y: auto;">
  <div v-bind="wrapperProps">
    <div v-for="item in list" :key="item.index" style="height: 50px">
      {{ item.data.name }}
    </div>
  </div>
</div>

Lazy components (defineAsyncComponent)

import { defineAsyncComponent } from 'vue'

// Компонент загружается только когда он нужен
const HeavyChart = defineAsyncComponent(() =>
  import('./components/HeavyChart.vue')
)

// С дополнительными опциями
const AsyncComponent = defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,  // пока грузится
  errorComponent: ErrorMessage,      // при ошибке
  delay: 200,          // задержка перед показом LoadingSpinner
  timeout: 10000,      // таймаут загрузки
})

keep-alive

Кэширует экземпляры компонентов вместо их уничтожения:

<!-- Вкладки не теряют состояние при переключении -->
<keep-alive :include="['TabA', 'TabB']" :max="5">
  <component :is="currentTab" />
</keep-alive>
// Хуки в кэшированных компонентах
import { onActivated, onDeactivated } from 'vue'

onActivated(() => {
  // Вызывается при "показе" кэшированного компонента
  refreshData()
})

onDeactivated(() => {
  // Вызывается при "скрытии" (не unmount!)
  pauseVideo()
})

Bundle splitting

// vite.config.js — разбивка на чанки
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['vue', 'vue-router', 'pinia'],
          'charts': ['chart.js', 'd3'],
        }
      }
    }
  }
})

Чеклист оптимизации

  • Используй v-memo для тяжёлых списков
  • shallowRef для больших неизменяемых структур
  • defineAsyncComponent для тяжёлых компонентов
  • <keep-alive> для часто переключаемых вкладок
  • Виртуализация для списков > 100 элементов
  • v-once для статичного контента
  • Анализ бандла: vite-bundle-visualizer
  • Примеры

    Мемоизация и виртуальный список (windowing) — ключевые техники оптимизации, аналог того, что делает Vue внутри

    // ============================================
    // 1. Мемоизация — кэширование результатов функции
    // ============================================
    
    function memoize(fn) {
      const cache = new Map()
    
      return function(...args) {
        const key = JSON.stringify(args)
    
        if (cache.has(key)) {
          console.log(`[memo] cache HIT для ${key}`)
          return cache.get(key)
        }
    
        console.log(`[memo] cache MISS для ${key} — вычисляем...`)
        const result = fn.apply(this, args)
        cache.set(key, result)
        return result
      }
    }
    
    // Дорогая функция (симуляция)
    let callCount = 0
    const expensiveCalc = memoize((n) => {
      callCount++
      // Симулируем тяжёлые вычисления
      let result = 0
      for (let i = 0; i < n * 1000; i++) result += Math.sqrt(i)
      return Math.round(result)
    })
    
    console.log('=== Мемоизация ===')
    console.log(expensiveCalc(100))  // вычисляем
    console.log(expensiveCalc(100))  // из кэша
    console.log(expensiveCalc(200))  // вычисляем
    console.log(expensiveCalc(100))  // из кэша
    console.log(`Реальных вызовов: ${callCount} (вместо 4)`)
    
    // ============================================
    // 2. Виртуальный список (windowing)
    // ============================================
    
    class VirtualList {
      constructor(itemHeight = 50, containerHeight = 300) {
        this.items = []
        this.itemHeight = itemHeight
        this.containerHeight = containerHeight
        this.scrollTop = 0
        this.overscan = 3  // рендерим N лишних элементов сверху/снизу
      }
    
      setItems(arr) {
        this.items = arr
        return this
      }
    
      // Общая высота всего списка (для скроллбара)
      get totalHeight() {
        return this.items.length * this.itemHeight
      }
    
      // Установить позицию прокрутки
      setScrollTop(scrollTop) {
        this.scrollTop = Math.max(0, scrollTop)
        return this
      }
    
      // Получить диапазон видимых элементов
      getVisibleRange() {
        const startIndex = Math.floor(this.scrollTop / this.itemHeight)
        const visibleCount = Math.ceil(this.containerHeight / this.itemHeight)
        const endIndex = startIndex + visibleCount
    
        // С overscan — рендерим немного больше для плавности
        return {
          start: Math.max(0, startIndex - this.overscan),
          end: Math.min(this.items.length - 1, endIndex + this.overscan)
        }
      }
    
      // Получить видимые элементы с их позициями
      getVisibleItems() {
        const { start, end } = this.getVisibleRange()
        const result = []
    
        for (let i = start; i <= end; i++) {
          result.push({
            index: i,
            data: this.items[i],
            offsetY: i * this.itemHeight,  // позиция для CSS transform
          })
        }
    
        return result
      }
    
      // Статистика рендера
      stats() {
        const visible = this.getVisibleItems()
        return {
          total: this.items.length,
          rendered: visible.length,
          savings: `${((1 - visible.length / this.items.length) * 100).toFixed(1)}%`,
          range: this.getVisibleRange()
        }
      }
    }
    
    // --- Демонстрация виртуального списка ---
    console.log('\n=== Виртуальный список ===')
    
    const items = Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      name: `Элемент #${i}`,
      value: Math.random().toFixed(2)
    }))
    
    const vList = new VirtualList(50, 300)
    vList.setItems(items)
    
    // Прокрутка в начало
    vList.setScrollTop(0)
    let stats = vList.stats()
    console.log('Scroll: 0px', stats)
    console.log('Видимые элементы:', vList.getVisibleItems().slice(0, 3).map(i => i.data.name))
    
    // Прокрутка в середину
    vList.setScrollTop(25000)
    stats = vList.stats()
    console.log('\nScroll: 25000px', stats)
    console.log('Видимые элементы:', vList.getVisibleItems().slice(0, 3).map(i => i.data.name))
    
    // Прокрутка в конец
    vList.setScrollTop(vList.totalHeight)
    stats = vList.stats()
    console.log('\nScroll: end', stats)