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

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

⚡ Производительность Svelte: Почему это так быстро

Заголовок раздела «⚡ Производительность Svelte: Почему это так быстро»

Привет! 👋 Svelte часто называют “самым быстрым фреймворком” — и это не просто маркетинг. Разберём почему Svelte быстр, как сделать его ещё быстрее, и как правильно измерять производительность.

Представь двух поваров 🍳. Первый (React) готовит через посредника (виртуальный DOM): сначала рисует блюдо на бумаге, сравнивает с предыдущим рисунком, и только потом идёт на кухню. Второй (Svelte) идёт прямо на кухню и меняет только то, что нужно. Кто быстрее?


Virtual DOM — это концепция React/Vue, при которой каждое обновление проходит через:

1. Создать новый Virtual DOM (дерево объектов в памяти)
2. Сравнить с предыдущим Virtual DOM (diffing)
3. Вычислить минимальные изменения (reconciliation)
4. Применить изменения к реальному DOM
// React — при каждом setState происходит вся цепочка выше:
function Counter() {
const [count, setCount] = useState(0);
// Re-render всего компонента → VDOM diff → DOM update
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
<!-- Svelte — компилятор генерирует прямые DOM операции: -->
<script>
let count = $state(0);
</script>
<!-- Компилируется в что-то вроде: -->
<!-- document.createTextNode(count) -->
<!-- при изменении: textNode.data = count -->
<button onclick={() => count++}>{count}</button>

Что генерирует Svelte компилятор:

// Упрощённый вывод компилятора для <p>{count}</p>:
function create_fragment(ctx) {
let p, t;
return {
c() {
p = element("p");
t = text(ctx[0]); // count
},
m(target, anchor) {
insert(target, p, anchor);
append(p, t);
},
p(ctx, [dirty]) {
// dirty — битовая маска того, что изменилось
if (dirty & 1) set_data(t, ctx[0]); // Обновляем только текст!
},
d(detaching) {
if (detaching) detach(p);
}
};
}

Фреймворк Runtime в бандле Hello World приложение
────────────────────────────────────────────────────────────────
Vanilla JS 0 KB ~1 KB
Svelte 5 ~10-15 KB ~5-10 KB
Vue 3 ~22 KB (min+gzip) ~30-40 KB
React 18 ~47 KB (min+gzip) ~130 KB (с ReactDOM)
Angular 17 ~70+ KB ~200+ KB

Почему Svelte такой маленький? Потому что нет runtime — компилятор генерирует эффективный JavaScript напрямую. Каждое приложение использует только то, что ему нужно.


immutable={true} говорит Svelte: “Мои props никогда не мутируются — новое значение всегда новый объект.” Это позволяет делать быстрые проверки по ссылке:

<!-- ❌ Без immutable — Svelte проверяет каждый prop глубоко -->
<script>
let { user } = $props();
</script>
<!-- ✅ С immutable — Svelte только сравнивает ссылки (===) -->
<svelte:options immutable={true} />
<script>
let { user } = $props();
// Теперь если user === prevUser → компонент НЕ перерисовывается
// Только при user !== prevUser (новый объект)
</script>
<div>{user.name}</div>
ListItem.svelte
<!-- Практичный пример — список элементов: -->
<svelte:options immutable={true} />
<script lang="ts">
let { item }: { item: { id: string; name: string; price: number } } = $props();
</script>
<div>{item.name}: {item.price} ₽</div>
<!-- Родительский компонент -->
<script lang="ts">
let items = $state([
{ id: '1', name: 'Ноутбук', price: 80000 },
{ id: '2', name: 'Мышь', price: 2000 },
]);
function updatePrice(id: string, price: number) {
// НОВЫЙ объект для элемента — immutable правило соблюдено
items = items.map(item =>
item.id === id ? { ...item, price } : item
);
// Svelte перерисует только тот ListItem, у которого изменилась ссылка
}
</script>
{#each items as item (item.id)}
<ListItem {item} />
{/each}

{#key} — это способ сказать Svelte: “Когда это значение меняется, создай компонент заново (ремонтируй)”:

<script lang="ts">
let userId = $state('user-1');
// При смене userId компонент UserProfile будет уничтожен и создан заново
// Это полезно когда у компонента есть внутреннее состояние,
// которое нужно сбросить
</script>
{#key userId}
<UserProfile id={userId} />
{/key}
<!-- Без #key: компонент обновится (lifecycle сохранится) -->
<!-- С #key: компонент уничтожится и создастся заново -->
<!-- Анимации при смене контента: -->
<script lang="ts">
import { fade } from 'svelte/transition';
let currentPage = $state(1);
</script>
{#key currentPage}
<div transition:fade={{ duration: 200 }}>
<PageContent page={currentPage} />
</div>
{/key}

<script lang="ts">
// Ленивая загрузка — компонент не включается в начальный бандл:
let HeavyChart: any = null;
let showChart = $state(false);
async function loadChart() {
// Динамический импорт — создаёт отдельный чанк
const module = await import('./HeavyChart.svelte');
HeavyChart = module.default;
showChart = true;
}
</script>
<button onclick={loadChart}>Показать граф</button>
{#if showChart && HeavyChart}
<svelte:component this={HeavyChart} data={chartData} />
{/if}

Code splitting в SvelteKit (автоматический):

// SvelteKit автоматически разбивает код по маршрутам
// src/routes/heavy-page/+page.svelte → отдельный чанк
// Можно также lazy load внутри страницы:
// src/routes/dashboard/+page.svelte
<script lang="ts">
import { onMount } from 'svelte';
let DashboardChart: any = null;
onMount(async () => {
// Загружаем только когда компонент смонтирован (клиент)
const mod = await import('$lib/components/DashboardChart.svelte');
DashboardChart = mod.default;
});
</script>
{#if DashboardChart}
<svelte:component this={DashboardChart} />
{:else}
<div class="skeleton">Загружаем граф...</div>
{/if}

🎨 svelte/motion: анимации без потерь производительности

Заголовок раздела «🎨 svelte/motion: анимации без потерь производительности»

Svelte поставляется с двумя утилитами для плавных анимаций: tweened и spring. Они используют requestAnimationFrame и не влияют на реактивную систему.

<script lang="ts">
import { tweened, spring } from 'svelte/motion';
import { cubicOut, elasticOut } from 'svelte/easing';
// tweened — линейная анимация с easing
const progress = tweened(0, {
duration: 400,
easing: cubicOut,
});
// spring — физическая симуляция (пружина)
const position = spring({ x: 0, y: 0 }, {
stiffness: 0.1, // Жёсткость пружины (0-1)
damping: 0.8, // Затухание (0-1)
precision: 0.001, // Порог остановки
});
let score = $state(0);
function updateScore(newScore: number) {
score = newScore;
progress.set(newScore / 100); // Анимируем прогресс-бар
}
function handleMouseMove(event: MouseEvent) {
position.set({ x: event.clientX, y: event.clientY });
// spring автоматически анимирует до нового значения!
}
</script>
<!-- Прогресс-бар с анимацией: -->
<div class="progress-container">
<div class="progress-bar" style="width: {$progress * 100}%"></div>
</div>
<!-- Следящий элемент: -->
<div
class="follower"
style="transform: translate({$position.x}px, {$position.y}px)"
onmousemove={handleMouseMove}
></div>
<!-- Счётчик с tweened: -->
<button onclick={() => updateScore(Math.floor(Math.random() * 100))}>
Рандомный счёт
</button>

Разница между tweened и spring:

tweened:
- Фиксированная длительность
- Точный контроль через easing функции
- Идеально: прогресс-бары, счётчики, transitions
spring:
- Динамическая длительность (пока не "успокоится")
- Физическая симуляция — естественное движение
- Идеально: курсоры, drag&drop, card hover effects

// src/routes/+layout.ts — измерение Web Vitals
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = () => {
// Запускаем измерение Web Vitals только на клиенте
if (typeof window !== 'undefined') {
import('web-vitals').then(({ onCLS, onFID, onFCP, onLCP, onTTFB }) => {
onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onFCP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);
});
}
return {};
};
function sendToAnalytics(metric: any) {
// Отправляем в Vercel Analytics, Plausible, etc.
console.log('Web Vital:', metric.name, metric.value);
if (typeof window !== 'undefined' && 'sendBeacon' in navigator) {
navigator.sendBeacon('/api/analytics', JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
delta: metric.delta,
}));
}
}

Оптимизация LCP (Largest Contentful Paint):

<!-- src/routes/+layout.svelte — предзагрузка критических ресурсов -->
<svelte:head>
<!-- Preload критического шрифта: -->
<link
rel="preload"
href="/fonts/inter.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Preload LCP изображения: -->
<link
rel="preload"
href="/hero-image.webp"
as="image"
type="image/webp"
/>
</svelte:head>

Окно терминала
npm install -D rollup-plugin-visualizer
vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
sveltekit(),
visualizer({
open: true, // Автооткрытие в браузере
filename: 'stats.html',
gzipSize: true, // Показывать gzip размер
brotliSize: true, // Показывать brotli размер
template: 'treemap', // Тип диаграммы: treemap | sunburst | network
}),
],
});
// npm run build → откроется stats.html с визуализацией бандла

<script lang="ts">
// @sveltejs/enhanced-img — оптимизация изображений в SvelteKit
import { Picture } from '@sveltejs/enhanced-img';
import heroImg from '$lib/assets/hero.jpg';
</script>
<!-- Автоматически:
- Конвертирует в WebP/AVIF
- Генерирует srcset для разных размеров
- Добавляет lazy loading
- Предотвращает layout shift (width/height)
-->
<enhanced:img src={heroImg} alt="Hero" />
<!-- Или с Picture для art direction: -->
<Picture
src={heroImg}
alt="Hero"
sizes="(max-width: 768px) 100vw, 50vw"
/>
// vite.config.ts — настройка enhancedImages:
import { enhancedImages } from '@sveltejs/enhanced-img';
export default defineConfig({
plugins: [
enhancedImages(),
sveltekit(),
],
});

<script lang="ts">
import { onMount, tick } from 'svelte';
let items = $state(Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Item ${i}` })));
// Измеряем время рендеринга:
async function measure() {
const start = performance.now();
items = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Updated ${i}` }));
// tick() — ждём когда Svelte обновит DOM
await tick();
const end = performance.now();
console.log(`Обновление 1000 элементов: ${(end - start).toFixed(2)}ms`);
}
// Performance observer:
onMount(() => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
}
});
observer.observe({ entryTypes: ['measure', 'navigation', 'paint'] });
return () => observer.disconnect();
});
</script>

Базовая оптимизация:
✅ Используй $derived вместо $effect для вычислений
✅ Используй {#each items as item (item.id)} с key
✅ immutable={true} для компонентов с неизменяемыми props
✅ Используй tweened/spring вместо CSS transitions для JS анимаций
Средняя оптимизация:
✅ Lazy load тяжёлых компонентов (графики, таблицы)
✅ {#key} для сброса состояния компонента
✅ Кэшируй дорогие вычисления в $derived
✅ Используй $state.raw() для неизменяемых данных
Продвинутая оптимизация:
✅ Bundle analyzer для анализа зависимостей
✅ Preload критических ресурсов
✅ Web Vitals мониторинг
✅ Оптимизированные изображения (@sveltejs/enhanced-img)
✅ Правильные HTTP кэш заголовки