← Курс/Глубокая оптимизация Vue приложений#248 из 257+35 XP

Глубокая оптимизация Vue приложений

v-memo: мемоизация поддеревьев

v-memo пропускает обновление DOM-поддерева если зависимости не изменились:

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

<!-- v-memo="[]" — никогда не обновляется (статический) -->
<HeavyStaticContent v-memo="[]" />

Идеален для тяжёлых списков, где большинство элементов не меняется при каждом ре-рендере.

markRaw: исключение из реактивности

import { ref, markRaw } from 'vue'

// Большой объект с 10 000 свойств — дорого делать реактивным
const hugeDataset = markRaw({
  points: new Float32Array(300000),
  metadata: { ... }
})

// ref хранит объект, но НЕ делает его реактивным
const data = ref(hugeDataset)

// Аналогично с компонентами в динамическом :is
const currentComponent = ref(markRaw(HeavyChartComponent))

Виртуальный скроллинг

Рендерить 10 000 строк — катастрофа. Решение: рендерить только видимые:

// С @tanstack/virtual или vue-virtual-scroller
import { useVirtualizer } from '@tanstack/vue-virtual'

const parentRef = ref(null)
const virtualizer = useVirtualizer({
  count: items.value.length,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 50,  // высота строки
})
<div ref="parentRef" style="height: 500px; overflow-y: auto">
  <div :style="{ height: virtualizer.getTotalSize() + 'px', position: 'relative' }">
    <div
      v-for="row in virtualizer.getVirtualItems()"
      :key="row.index"
      :style="{ position: 'absolute', top: row.start + 'px', height: row.size + 'px' }"
    >
      {{ items[row.index].name }}
    </div>
  </div>
</div>

Ленивые компоненты и разбивка бандла

// Разные стратегии ленивой загрузки:

// 1. Загрузка при первом рендере
const HeavyEditor = defineAsyncComponent(() => import('./HeavyEditor.vue'))

// 2. Предзагрузка при idle (после основной загрузки)
const prefetchEditor = () => import('./HeavyEditor.vue')
onMounted(() => {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(prefetchEditor)
  }
})

// 3. Загрузка при видимости (Intersection Observer)
const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
  if (isIntersecting) {
    prefetchEditor()
    stop()
  }
})

computed — кэширование вычислений

// ❌ Функция пересчитывается при каждом обращении
const expensiveValue = () => heavyComputation(data.value)

// ✅ computed кэширует результат — пересчёт только при изменении data
const expensiveValue = computed(() => heavyComputation(data.value))

// Множественные computed вместо одного большого
const filteredItems = computed(() => items.value.filter(...))
const sortedItems = computed(() => [...filteredItems.value].sort(...))
const paginatedItems = computed(() => sortedItems.value.slice(offset, offset + pageSize))

Анализ бандла

// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    visualizer({ open: true })  // открывает treemap бандла
  ],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-lib': ['element-plus'],
          'charts': ['chart.js', 'echarts'],
        }
      }
    }
  }
})

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

1. **Профилируй сначала** — Chrome DevTools, Vue DevTools Profiler

2. v-memo для тяжёлых v-for списков

3. markRaw для больших неизменяемых объектов

4. shallowRef / shallowReactive вместо глубокой реактивности

5. Виртуализация для списков > 100-200 элементов

6. defineAsyncComponent для компонентов > 20кб

7. v-once для полностью статичного контента

8. Анализ бандла + manualChunks

Примеры

Мемоизация, виртуализация и ленивая загрузка — три ключевые техники оптимизации на чистом JS

// Три техники оптимизации Vue приложений — реализуем на JS.

// =====================================================
// 1. Мемоизация (аналог v-memo / computed)
// =====================================================

function createMemoizedComputed(computeFn) {
  let cachedResult = undefined
  let cachedDeps = []
  let callCount = 0

  return {
    get(deps) {
      // Глубокое сравнение зависимостей
      const depsChanged = deps.some((dep, i) =>
        JSON.stringify(dep) !== JSON.stringify(cachedDeps[i])
      )

      if (depsChanged || cachedResult === undefined) {
        cachedDeps = [...deps]
        cachedResult = computeFn(...deps)
        callCount++
        console.log(`  [computed] пересчитан (вызовов: ${callCount})`)
      } else {
        console.log('  [computed] кэш (без пересчёта)')
      }
      return cachedResult
    },
    getCallCount: () => callCount
  }
}

// =====================================================
// 2. Виртуализация списка
// =====================================================

class VirtualScroller {
  constructor({ containerHeight = 400, itemHeight = 50, overscan = 3 } = {}) {
    this.containerHeight = containerHeight
    this.itemHeight = itemHeight
    this.overscan = overscan
    this.scrollTop = 0
    this.items = []
    this._renderCount = 0
  }

  setItems(items) { this.items = items }
  setScrollTop(top) { this.scrollTop = Math.max(0, top) }

  getVirtualItems() {
    const startIndex = Math.max(0,
      Math.floor(this.scrollTop / this.itemHeight) - this.overscan
    )
    const visibleCount = Math.ceil(this.containerHeight / this.itemHeight)
    const endIndex = Math.min(
      this.items.length - 1,
      Math.floor(this.scrollTop / this.itemHeight) + visibleCount + this.overscan
    )

    this._renderCount++
    const rendered = []
    for (let i = startIndex; i <= endIndex; i++) {
      rendered.push({
        index: i,
        data: this.items[i],
        offsetTop: i * this.itemHeight,
        height: this.itemHeight,
      })
    }
    return rendered
  }

  get totalHeight() { return this.items.length * this.itemHeight }

  stats(virtualItems) {
    const savings = ((1 - virtualItems.length / this.items.length) * 100).toFixed(1)
    return {
      total: this.items.length,
      rendered: virtualItems.length,
      savings: savings + '%',
      totalHeightPx: this.totalHeight + 'px',
    }
  }
}

// =====================================================
// 3. Ленивая загрузка с очередью приоритетов
// =====================================================

class LazyLoader {
  constructor({ concurrency = 2 } = {}) {
    this.queue = []
    this.running = 0
    this.concurrency = concurrency
    this.loaded = new Map()
  }

  // Добавить в очередь с приоритетом (меньше = выше)
  enqueue(key, loader, priority = 5) {
    if (this.loaded.has(key)) {
      console.log(`[LazyLoader] "${key}" уже загружен`)
      return Promise.resolve(this.loaded.get(key))
    }

    return new Promise((resolve, reject) => {
      this.queue.push({ key, loader, priority, resolve, reject })
      this.queue.sort((a, b) => a.priority - b.priority)
      this._process()
    })
  }

  async _process() {
    while (this.running < this.concurrency && this.queue.length > 0) {
      const { key, loader, priority, resolve, reject } = this.queue.shift()
      this.running++
      console.log(`[LazyLoader] загружаем "${key}" (приоритет: ${priority})`)
      try {
        const result = await loader()
        this.loaded.set(key, result)
        resolve(result)
        console.log(`[LazyLoader] "${key}" загружен`)
      } catch(e) {
        reject(e)
      } finally {
        this.running--
        this._process()
      }
    }
  }
}

// === Демонстрация ===

console.log('=== 1. Мемоизация (v-memo) ===')
const expensiveSort = createMemoizedComputed((items, order) => {
  return [...items].sort((a, b) => order === 'asc' ? a - b : b - a)
})

const items = [3, 1, 4, 1, 5, 9, 2, 6]
const result1 = expensiveSort.get([items, 'asc'])
const result2 = expensiveSort.get([items, 'asc'])   // кэш!
const result3 = expensiveSort.get([items, 'desc'])  // пересчёт
console.log('Результат:', result3)
console.log('Реальных вычислений:', expensiveSort.getCallCount())  // 2

console.log('\n=== 2. Виртуализация ===')
const scroller = new VirtualScroller({ containerHeight: 300, itemHeight: 40 })
scroller.setItems(Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` })))

scroller.setScrollTop(0)
let vis = scroller.getVirtualItems()
console.log('Scroll=0:', scroller.stats(vis))

scroller.setScrollTop(50000)
vis = scroller.getVirtualItems()
console.log('Scroll=50000:', scroller.stats(vis))
console.log('Первый видимый:', vis[0].data.name)

console.log('\n=== 3. Ленивая загрузка ===')
const loader = new LazyLoader({ concurrency: 2 })

const makeLoader = (name, ms) => async () => {
  await new Promise(r => setTimeout(r, ms))
  return { component: name }
}

// Добавляем 4 задачи, concurrency=2 — две параллельно
Promise.all([
  loader.enqueue('Header',  makeLoader('Header', 50),  1),
  loader.enqueue('Sidebar', makeLoader('Sidebar', 30), 2),
  loader.enqueue('Charts',  makeLoader('Charts', 80),  5),
  loader.enqueue('Footer',  makeLoader('Footer', 20),  3),
]).then(results => {
  console.log('Загружено:', results.map(r => r.component))
  // Повторный вызов — из кэша
  loader.enqueue('Header', makeLoader('Header', 50), 1)
})