Список вакансий на Svelte за последние годы вырос в разы, и вместе с ним выросла путаница: в старых руководствах мелькает Sapper и сборка на Rollup, в новых SvelteKit и Vite, а посередине изменился сам язык компонентов, потому что пятая версия Svelte привнесла руны. На руках у читателя может быть и свежий проект, и наследственный на связке Sapper. Разберём всё сразу: как устроен компилятор, что такое руны, чем Sapper отличался, как его наследие живёт в SvelteKit 3.1 и что делать проекту на старом стеке. Код будет рабочим в обеих эпохах, с пометками, где граница. Причина популярности проста: интерфейсы на Svelte шустры в браузере, потому что в поставке нет тяжёлого слоя посредников.

Компилятор Svelte превращает компонент в минимальный код без рантайма

Главное отличие Svelte отReact и Vue в том, что это не библиотека в браузере, а компилятор на этапе сборки. Компонент с разметкой, стилями и логикой превращается в обычный JavaScript, который сам создаёт и обновляет элементы. В готовый bundle не едет ни рантайм, ни виртуальное дерево, ни слои совместимости, поэтому итог весит меньше, а обновление интерфейса не тратит время на сравнение деревьев.

Стили живут прямо в компоненте и не протекают наружу: классы получают суффиксы, а скрипт и разметка видят друг друга без посредников. Простейший счётчик на пятой версии выглядит так:

<script>
    let count = $state(0);
    let doubled = $derived(count * 2);
</script>

<button onclick={() => count++}>
    нажато {count} раз, удвоенно {doubled}
</button>

<style>
    button { padding: 8px 16px; border-radius: 8px; }
</style>

Реактивность здесь не результат вызовов с подписками, а прямое следствие компиляции: изменение count обновляет ровно те места, где значение встречается. Для сравнения, аналогичный компонент на классических библиотеках требует обёртку состояния и подписку, тут их просто нет.

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

<p transition:fade>итог: {doubled}</p>

Эффекты fade, fly и scale работают из коробки, шаги анимации считаются на процессоре или графике, а параметры вроде задержки и длительности задаются прямо в директиве. Мелочь, но именно из таких мелочей складывается ощущение живого интерфейса.

Руны пятой версии управляют состоянием без лишнего бойлерплейта

До пятой версии реактивность в Svelte держалась на присваиваниях и магии компилятора, что работало, но не переносилось в файлы вне компонентов и в библиотеки. Пятая версия, вышедшая осенью 2024 года, ввела руны, явные сигнальные примитивы: $state объявляет состояние, $derived производное значение, $effect побочное действие, $props входные значения компонента.

<script>
    let { title, items = [] } = $props();

    let query = $state("");
    let visible = $derived(
        items.filter(i => i.name.includes(query))
    );

    $effect(() => {
        console.log("поиск:", query, "найдено:", visible.length);
    });
</script>

<input bind:value={query} placeholder="фильтр" />
<ul>
    {#each visible as item (item.id)}
        <li>{title}: {item.name}</li>
    {/each}
</ul>

Руны работают внутри компонентов и в файлах с расширением svelte.js, поэтому состояние выносится в переиспользуемые модули без потери реактивности. События тоже переименовали: вместо двоеточия в on:click теперь обычные свойства onclick, а устаревшая форма поддерживается ради совместимости. Порог входа остался низким, а границы фреймворка стали честными: то, что выглядит как функция, и есть функция.

Классические хранилища из модуля svelte/store никуда не делись: конструкция с подпиской через знак доллара перед именем по-прежнему работает и в пятой версии, поэтому старый код с хранилищами не требует переписывания. Новому коду хватает рун, а переходное сосуществование обеих механик спокойно живёт в одном проекте.

Sapper открыл эпоху фреймворка и ушёл в историю двумя годами позже

Svelte начинался как способ писать компоненты, но для приложения нужны маршруты, переходы между экранами и серверный рендеринг. Эту нишу занял Sapper, фреймворк поверх Svelte, который работал поверх Rollup: маршруты лежали в каталоге routes, файл с подчёркиванием задавал параметр, а функция preload загружала значения до отрисовки.

// _slug.svelte из эпохи Sapper
<script>
    export let post;
</script>

<script context="module">
    export async function preload(page) {
        const res = await this.fetch(`posts/${page.params.slug}.json`);
        return { post: await res.json() };
    }
</script>

<article><h1>{post.title}</h1></article>

Идея оказалась верной, а реализация ушла. Разработка Sapper остановилась в 2021 году, последний выпуск 0.29.3 датирован январём 2023 года, и официальный сайт прямо отправляет всех в SvelteKit. Сегодня запускать новый проект на Sapper нет смысла, а поддерживаемый на нём стоит расценивать как кандидата на переезд, потому что инструменты вокруг него больше не обновляются.

SvelteKit наследует маршрутизацию по файлам и слои раскладки

SvelteKit, вышедший в стабильной версии в декабре 2022 года, забрал у Sapper идеологию и переписал механику. Актуальная версия 3.1.0 собирается на Vite, развивающемся под 8.3.5, и наследует знакомый словарь файлов:

  1. +page.svelte рисует экран и хранит разметку с логикой;
  2. +page.ts и +page.server.ts загружают значения до отрисовки экрана;
  3. +layout.svelte обрамляет вложенные экраны общим каркасом;
  4. +layout.server.ts грузит общие значения для целого слоя;
  5. +server.ts описывает чистые API-эндпоинты со своими методами;
  6. +error.svelte ловит сбои в своём слое и показывает заглушку;
  7. папка в квадратных скобках задаёт имя динамического параметра.

Каталог src/routes превращается в карту приложения: папка products с файлом [id] отвечает пути products/42, layout в корне обрамляет всё, а layout внутри папки добавляет рамку только её ветке. Слои вкладываются друг в друга, и у каждого слоя может быть своя загрузка, свой каркас и своя страница сбоя.

Для сквозных вещей вроде сессии есть глобальный перехватчик hooks.server.ts, который выполняется до маршрута и наполняет locals контекстом пользователя:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
    event.locals.user = await getUser(event.cookies);
    return resolve(event);
};

Значение locals затем читается в любой загрузке, и проверка доступа превращается в одну строку. Инвалидация делает обновление честным: после действия формы SvelteKit сам перезапускает загрузки текущей страницы, и свежие значения приезжают в экран без ручного обновления.

// src/routes/products/[id]/+page.server.ts
export const load = async ({ params }) => {
    const product = await db.products.findUnique({
        where: { id: Number(params.id) },
    });
    if (!product) {
        throw error(404, "товар не найден");
    }
    return { product };
};

Загрузка выполняется и на сервере, и на клиенте при переходах, значения сериализуются и передаются в компонент как props. Это и есть преемственность идей Sapper: файл с загрузкой соседствует с файлом экрана, а параметры маршрута достаются из одного места.

Загрузка на сервере и действия форм покрывают полный цикл запроса

Серверная загрузка решает только чтение, а записи в SvelteKit делаются действиями форм. Файл +page.server.ts экспортирует объект actions, каждая ветка которого обрабатывает отправку формы, а компонент использует директиву formaction, чтобы выбрать нужную. Никакого отдельного слоя API для простых изменений не требуется.

<script>
    let { form } = $props();
</script>

<form method="POST" use:enhance>
    <textarea name="text" required></textarea>
    <button>отправить</button>
</form>

{#if form?.ok}
    <p>комментарий сохранён</p>
{/if}

<script>
    // +page.server.ts рядом с экраном
    // export const actions = { ... }
</script>

Директива enhance перехватывает отправку без перезагрузки страницы, прогресс показывается автоматически, ошибки валидации возвращаются в форму с сохранением введённого. Полный пример действия с проверкой выглядит так:

export const actions = {
    default: async ({ request }) => {
        const data = await request.formData();
        const name = String(data.get("name"));
        if (name.length < 2) {
            return fail(400, { name, error: "слишком коротко" });
        }
        await db.comments.create({ data: { name } });
        return { ok: true };
    },
};

Функция fail возвращает статус и значения обратно в форму, поле error читается в компоненте через проп form, и пользователь видит замечание без потери набранного текста. Для мутаций, которые должны остаться чистыми эндпоинтами, файл +server.ts экспортирует функции post и get, и оба мира соседствуют в одном каталоге маршрутов без единой строчки конфигурации.

Сборка на Vite и адаптеры развёртывания вместо ручного Rollup

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

Последний шаг это адаптер, который превращает проект в артефакт под конкретную площадку. adapter-node 6.0 собирает автономный серверный образ для своих виртуалок и контейнеров, adapter-static делает полностью статический сайт без серверной части, для площадок провайдеров есть свои адаптеры. Выбор фиксируется одной строкой конфигурации:

import adapter from '@sveltejs/adapter-node';

export default {
    kit: { adapter: adapter() }
};

Приложение, собранное через adapter-node, запускается командой node build и переживает любой оркестратор, статический вариант ложится на любое хранилище файлов. Смена хостинга не меняет ни одного компонента.

Отдельный рычаг это предрендеринг: страница или целый слой помечается флагом prerender и при сборке превращается в готовый HTML со значениями, вшитыми на этапе сборки. Витрина документации, лендинг и маркетинговые страницы собираются статикой, а личный кабинет остаётся серверным, и всё это один проект без разделения на два репозитория.

Практический портрет проекта от структуры до релиза

Свежий проект создаётся утилитой из официального пакета одной строкой, scaffold задаёт минимальный каркас с маршрутом-заготовкой, а дальше каталоги добавляются по мере роста. Структура быстро устаканивается: lib хранит компоненты и утилиты, routes карты приложения, hooks.server.ts глобальные перехватчики вроде проверки сессии.

Для проекта на связке Sapper с Rollup переезд выглядит пугающе только на словах. Компоненты переносятся почти без правок, маршруты перекладываются из файлов с подчёркиваниями в плюсовые файлы, preload превращается в load, а ручной конфиг сборки просто выбрасывается. Практика команд показывает недели на проект среднего размера, и это дешевле, чем вечная жизнь на замороженном фреймворке.

Переносимость кода выше, чем кажется: модули с логикой на чистом TypeScript переезжают без изменений, компоненты требуют только правки событий и мелочей разметки, а тесты на утилиты продолжают работать. Переезд удобнее делать по слоям маршрутов, запуская обе версии рядом за балансировщиком.

Итог для практики звучит так. Svelte как язык компонентов силён и в пятой версии доведён до зрелого состояния рунами. Sapper сыграл роль трамплина и честно ушёл со сцены, SvelteKit 3.1 принял эстафету целиком: та же маршрутизация по файлам, тот же серверный рендеринг, но на современном сборщике с горячей перезагрузкой и адаптерами на все случаи. Новый проект начинается с SvelteKit, наследственный переезжает на него, и в обоих случаях знания о компонентах переносятся полностью. А сообщество за это время выросло из кружка энтузиастов в индустрию с конференциями и потоком вакансий, так что ставка на Svelte больше не выглядит смелой.