Навигация

Оптимизация производительности

Vue 3 из коробки работает быстро. Его реактивная система на Proxy, компилятор шаблонов и виртуальный DOM дают хорошую производительность для большинства приложений без дополнительных усилий. Но по мере роста проекта начинают проявляться узкие места: тяжёлые вычисления в шаблоне, частые ререндеры больших списков, синхронная загрузка всего бандла, избыточная реактивность глубоких объектов.

Эта статья посвящена тому, как находить и устранять такие проблемы. Мы разберём встроенные инструменты Vue: computed, v-once, v-memo, defineAsyncComponent, shallowRef, lazy hydration, а также подходы к профилированию. Везде, где это уместно, будем сравнивать с React, чтобы вы лучше понимали, какая ментальная модель стоит за каждым решением.

Содержание

  1. Когда оптимизация действительно нужна
  2. computed — кэширование производных данных
  3. v-once — один рендер без отслеживания
  4. v-memo — условная мемоизация поддерева
  5. Асинхронные компоненты defineAsyncComponent
  6. shallowRef и shallowReactive — ограниченная реактивность
  7. Оптимизация списков: key и виртуализация
  8. Lazy hydration в SSR
  9. Профилирование и поиск узких мест
  10. Сравнение с React
  11. Лучшие практики и антипаттерны
  12. Ключевые тезисы для интервью
  13. Заключение
  14. Полезные ссылки

Когда оптимизация действительно нужна

Первое правило оптимизации: не оптимизируйте преждевременно. Vue умеет многие вещи делать эффективно сам, и попытка внедрить мемоизацию везде может привести к обратному эффекту — усложнению кода, новым багам и даже снижению производительности из-за накладных расходов на сравнение.

Однако есть типичные сигналы, что пора вмешаться:

СимптомВозможная причинаЧто проверить
Зависает ввод в форму при большом спискеКаждый ввод триггерит ререндер всего спискаcomputed, v-memo, разбиение на компоненты
Долгая первоначальная загрузкаБольшой бандл, синхронный импорт всех страницdefineAsyncComponent, code splitting
Медленный рендер таблицы с тысячами строкРеактивность на каждой ячейке, полный diffВиртуализация, shallowRef, оптимизация key
Падение FPS при анимацииЛишние обновления DOM, тяжёлые вычисления в шаблонеcomputed, v-once, requestAnimationFrame
Высокое потребление памятиГлубокая реактивность на больших объектахshallowRef, markRaw, правильная очистка

Прежде чем что-то оптимизировать, нужно измерить. Догадки о причине тормозов часто оказываются неверными. Используйте Vue DevTools, Performance-вкладку Chrome DevTools, Lighthouse и профилировщик JavaScript, чтобы найти реальное узкое место.

Оптимизация в Vue строится на нескольких идеях:

  1. Избегать лишней работы. Если результат вычисления не изменился, не вычислять его заново (computed).
  2. Избегать лишнего рендера. Если поддерево не зависит от изменившихся данных, не обновлять его (v-memo, v-once).
  3. Откладывать загрузку. Загружать код только тогда, когда он нужен (defineAsyncComponent).
  4. Уменьшать стоимость реактивности. Не делать реактивным то, что не должно реагировать на изменения (shallowRef, markRaw).
  5. Уменьшать размер обновлений. Правильно использовать key в списках и разбивать компоненты, чтобы локализовать изменения.

computed — кэширование производных данных

computed — это самый частый и самый полезный инструмент оптимизации в Vue. Он создаёт вычисляемое свойство, которое пересчитывается только тогда, когда изменяются его реактивные зависимости. Во всех остальных случаях возвращается закэшированное значение.

Базовый пример

<script setup>
import { ref, computed } from 'vue';

const items = ref([
  { name: 'Apple', price: 100 },
  { name: 'Banana', price: 50 },
  { name: 'Cherry', price: 200 },
]);

const totalPrice = computed(() => {
  return items.value.reduce((sum, item) => sum + item.price, 0);
});
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.name">
      {{ item.name }}: {{ item.price }}
    </li>
  </ul>
  <p>Итого: {{ totalPrice }}</p>
</template>

Каждый раз, когда шаблон обращается к totalPrice, не происходит повторного вычисления суммы, если items не изменился. Если items огромный, это огромная экономия.

computed vs обычная функция

Сравним с обычным методом:

<template>
  <p>Итого: {{ getTotalPrice() }}</p>
  <p>Со скидкой: {{ getTotalPrice() * 0.9 }}</p>
</template>

Функция getTotalPrice() будет вызвана дважды при каждом обновлении компонента, даже если items не менялся. computed вычислит значение один раз и вернёт кэш.

Не мутируйте computed

computed по умолчанию readonly. Попытка присвоить ему значение приведёт к предупреждению:

const fullName = computed(() => `${firstName.value} ${lastName.value}`);
fullName.value = 'New Name'; // ⚠️ Write operation failed: computed value is readonly

Если нужна writable computed, используйте getter + setter:

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (newValue) => {
    [firstName.value, lastName.value] = newValue.split(' ');
  },
});

Важное ограничение

computed кэширует результат, но кэш работает только внутри одного экземпляра компонента. Если вы создаёте computed внутри composable без должного учёта, убедитесь, что каждый компонент получает свой экземпляр.

// ✅ Правильно: каждый вызов useFilteredItems создаёт свой computed
export function useFilteredItems(items) {
  const filter = ref('');
  const filtered = computed(() =>
    items.value.filter((item) => item.name.includes(filter.value))
  );
  return { filter, filtered };
}

v-once — один рендер без отслеживания

Директива v-once говорит Vue: отрендерить элемент один раз и больше не отслеживать его зависимости. Это самый дешёвый способ сделать часть UI статической.

<template>
  <div>
    <h1 v-once>{{ title }}</h1>
    <p>Последнее обновление: {{ lastUpdated }}</p>
  </div>
</template>

Здесь <h1> отрендерится один раз и не будет обновляться при изменении title. Второй <p> продолжит реагировать на lastUpdated.

Когда использовать

v-once полезен для:

  • Статических заголовков, футеров, легал-текстов.
  • Контента, который приходит из CMS и гарантированно не меняется.
  • Оптимизации SSR: v-once уменьшает объём гидратации.

Ограничения

v-once не сравнивает зависимости — она просто отключает реактивность полностью. Если данные всё-таки могут измениться, v-once не подходит. Для условной мемоизации есть v-memo.


v-memo — условная мемоизация поддерева

v-memo — более гибкая альтернатива v-once. Она запоминает результат рендера элемента и сравнивает переданный массив зависимостей. Если зависимости не изменились, поддерево пропускает обновление.

<template>
  <div v-memo="[user.id, user.status]">
    <h2>{{ user.name }}</h2>
    <p>{{ user.bio }}</p>
    <UserStats :stats="user.stats" />
  </div>
</template>

Если user.id и user.status не изменились, Vue не будет обновлять <div> и его потомков, даже если другие свойства user изменились.

Сравнение с v-once

ДирективаКогда обновляетсяГибкость
v-onceНикогдаМинимальная
v-memoТолько когда изменились указанные зависимостиВысокая

Использование со списками

v-memo особенно полезен в длинных списках, где каждый элемент имеет свой собственный набор зависимостей:

<template>
  <div>
    <div
      v-for="item in items"
      :key="item.id"
      v-memo="[item.id, item.selected]"
    >
      <h3>{{ item.title }}</h3>
      <p>{{ item.description }}</p>
      <span v-if="item.selected">★</span>
    </div>
  </div>
</template>

Здесь элемент списка будет обновляться, только если изменился item.id или item.selected. Изменение item.description не вызовет обновления, потому что мы его исключили из зависимостей. Это опасно, если description действительно должен отображаться актуальный — но если это допустимо, это сильная оптимизация.

Накладные расходы

v-memo требует сравнения зависимостей и хранения снимка VNode. Если поддерево маленькое и дешёвое в рендере, v-memo может принести больше вреда, чем пользы. Применяйте его к большим и тяжёлым фрагментам.


Асинхронные компоненты defineAsyncComponent

defineAsyncComponent позволяет загружать компонент лениво — только в тот момент, когда он понадобится для рендера. Это основной способ code splitting в Vue.

<script setup>
import { defineAsyncComponent } from 'vue';

const AdminPanel = defineAsyncComponent(() => import('./AdminPanel.vue'));
</script>

<template>
  <AdminPanel />
</template>

Когда компонент впервые попадёт в дерево рендера, Vue вызовет loader-функцию, динамически импортирует модуль и отрендерит компонент.

Loading и error states

defineAsyncComponent принимает объект с дополнительными опциями:

<script setup>
import { defineAsyncComponent } from 'vue';
import LoadingSpinner from './LoadingSpinner.vue';
import ErrorFallback from './ErrorFallback.vue';

const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorFallback,
  delay: 200,
  timeout: 3000,
  suspensible: true,
});
</script>
ОпцияОписание
loaderФункция, возвращающая Promise с компонентом
loadingComponentКомпонент, показываемый во время загрузки
errorComponentКомпонент, показываемый при ошибке
delayЗадержка в мс перед показом loadingComponent (по умолчанию 200)
timeoutЛимит времени на загрузку
suspensibleМожет ли компонент участвовать в <Suspense>

Code splitting по маршрутам

Чаще всего defineAsyncComponent используется вместе с Vue Router:

import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/dashboard',
      component: () => import('./views/DashboardView.vue'),
    },
    {
      path: '/reports',
      component: () => import('./views/ReportsView.vue'),
    },
  ],
});

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

Не злоупотребляйте

Не оборачивайте в defineAsyncComponent каждый мелкий компонент. Динамический импорт добавляет сетевой запрос и накладные расходы. Используйте его для:

  • Страниц и крупных разделов.
  • Тяжёлых виджетов, которые не нужны сразу (графики, редакторы, карты).
  • Компонентов, загружаемых по условию (модалки, админ-панели).

shallowRef и shallowReactive — ограниченная реактивность

По умолчанию ref и reactive создают глубокую реактивность: любое изменение на любой глубине вложенности будет отслеживаться. Это удобно, но дорого для больших объектов.

shallowRef и shallowReactive ограничивают реактивность верхним уровнем.

shallowRef

import { shallowRef, triggerRef } from 'vue';

const state = shallowRef({
  user: { name: 'Alice', age: 30 },
  settings: { theme: 'dark' },
});

// ❌ Это не вызовет обновления компонента
state.value.user.name = 'Bob';

// ✅ А это вызовет
state.value = { ...state.value, user: { ...state.value.user, name: 'Bob' } };

shallowRef отслеживает только замену value. Внутренние изменения игнорируются. Это полезно, когда:

  • Объект большой и часто мутируется, но вам нужно обновлять UI только явной заменой.
  • Хранится DOM-элемент, компонент или сторонний объект.
  • Нужна ручная оптимизация реактивности.

shallowReactive

import { shallowReactive } from 'vue';

const state = shallowReactive({
  count: 0,
  nested: { value: 1 },
});

state.count++;              // ✅ Реактивно
state.nested.value = 2;     // ❌ Не реактивно

shallowReactive отслеживает только прямые свойства объекта, но не глубокие вложенности.

triggerRef для ручного обновления

Если вы мутировали внутренности shallowRef и хотите вручную сообщить Vue об обновлении:

state.value.user.name = 'Bob';
triggerRef(state);

markRaw

Если объект вообще не должен стать реактивным — например, сторонняя библиотека, большой конфиг, сложный структурный объект — используйте markRaw:

import { markRaw } from 'vue';

const chartInstance = markRaw(new Chart(ctx, options));

Такой объект нельзя будет неявно обернуть в прокси при передаче в reactive или ref.


Оптимизация списков: key и виртуализация

Списки — одно из самых распространённых узких мест. Vue хорошо справляется с diff-алгоритмом, но тысячи элементов или частые обновления могут привести к замедлениям.

Правильный key

key помогает Vue идентифицировать элементы при обновлении списка:

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

Правила:

  • key должен быть уникальным и стабильным в рамках списка.
  • Используйте id сущности, а не индекс массива.
  • Индекс как key допустим только для статических списков без сортировки и фильтрации.

Почему индекс плох: при сортировке Vue будет переиспользовать DOM-элементы, что может привести к некорректному состоянию компонентов и лишним обновлениям.

Разбиение списка на компоненты

Если элемент списка сложный, вынесите его в отдельный компонент:

<template>
  <ul>
    <ListItem
      v-for="item in items"
      :key="item.id"
      :item="item"
    />
  </ul>
</template>

Так Vue сможет локализовать обновления: если изменится только один элемент, перерисуется только его компонент.

Виртуализация

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

Популярные библиотеки:

  • vue-virtual-scroller
  • vue-virtual-scroll-grid
  • @tanstack/vue-virtual
<template>
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="32"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="item">{{ item.name }}</div>
  </RecycleScroller>
</template>

Виртуализация не входит в ядро Vue, но является стандартным инструментом для больших таблиц и лент.

v-memo в списках

Если элементы списка дорогие в рендере, а обновляются редко, v-memo помогает пропустить лишние обновления:

<template>
  <div
    v-for="item in items"
    :key="item.id"
    v-memo="[item.title, item.isFavorite]"
  >
    <ExpensiveCard :data="item" />
  </div>
</template>

Главное — не забыть включить в зависимости все данные, от которых зависит отображение.


Lazy hydration в SSR

В SSR-приложениях Vue сначала рендерит HTML на сервере, а затем «гидратирует» его на клиенте — добавляет реактивность, обработчики событий, ref’ы. Гидратация больших страниц может быть дорогой.

Lazy hydration позволяет отложить гидратацию частей страницы до определённого момента:

  • lazy — гидратация происходит асинхронно, не блокируя основной поток.
  • on-interaction — гидратация при первом взаимодействии (клик, фокус).
  • on-visible — гидратация, когда компонент попадает в viewport.
  • idle — гидратация, когда браузер простаивает (requestIdleCallback).

Lazy hydration доступна в Nuxt через hydrate-on-interaction, Lazy префиксы и другие механизмы. В чистом Vue без Nuxt lazy hydration требует ручной реализации или использования специализированных решений.

<!-- Nuxt-пример -->
<template>
  <div>
    <CommentsSection />
    <LazyFooter hydrate-on-visible />
  </div>
</template>

Компонент <LazyFooter> не будет гидратироваться сразу, а только когда попадёт в видимую область.

Зачем это нужно

  • Уменьшает Time to Interactive (TTI).
  • Снижает нагрузку на главный поток при старте.
  • Позволяет приоритизировать гидратацию критичного контента.

Ограничения

Компонент с lazy hydration не будет интерактивен до момента гидратации. Кнопки в футере не будут работать, пока он не загрузится. Это компромисс между скоростью и отзывчивостью.


Профилирование и поиск узких мест

Оптимизация без измерений — это угадывание. В Vue есть несколько инструментов для профилирования.

Vue DevTools

Vue DevTools показывают:

  • Дерево компонентов и их props/state.
  • Timeline событий: рендеры, обновления, эффекты.
  • Performance-записи с длительностью рендера каждого компонента.

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

Chrome DevTools Performance

Вкладка Performance в Chrome позволяет записывать профиль загрузки и взаимодействия. Ищите:

  • Длинные задачи в главном потоке.
  • Частые вызовы patch и render.
  • Тяжёлые JavaScript-вычисления.
  • Layout Thrashing — чередование чтения и записи DOM.

markRaw и console.time

Для быстрой локальной проверки можно измерить время выполнения:

console.time('heavy computation');
const result = heavyComputation();
console.timeEnd('heavy computation');

Если вычисление занимает больше 16 мс, оно может вызвать пропуск кадра.

Lighthouse

Lighthouse даёт общую оценку производительности и рекомендации:

  • Уменьшить размер JavaScript.
  • Использовать ленивую загрузку изображений.
  • Оптимизировать First Contentful Paint и Time to Interactive.

Паттерны при профилировании

  1. Найдите медленный компонент. Если один компонент занимает 80% времени рендера, начните с него.
  2. Проверьте частоту обновлений. Частые изменения ref могут вызывать лишние рендеры.
  3. Измерьте влияние оптимизации. После изменений снова запустите профилировщик, чтобы убедиться, что стало лучше.

Сравнение с React

В React мемоизация достигается через React.memo, useMemo, useCallback. Во Vue большинство этих проблем решается иначе — благодаря реактивной системе и компилятору шаблонов.

computed vs useMemo

VueReactСуть
computed(() => ...)useMemo(() => ..., deps)Кэширование вычисляемого значения

Разница в том, что computed автоматически отслеживает зависимости. В useMemo разработчик должен сам указать массив зависимостей, и неправильный deps-array — один из самых частых источников багов.

// Vue: зависимости отслеживаются автоматически
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

// React: зависимости нужно указать вручную
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

v-memo vs React.memo

VueReactСуть
v-memo="[dep1, dep2]"React.memo(Component, areEqual)Пропуск рендера при неизменных зависимостях

v-memo работает на уровне шаблона и позволяет указать зависимости. React.memo оборачивает компонент и по умолчанию делает поверхностное сравнение пропсов.

<!-- Vue -->
<div v-memo="[user.id]">
  <UserProfile :user="user" />
</div>
// React
const UserProfileMemo = React.memo(UserProfile);

defineAsyncComponent vs React.lazy

VueReactСуть
defineAsyncComponent(() => import(...))React.lazy(() => import(...))Ленивая загрузка компонента

Оба инструмента решают одну задачу, но defineAsyncComponent предлагает больше встроенных опций: loading/error компоненты, delay, timeout.

shallowRef vs useRef / useState

shallowRef не имеет прямого аналога в React. React-разработчики обычно используют useRef для не вызывающих рендер значений и useState для вызывающих. shallowRef занимает промежуточное положение: он вызывает рендер при замене значения, но не отслеживает внутренние изменения.

Общая философия

Vue пытается делать много оптимизаций неявно: компилятор шаблонов генерирует эффективный код, реактивная система минимизирует количество обновлений, computed автоматически отслеживает зависимости. React предоставляет больше ручных инструментов, что даёт больше контроля, но и больше возможностей ошибиться.


Лучшие практики и антипаттерны

Практики

1. Используйте computed для всех производных данных

Если значение вычисляется из реактивного состояния, оберните его в computed. Это не только оптимизация, но и правильная архитектура.

2. Делите компоненты, чтобы локализовать ререндеры

Маленькие компоненты обновляются изолированно. Если в огромном компоненте меняется один счётчик, перерисовывается всё.

3. Используйте defineAsyncComponent для страниц и тяжёлых виджетов

Code splitting по маршрутам — must have для приложений любого размера.

4. Применяйте v-memo к тяжёлым поддеревьям

Не везде, а там, где рендер действительно дорогой и зависимости чётко ограничены.

5. Используйте shallowRef для больших объектов и сторонних инстансов

Если объект не должен быть глубоко реактивным, не превращайте его в прокси.

6. Правильно задавайте key в списках

Стабильный id предпочтительнее индекса. Это влияет и на корректность, и на производительность.

7. Используйте виртуализацию для длинных списков

Тысячи DOM-элементов никогда не будут быстрыми, даже в самом оптимизированном фреймворке.

8. Профилируйте перед оптимизацией

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

Антипаттерны

1. Мемоизация всего подряд

v-memo и computed имеют свою цену. Если поддерево рендерится за доли миллисекунды, мемоизация только замедлит.

2. v-for без key на изменяемых списках

Индекс в качестве key приводит к некорректным обновлениям и лишнему diff’у.

3. Глубокая реактивность на огромных объектах

Хранение большого JSON-ответа от API в reactive или ref превращает каждое вложенное свойство в прокси. Используйте shallowRef или markRaw.

4. Тяжёлые вычисления в шаблоне

<!-- ❌ Плохо -->
<p>{{ items.filter(i => i.active).map(i => i.name).join(', ') }}</p>

Вынесите в computed:

<!-- ✅ Хорошо -->
<script setup>
const activeNames = computed(() =>
  items.value.filter(i => i.active).map(i => i.name).join(', ')
);
</script>

<template>
  <p>{{ activeNames }}</p>
</template>

5. Синхронная загрузка всего приложения

Если все страницы импортируются статически, бандл растёт линейно с ростом приложения.

6. Мутация пропсов или slot props

Это не столько проблема производительности, сколько архитектурная ошибка, но она может приводить к неожиданным лишним обновлениям.

7. Неправильное использование watchEffect для вычислений

// ❌ Плохо
const doubled = ref(0);
watchEffect(() => {
  doubled.value = count.value * 2;
});
// ✅ Хорошо
const doubled = computed(() => count.value * 2);

computed кэширует результат и имеет чёткую семантику производного значения.


Ключевые тезисы для интервью

  1. computed кэширует результат и пересчитывается только при изменении зависимостей. Автоматически отслеживает зависимости, в отличие от useMemo.
  2. v-once отключает реактивность для элемента полностью, полезен для статического контента.
  3. v-memo позволяет условно пропускать обновление поддерева на основе указанных зависимостей.
  4. defineAsyncComponent лениво загружает компонент, основной инструмент code splitting в Vue.
  5. shallowRef и shallowReactive ограничивают реактивность верхним уровнем, снижая накладные расходы на большие объекты.
  6. key в v-for должен быть уникальным и стабильным, индекс используется только для статических списков.
  7. Виртуализация необходима для длинных списков, так как DOM-элементы всё равно имеют предел производительности.
  8. Lazy hydration в SSR откладывает гидратацию частей страницы, улучшая TTI.
  9. Производительность нужно измерять до оптимизации: Vue DevTools, Chrome Performance, Lighthouse.
  10. Мемоизация — не бесплатна. Применяйте computed, v-memo и асинхронные компоненты осмысленно, а не по умолчанию.

Заключение

Vue 3 предоставляет разработчику мощный набор инструментов для оптимизации, но большинство из них работают лучше всего тогда, когда применяются точечно и осознанно. computed становится естественным способом выражать производные данные. defineAsyncComponent помогает уменьшать начальный бандл. v-memo и v-once позволяют пропускать лишние обновления. shallowRef и shallowReactive снижают стоимость реактивности на больших объектах.

Главное — не превращать оптимизацию в религию. Сначала измерьте, найдите реальное узкое место, затем выберите подходящий инструмент. Правильная архитектура — маленькие компоненты, чёткое разделение состояния и представления, осмысленное использование реактивности — обычно даёт больше, чем десятки v-memo в неправильных местах.

На собеседованиях важно показать, что вы понимаете не только синтаксис, но и принципы: зачем нужна мемоизация, когда она полезна, а когда вредна, и как Vue отличается от React в подходе к производительности.


Полезные ссылки