Svelte
Производительность Svelte
Дашборд аналитики рендерит таблицу на 10 000 строк. Каждый тик WebSocket приходит новый снимок, разработчик кладёт весь массив в `$state`, и Svelte рекурсивно оборачивает в прокси каждый объект и каждое вложенное поле. Прокрутка дёргается, профайлер показывает, что время уходит не на отрисовку, а на создание тысяч реактивных прокси на данные, которые целиком меняются раз в секунду. Замена `$state` на `$state.raw` и ключи по id в each возвращают плавность - реактивность перестаёт работать там, где она не нужна.
- Svelte стабильно держит верхние строчки бенчмарка js-framework-benchmark по размеру бандла и времени старта среди мейнстрим-фреймворков
- Компилятор Svelte 5 генерирует код на сигналах без virtual DOM, поэтому рантайм-обвязка минимальна и tree-shaking выкидывает неиспользуемое
- Дашборды (биржевые терминалы, мониторинг) - типичный случай, где наивный `$state` на больших массивах упирается в стоимость прокси
- Chrome DevTools Performance и встроенный Lighthouse - стандартные инструменты, которыми меряют реальную производительность Svelte-приложений
- Виртуализация списков (рендер только видимых строк) - решение проблемы 10 000 DOM-узлов, ортогональное самому Svelte
Предварительные знания
- Гранулярная реактивность Svelte 5: `$state`, `$derived`, `$effect` и идея сигналов
- Понимание, что `$state` оборачивает объекты и массивы в реактивные прокси
- Базовое знание блока each и того, что список можно перерисовывать при изменении данных
Откуда маленький бандл
Большинство фреймворков отправляют в браузер рантайм-движок: код, который во время выполнения сравнивает деревья (virtual DOM) и решает, что обновить. Svelte переносит эту работу на этап сборки. Компилятор читает компонент и генерирует адресные инструкции: при изменении конкретного значения обновить конкретный текстовый узел. В браузер едет результат, а не интерпретатор.
- Рантайм-фреймворк (virtual DOM) — В бандле едет движок diff-алгоритма. Он одинаков для всех приложений, поэтому базовая стоимость фиксирована и платится всегда, даже за hello world.
- Компилятор (Svelte) — Каждый компонент превращается в небольшой код прямых обновлений. Нет общего движка diff, базовая стоимость близка к нулю, а tree-shaking выкидывает неиспользуемые фичи.
Компилятор знает, что в DOM меняются ровно два места: текст внутри h1 (при смене name) и текст внутри button (при смене count). Он генерирует код, который при изменении count трогает только текстовый узел кнопки. Нет ни сравнения деревьев, ни перерисовки всего компонента. Это и даёт маленький бандл и быстрые обновления одновременно.
Важная оговорка: маленький бандл - не магия, которая сохраняется при любом стиле кода. Тяжёлая зависимость (большая библиотека дат, чарты, редактор кода) весит столько же, сколько весит, независимо от фреймворка. Преимущество Svelte в базовой стоимости самого фреймворка, а не в обнулении прикладного кода.
Почему базовый бандл Svelte-приложения меньше, чем у фреймворка на virtual DOM?
Не переусердствовать с реактивностью
Реактивность не бесплатна. Каждый `$state` с объектом создаёт прокси, каждый `$effect` подписывается на зависимости и запускается при их изменении. Частая ошибка - тянуться к `$effect` там, где нужно вычисляемое значение. `$derived` ленив и пересчитывается только при чтении, а `$effect` запускается на каждое изменение зависимости, даже если результат никому не нужен прямо сейчас.
| Инструмент | Когда применять | Стоимость |
|---|---|---|
| обычная let | Значение не участвует в разметке и не меняется реактивно | Нулевая, никакой обвязки |
| $derived | Значение вычисляется из другого состояния | Ленивый пересчёт при чтении, дублирования нет |
| $effect | Нужен побочный эффект: лог, запрос, работа с не-Svelte API | Запуск на каждое изменение зависимостей |
Использование `$effect` для синхронизации одного состояния с другим почти всегда признак ошибки: появляется дубль источника правды и риск каскадных перезапусков. Для производных значений существует `$derived`, для побочных эффектов с внешним миром - `$effect`.
Значение total нужно вычислять из price и qty и показывать в разметке. Что выбрать?
Ключи в each и `$state.raw` для больших данных
Когда список меняется, Svelte должен сопоставить старые элементы с новыми. Без ключа он сопоставляет по позиции: если элемент вставлен в начало, все последующие узлы считаются изменёнными и обновляются зря. Ключ (item.id) говорит Svelte, какой DOM-узел соответствует какому элементу данных, и узлы переиспользуются, а не пересоздаются.
Вторая проблема больших списков - стоимость прокси. Обычный `$state` рекурсивно оборачивает каждый объект массива в реактивный прокси, чтобы отслеживать изменения отдельных полей. Для 10 000 строк, которые приходят с сервера целым снимком и не мутируются по полям, это лишняя работа. `$state.raw` делает реактивной саму ссылку (замена массива целиком триггерит обновление), но не оборачивает содержимое в прокси.
Граница простая. Данные мутируются точечно (пользователь редактирует поле строки) - нужен обычный `$state`, чтобы отследить изменение поля. Данные приходят снимком и заменяются целиком - подходит `$state.raw`: реактивность на уровне ссылки, без затрат на тысячи прокси. Для 10 000 DOM-узлов дополнительно применяют виртуализацию, рендеря только видимые строки.
С сервера каждую секунду приходит снимок на 10 000 строк, который целиком заменяет предыдущий и не редактируется по отдельным полям. Какое состояние выбрать?
Измерять, а не угадывать
Оптимизация вслепую тратит время на места, которые ничего не стоят, и пропускает реальное узкое горлышко. Сначала измерение, потом правка. Для размера бандла - вывод сборщика и анализаторы зависимостей. Для рантайма - вкладка Performance в Chrome DevTools: она показывает, где уходит время, в скрипте, в layout или в перерисовке. Lighthouse даёт пользовательские метрики загрузки.
| Что меряем | Инструмент | Что покажет |
|---|---|---|
| Размер бандла | Вывод vite build, rollup-plugin-visualizer | Какие модули весят больше всего |
| Рантайм-затраты | Chrome DevTools Performance | Время в скриптинге, layout, paint; долгие задачи |
| Загрузка страницы | Lighthouse, web-vitals | LCP, INP, CLS - метрики реального пользователя |
| Регрессии в CI | Бюджет бандла в сборке | Падение билда при превышении порога размера |
Полезное правило: оптимизировать имеет смысл то, что измерено и подтверждено профайлером. Замена `$state` на `$state.raw` оправдана, когда профиль показывает время в создании прокси; на списке из десяти элементов разницы не будет, а код станет сложнее без причины.
С чего разумно начать работу над производительностью медленной страницы?
Связь с другими темами
Производительность - прикладной слой поверх модели реактивности. Связи:
- Внутренности компилятора — Маленький бандл и скорость - прямое следствие того, что компилятор превращает .svelte в адресный код на сигналах
- Гранулярная реактивность — Стоимость прокси и переусложнённой реактивности понятна только при знании, как устроен сигнальный граф
Итог
- Малый бандл Svelte - следствие компиляции: разметка превращается в адресные обновления DOM, а не в рантайм с virtual DOM
- Переусердствование с `$state` и `$effect` стоит дорого: лишние прокси и эффекты создают работу там, где хватило бы обычной переменной или `$derived`
- Ключ в each (each items as item (item.id)) позволяет Svelte переиспользовать узлы вместо пересоздания при изменении списка
- $state.raw делает значение реактивным целиком, но не оборачивает содержимое в прокси - выбор для больших данных, которые заменяются, а не мутируются по полям
- Меряют не на глаз: Chrome DevTools Performance, Lighthouse и сравнение размера бандла до и после, иначе оптимизация идёт вслепую
Связанные уроки
- sv-43-compiler-internals — Производительность Svelte растёт из компилятора - урок о внутренностях показывает, какой код генерируется и почему он быстрый
- sv-37-testing-vitest-playwright — Перед оптимизацией нужны тесты как сетка безопасности, чтобы ускорение не сломало поведение