Перейти к содержимому

26. Производительность

Vue 3 уже очень быстрый из коробки — компилятор шаблонов делает оптимизации автоматически. Но когда у тебя тысячи элементов в списке, сложные вычисления или дерево компонентов глубиной 20 уровней — нужно знать инструменты для ручной оптимизации. 🚀


Прежде чем оптимизировать — измерь. Не угадывай, где узкое место!

// Vue DevTools — вкладка Performance
// Показывает время рендера каждого компонента
// Профайлер в браузере
performance.mark('render-start');
// ... код ...
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');
// Vue 3 встроенный профайлер (dev mode)
const app = createApp(App);
app.config.performance = true; // включить трассировку в DevTools

Директива v-once указывает Vue: «этот элемент никогда не изменится — не трать время на его отслеживание».

<template>
<!-- Статичный контент, который никогда не меняется -->
<header v-once>
<h1>{{ appTitle }}</h1>
<nav>
<a v-for="link in staticLinks" :key="link.href" :href="link.href">
{{ link.label }}
</a>
</nav>
</header>
<!-- Счётчик посещений — меняется, поэтому без v-once -->
<p>Посещений: {{ visitCount }}</p>
</template>
<script setup lang="ts">
const appTitle = 'Мой Магазин';
const staticLinks = [
{ href: '/', label: 'Главная' },
{ href: '/catalog', label: 'Каталог' },
{ href: '/about', label: 'О нас' },
];
const visitCount = ref(0);
</script>

Vue скомпилирует v-once в статичный VNode и пропустит его при перерендере. Отлично для шапки, подвала, статичных баннеров.


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

<template>
<!-- Список с тысячами элементов -->
<ul>
<li
v-for="item in hugeList"
:key="item.id"
v-memo="[item.id, item.selected, item.label]"
>
<!-- Ререндер только если изменились id, selected или label -->
<span>{{ item.label }}</span>
<input type="checkbox" :checked="item.selected" />
<ExpensiveSubComponent :data="item.details" />
</li>
</ul>
</template>
<!-- Особенно полезно с v-for + частичными обновлениями -->
<template>
<div v-for="row in rows" :key="row.id" v-memo="[row.id === selectedId]">
<!-- Ререндерится только при смене selected-строки -->
<TableRow :row="row" :selected="row.id === selectedId" />
</div>
</template>

⚠️ Важно: v-memo с пустым массивом [] — то же самое что v-once.


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

import { ref, shallowRef, reactive, shallowReactive } from 'vue';
// ref — глубокая реактивность (следит за всеми вложенными свойствами)
const deepState = ref({
user: { profile: { settings: { theme: 'dark' } } }
});
// Это тоже реактивно:
deepState.value.user.profile.settings.theme = 'light'; // ✅ триггерит обновление
// shallowRef — реактивна только смена .value целиком
const shallowState = shallowRef({
user: { profile: { settings: { theme: 'dark' } } }
});
// Это НЕ реактивно:
shallowState.value.user.profile.settings.theme = 'light'; // ❌ НЕ триггерит
// Это реактивно:
shallowState.value = { ...shallowState.value }; // ✅ триггерит
// shallowReactive — реактивен только первый уровень
const state = shallowReactive({
count: 0, // ✅ реактивно
user: {
name: 'Иван', // ❌ НЕ реактивно
age: 25, // ❌ НЕ реактивно
},
});
state.count++; // ✅ обновление
state.user.name = 'XX'; // ❌ нет обновления
state.user = { name: 'Пётр', age: 30 }; // ✅ обновление (первый уровень)

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

  • Большие объекты с неизменяемыми вложенными данными
  • Иммутабельные структуры (заменяем целиком)
  • Данные из внешних источников (API, Canvas, Three.js)

<KeepAlive> сохраняет компонент в памяти при скрытии, вместо того чтобы уничтожать и создавать заново.

<template>
<TabBar v-model:active="activeTab" />
<!-- Без KeepAlive: каждое переключение вкладки — пересоздание компонента -->
<component :is="activeTabComponent" />
<!-- С KeepAlive: компонент "засыпает" и "просыпается" -->
<KeepAlive>
<component :is="activeTabComponent" />
</KeepAlive>
</template>
<!-- Управление кэшированием -->
<KeepAlive
:include="['ProfileTab', 'SettingsTab']"
:exclude="['TempComponent']"
:max="5"
>
<!-- max: максимум 5 компонентов в кэше -->
<component :is="currentComponent" />
</KeepAlive>
// Хуки для компонентов внутри KeepAlive
export default defineComponent({
setup() {
// Вызывается когда компонент "просыпается" (стал видимым)
onActivated(() => {
console.log('Компонент активирован — обновить данные');
fetchLatestData();
});
// Вызывается когда компонент "засыпает" (скрывается)
onDeactivated(() => {
console.log('Компонент деактивирован — остановить таймеры');
clearInterval(timer);
});
},
});

Разбивай большое приложение на чанки — загружай компоненты только когда они нужны.

// defineAsyncComponent — ленивая загрузка компонента
import { defineAsyncComponent } from 'vue';
// Простой вариант
const HeavyChart = defineAsyncComponent(() =>
import('./components/HeavyChart.vue')
);
// Расширенный вариант с обработкой состояний
const AdminPanel = defineAsyncComponent({
loader: () => import('./components/AdminPanel.vue'),
loadingComponent: LoadingSpinner, // Пока загружается
errorComponent: ErrorDisplay, // Если ошибка
delay: 200, // Задержка показа loadingComponent
timeout: 10000, // Таймаут (мс)
onError(error, retry, fail, attempts) {
if (attempts <= 3) retry(); // Повторить до 3 раз
else fail();
},
});
<!-- Используем с Suspense -->
<template>
<Suspense>
<!-- Основной контент -->
<template #default>
<HeavyChart :data="chartData" />
</template>
<!-- Fallback пока грузится -->
<template #fallback>
<div class="skeleton-chart" />
</template>
</Suspense>
</template>
// Роутер тоже поддерживает ленивую загрузку
const routes = [
{
path: '/dashboard',
// Этот компонент загрузится только при переходе на /dashboard
component: () => import('./views/DashboardView.vue'),
},
{
path: '/admin',
// Группируем в один чанк с webpackChunkName
component: () => import(/* webpackChunkName: "admin" */ './views/AdminView.vue'),
},
];

Если у тебя список из 10 000 элементов — нельзя рендерить все сразу. Виртуальный скролл рендерит только видимые элементы.

Окно терминала
npm install vue-virtual-scroller
<template>
<!-- Рендерит только ~20 видимых строк из 100 000 -->
<RecycleScroller
class="scroller"
:items="hugeList"
:item-size="48"
key-field="id"
v-slot="{ item }"
>
<div class="user-item">
<img :src="item.avatar" />
<span>{{ item.name }}</span>
<span>{{ item.email }}</span>
</div>
</RecycleScroller>
</template>
<style scoped>
.scroller {
height: 500px; /* ОБЯЗАТЕЛЬНО задать высоту */
}
.user-item {
height: 48px; /* ОБЯЗАТЕЛЬНО соответствует item-size */
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>
<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
// 100 000 элементов — без проблем!
const hugeList = Array.from({ length: 100_000 }, (_, i) => ({
id: i,
name: \`Пользователь \${i}\`,
email: \`user\${i}@test.com\`,
avatar: \`https://i.pravatar.cc/48?img=\${i % 70}\`,
}));
</script>

// vite.config.ts — разделение чанков
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
// Разделяем вендоры от кода приложения
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['@headlessui/vue', '@heroicons/vue'],
'vendor-charts': ['chart.js', 'vue-chartjs'],
},
},
},
// Анализ размера бандла
reportCompressedSize: true,
},
});
Окно терминала
npm install -D rollup-plugin-visualizer
# Добавить в vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
plugins: [vue(), visualizer({ open: true })]
// Трясём дерево (Tree-shaking) — импортируй только нужное
// ❌ Плохо — импортируем всю библиотеку
import _ from 'lodash';
_.debounce(fn, 300);
// ✅ Хорошо — импортируем одну функцию
import debounce from 'lodash/debounce';
debounce(fn, 300);
// ✅ Ещё лучше — lodash-es с tree-shaking
import { debounce } from 'lodash-es';
debounce(fn, 300);

<template>
<!-- Нативная ленивая загрузка -->
<img
loading="lazy"
:src="image.url"
:alt="image.alt"
width="400"
height="300"
/>
<!-- С плейсхолдером через IntersectionObserver -->
<img
ref="imgRef"
:src="isVisible ? image.url : placeholder"
:alt="image.alt"
/>
</template>
<script setup lang="ts">
const imgRef = ref<HTMLImageElement | null>(null);
const isVisible = ref(false);
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
isVisible.value = true;
observer.disconnect();
}
});
onMounted(() => {
if (imgRef.value) observer.observe(imgRef.value);
});
onUnmounted(() => observer.disconnect());
</script>

✅ Используй v-once для статичного контента
✅ Используй v-memo для больших списков
✅ shallowRef/shallowReactive для больших объектов
✅ KeepAlive для часто переключаемых компонентов
✅ defineAsyncComponent для тяжёлых компонентов
✅ Виртуальный скролл для списков 100+ элементов
✅ Разделяй бандл на чанки
✅ Tree-shaking — импортируй точечно
✅ loading="lazy" для изображений
✅ Измеряй ПЕРЕД оптимизацией!

ИнструментКогда использовать
v-onceКонтент никогда не меняется
v-memoБольшие списки с частичными обновлениями
shallowRefБольшие объекты, заменяемые целиком
KeepAliveВкладки, фильтры, переключаемые панели
defineAsyncComponentТяжёлые компоненты, редко используемые
Virtual ScrollСписки 100+ элементов
Code SplittingБольшой бандл, много роутов

Vue 3 даёт тебе все инструменты для создания молниеносных приложений. Главное правило — сначала измеряй, потом оптимизируй. Преждевременная оптимизация — корень всех зол. 🎯