Почему сайт тормозит даже на быстром интернете
Вы открываете сайт, смотрите на счётчик загрузки и думаете: зачем ждать? Интернет быстрый, сервер нормальный, дизайн не перегружен. Но браузер всё равно грузит что-то три секунды. Скорее всего, дело в том, что сайт загружает всё сразу — включая то, что пользователь никогда не увидит.
Типичная история: разработчик собрал всё в один бандл. Страница приветствия грузит код модального окна для регистрации, страницу профиля, административную панель и библиотеку для графиков, которая показывается только в разделе аналитики. Пользователь открыл главную — а браузер уже тащит мегабайты ненужного.
Два инструмента помогают это исправить: lazy loading и code splitting.
Что такое code splitting
Code splitting — это разделение JavaScript-бандла на части. Вместо одного огромного файла браузер получает несколько маленьких и загружает только нужные.
Без code splitting:
bundle.js — 1.2 MB
С code splitting:
main.js — 120 KB (загружается сразу)
page-catalog.js — 340 KB (загружается при переходе в каталог)
page-checkout.js — 180 KB (загружается при оформлении заказа)
admin.js — 560 KB (загружается только для администраторов)
Пользователь, который зашёл на главную и ушёл, скачал 120 KB вместо 1.2 MB — в 10 раз меньше.
Как это работает в Webpack и Vite
В Webpack code splitting включается через динамический импорт:
// Статический импорт — код попадёт в основной бандл
import HeavyComponent from './HeavyComponent';
// Динамический импорт — будет отдельным чанком
const HeavyComponent = () => import('./HeavyComponent');
В React это выглядит так:
import { lazy, Suspense } from 'react';
const AdminPanel = lazy(() => import('./AdminPanel'));
function App() {
return (
<Suspense fallback={<div>Загрузка...</div>}>
<AdminPanel />
</Suspense>
);
}
Vite делает code splitting автоматически — каждый динамический импорт становится отдельным чанком. Настраивать почти ничего не нужно.
Route-based splitting — самый простой способ начать
Если у вас SPA (React, Vue, Angular), начните с разделения по маршрутам. Каждая страница — отдельный чанк:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Catalog = lazy(() => import('./pages/Catalog'));
const Checkout = lazy(() => import('./pages/Checkout'));
function App() {
return (
<Suspense fallback={<Loader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/catalog" element={<Catalog />} />
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Suspense>
);
}
Пользователи, которые никогда не доходят до оформления заказа, не скачивают его код.
Что такое lazy loading
Lazy loading — загрузка ресурсов только тогда, когда они нужны. Чаще всего это применяют к изображениям: не грузить картинку, пока она за пределами экрана.
Lazy loading изображений
Нативный способ — атрибут loading="lazy" в HTML:
<img src="photo.jpg" loading="lazy" alt="Описание" width="800" height="600">
Браузер сам решит, когда загрузить картинку — обычно когда до неё остаётся 1000–1500 пикселей прокрутки. Поддержка: Chrome 76+, Firefox 75+, Safari 15.4+.
Важный момент: всегда указывайте width и height. Без них браузер не знает размер картинки до загрузки и не может зарезервировать место — получится Cumulative Layout Shift (прыгающий контент), который портит Core Web Vitals.
Что не нужно грузить лениво:
- Картинки выше fold — те, что видны сразу при открытии
- Логотип
- Hero-изображение
Для них просто не указывайте атрибут или используйте loading="eager".
Lazy loading через Intersection Observer
Если нужно больше контроля:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
rootMargin: '200px' — начать загрузку за 200px до того, как элемент появится на экране. Убирает эффект белого прямоугольника при быстрой прокрутке.
Lazy loading компонентов
Та же идея работает для UI-элементов. Модальное окно, которое открывается при клике — зачем грузить его код при загрузке страницы?
// Vue 3
const Modal = defineAsyncComponent(() => import('./Modal.vue'));
// Загружается только когда пользователь нажал кнопку
Особенно актуально для тяжёлых зависимостей. Если в модалке используется редактор кода или библиотека для работы с PDF — их код весит несколько сотен килобайт. Грузить это нужно только тогда, когда пользователь реально открыл окно.
Реальные цифры: что даёт оптимизация
Конкретный пример: интернет-магазин одежды.
До оптимизации:
- Один бандл: 1.8 MB
- First Contentful Paint (FCP): 3.2 сек
- Time to Interactive (TTI): 5.8 сек
- Lighthouse Performance: 42/100
После code splitting и lazy loading изображений:
- Начальный бандл: 210 KB
- FCP: 1.1 сек
- TTI: 2.4 сек
- Lighthouse Performance: 78/100
Время до интерактивности упало с почти 6 секунд до 2.4. По данным Google, каждая дополнительная секунда загрузки увеличивает процент отказов на 7–12%. Для среднего магазина это десятки тысяч рублей недополученной выручки в месяц.
Как анализировать бандл
Прежде чем оптимизировать — нужно понять, что именно много весит.
Webpack Bundle Analyzer
npm install --save-dev webpack-bundle-analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [new BundleAnalyzerPlugin()]
};
Открывается интерактивная карта, где видно, какие модули занимают место. Часто оказывается, что 40% бандла — одна библиотека, которую можно заменить более лёгкой альтернативой.
Vite — rollup-plugin-visualizer
npm install --save-dev rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [visualizer({ open: true })]
};
Chrome DevTools Network
Самый быстрый способ: DevTools → Network → перезагрузить страницу → отсортировать по размеру. Сразу видно, что грузится и сколько весит. Смотрите на колонку Waterfall — там видно, что грузится параллельно, а что блокирует рендеринг.
Распространённые ошибки
Дробить слишком мелко
Code splitting создаёт HTTP-запросы. Если разбить приложение на 200 чанков по 5 KB — это хуже, чем один файл 1 MB. HTTP/2 позволяет параллельные запросы, но overhead всё равно есть. Оптимально: чанки от 30–50 KB и выше.
Не кэшировать правильно
Смысл code splitting частично в том, что чанки, которые не менялись, браузер берёт из кэша. Это работает только если в именах чанков есть хэш:
vendor.a1b2c3d4.js ← хорошо, хэш меняется только при изменении контента
vendor.js ← плохо, браузер не знает, изменился ли файл
В Webpack настраивается через output.filename, в Vite работает из коробки.
Lazy loading для всего подряд
Не каждый компонент нужно грузить лениво. Кнопка «Добавить в корзину» с задержкой загрузки — это хуже, чем если бы она просто была в бандле. Ленивая загрузка нужна для:
- Тяжёлых компонентов: редакторы, графики, карты
- Компонентов, которые видит меньшинство пользователей
- Функционала за авторизацией
- Модалок и сайдбаров
Забыть про prefetching
После загрузки основной страницы можно в фоне начать тянуть следующую:
// Webpack magic comment
const Catalog = lazy(() => import(/* webpackPrefetch: true */ './pages/Catalog'));
Браузер скачает файл с низким приоритетом. Когда пользователь кликнет — страница откроется мгновенно.
Разница: prefetch — вероятно понадобится позже, низкий приоритет; preload — нужно прямо сейчас, высокий приоритет.
CSS и шрифты тоже оптимизируются
CSS splitting
В больших проектах стили тоже разделяют по маршрутам. MiniCssExtractPlugin в Webpack или встроенные возможности Vite позволяют подключать CSS только для текущей страницы.
Шрифты
Подключение Google Fonts через <link> блокирует рендеринг. Используйте font-display: swap:
@font-face {
font-family: 'MyFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}
С swap браузер сначала показывает текст системным шрифтом, потом заменяет на загруженный. Пользователь видит контент сразу.
Изображения: форматы
Lazy loading решает вопрос «когда грузить». Отдельно стоит разобраться с «что грузить».
WebP весит в среднем на 25–35% меньше JPEG при том же качестве. AVIF ещё легче, но поддержка чуть хуже.
<picture>
<source srcset="image.avif" type="image/avif">
<source srcset="image.webp" type="image/webp">
<img src="image.jpg" alt="Описание" loading="lazy" width="800" height="600">
</picture>
Браузер возьмёт первый поддерживаемый формат.
Когда это реально нужно
Всё вышесказанное не нужно для сайта-визитки на 5 страниц с парой картинок. Там особо нечего оптимизировать.
Code splitting и lazy loading актуальны когда:
- SPA с множеством маршрутов
- Много тяжёлых зависимостей: редакторы, графики, карты
- Интернет-магазин с каталогом и сотнями изображений
- Часть функционала доступна только авторизованным пользователям
- Lighthouse Performance ниже 70
Для обычного корпоративного сайта на Next.js code splitting уже встроен, lazy loading изображений делается одним атрибутом. Базовая оптимизация занимает пару часов.
Для сложного React-приложения — это полноценная задача с анализом бандла, настройкой чанков и тестированием. Если заказываете разработку, уточните: делает ли студия code splitting по умолчанию или это нужно просить отдельно. В REEXY оптимизация загрузки входит в стандартный процесс — при разработке интернет-магазина от 10 000 ₽ изображения подключаются правильно, бандл не превращается в один монолитный файл.
Чек-лист быстрой проверки
Проверьте свой сайт прямо сейчас:
- Откройте Chrome DevTools → Network → перезагрузите страницу
- Посмотрите на общий размер загруженных ресурсов (строка внизу)
- Отфильтруйте по JS — если один файл больше 500 KB, нужен code splitting
- Отфильтруйте по Img — если все картинки грузятся сразу, нужен lazy loading
- Проверьте Lighthouse: F12 → вкладка Lighthouse → Analyze page load
- Проверьте наличие
loading="lazy" у изображений ниже fold через вкладку Elements
Если Performance ниже 70 — есть конкретные точки для улучшения. Большинство из них поправляется за несколько часов работы.