Оптимизация производительности
Vue 3 из коробки работает быстро. Его реактивная система на Proxy, компилятор шаблонов и виртуальный DOM дают хорошую производительность для большинства приложений без дополнительных усилий. Но по мере роста проекта начинают проявляться узкие места: тяжёлые вычисления в шаблоне, частые ререндеры больших списков, синхронная загрузка всего бандла, избыточная реактивность глубоких объектов.
Эта статья посвящена тому, как находить и устранять такие проблемы. Мы разберём встроенные инструменты Vue: computed, v-once, v-memo, defineAsyncComponent, shallowRef, lazy hydration, а также подходы к профилированию. Везде, где это уместно, будем сравнивать с React, чтобы вы лучше понимали, какая ментальная модель стоит за каждым решением.
Содержание
- Когда оптимизация действительно нужна
computed— кэширование производных данныхv-once— один рендер без отслеживанияv-memo— условная мемоизация поддерева- Асинхронные компоненты
defineAsyncComponent shallowRefиshallowReactive— ограниченная реактивность- Оптимизация списков:
keyи виртуализация - Lazy hydration в SSR
- Профилирование и поиск узких мест
- Сравнение с React
- Лучшие практики и антипаттерны
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Когда оптимизация действительно нужна
Первое правило оптимизации: не оптимизируйте преждевременно. 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 строится на нескольких идеях:
- Избегать лишней работы. Если результат вычисления не изменился, не вычислять его заново (
computed). - Избегать лишнего рендера. Если поддерево не зависит от изменившихся данных, не обновлять его (
v-memo,v-once). - Откладывать загрузку. Загружать код только тогда, когда он нужен (
defineAsyncComponent). - Уменьшать стоимость реактивности. Не делать реактивным то, что не должно реагировать на изменения (
shallowRef,markRaw). - Уменьшать размер обновлений. Правильно использовать
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-scrollervue-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.
Паттерны при профилировании
- Найдите медленный компонент. Если один компонент занимает 80% времени рендера, начните с него.
- Проверьте частоту обновлений. Частые изменения
refмогут вызывать лишние рендеры. - Измерьте влияние оптимизации. После изменений снова запустите профилировщик, чтобы убедиться, что стало лучше.
Сравнение с React
В React мемоизация достигается через React.memo, useMemo, useCallback. Во Vue большинство этих проблем решается иначе — благодаря реактивной системе и компилятору шаблонов.
computed vs useMemo
| Vue | React | Суть |
|---|---|---|
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
| Vue | React | Суть |
|---|---|---|
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
| Vue | React | Суть |
|---|---|---|
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 кэширует результат и имеет чёткую семантику производного значения.
Ключевые тезисы для интервью
computedкэширует результат и пересчитывается только при изменении зависимостей. Автоматически отслеживает зависимости, в отличие отuseMemo.v-onceотключает реактивность для элемента полностью, полезен для статического контента.v-memoпозволяет условно пропускать обновление поддерева на основе указанных зависимостей.defineAsyncComponentлениво загружает компонент, основной инструмент code splitting в Vue.shallowRefиshallowReactiveограничивают реактивность верхним уровнем, снижая накладные расходы на большие объекты.keyвv-forдолжен быть уникальным и стабильным, индекс используется только для статических списков.- Виртуализация необходима для длинных списков, так как DOM-элементы всё равно имеют предел производительности.
- Lazy hydration в SSR откладывает гидратацию частей страницы, улучшая TTI.
- Производительность нужно измерять до оптимизации: Vue DevTools, Chrome Performance, Lighthouse.
- Мемоизация — не бесплатна. Применяйте
computed,v-memoи асинхронные компоненты осмысленно, а не по умолчанию.
Заключение
Vue 3 предоставляет разработчику мощный набор инструментов для оптимизации, но большинство из них работают лучше всего тогда, когда применяются точечно и осознанно. computed становится естественным способом выражать производные данные. defineAsyncComponent помогает уменьшать начальный бандл. v-memo и v-once позволяют пропускать лишние обновления. shallowRef и shallowReactive снижают стоимость реактивности на больших объектах.
Главное — не превращать оптимизацию в религию. Сначала измерьте, найдите реальное узкое место, затем выберите подходящий инструмент. Правильная архитектура — маленькие компоненты, чёткое разделение состояния и представления, осмысленное использование реактивности — обычно даёт больше, чем десятки v-memo в неправильных местах.
На собеседованиях важно показать, что вы понимаете не только синтаксис, но и принципы: зачем нужна мемоизация, когда она полезна, а когда вредна, и как Vue отличается от React в подходе к производительности.