Архитектура фронтенда
Разбивка кода: граф чанков — это бюджет латентности, а не выигрыш в размере
Команда «оптимизирует» дашборд: каждая панель получает свой React.lazy. Анализатор бандла выглядит шикарно — 40 крошечных чанков, ни одного больше 12 KB. Потом приходят полевые данные. LCP на 4G деградировал с 2.3s до 4.1s, CLS подскочил до 0.31, и спиннеры мигают на каждой панели. На телефоне с RTT 150 ms страница теперь делает цепочку round trip’ов, которой раньше не было: загрузить маршрут, распарсить его, затем обнаружить, что нужна панель A, загрузить её, обнаружить панель B, загрузить её. «Меньший» билд стал медленнее, потому что чанки — это запросы, а запросы на мобиле дорогие.
Разбивка — это трейдофф латентности, а не бесплатное уменьшение размера
Собрать всё в один файл — значит заставить пользователя скачать код страниц, которые он, возможно, никогда не посетит. Разбивка кода чинит это, разрезая граф на чанки, загружаемые по требованию: webpack/Rollup/Vite видят dynamic import() и выпускают отдельный файл. Меньше начального JavaScript — быстрее парсинг, быстрее Time-to-Interactive, быстрее Largest Contentful Paint.
Но ты не удалил код; ты превратил одну загрузку в несколько. Каждый чанк — это отдельный HTTP-запрос, а запрос не бесплатен: планирование соединения, латентность round trip, декомпрессия и парсинг, который может начаться только после прихода байтов. На быстром десктопе эти накладные расходы тонут в шуме. На 4G-телефоне с round trip 100–200 ms цепочка зависимых запросов — это доминирующая стоимость. Вопрос сеньора никогда не «стал ли бандл меньше?» — он «сколько последовательных round trip’ов разбивка добавила в критический путь?».
Разбивка по маршрутам — безопасный дефолт; по компонентам — скальпель
Разбивка по маршрутам — самый рычажный и наименее рискованный сплит: один чанк на страницу, грузится при навигации. Начальный маршрут отгружает только свой код; остальное приходит по мере движения пользователя. Фреймворки делают это бесплатно — Next.js, Remix и React Router режут по маршрутам автоматически. Выигрыш большой, а waterfall ограничен, потому что каждая навигация — это намеренное действие пользователя с естественным моментом загрузки.
Разбивка по компонентам (React.lazy вокруг тяжёлого виджета) — это скальпель, и правило таково: режь то, что тяжёлое и не на первой отрисовке. Богатый текстовый редактор на 200 KB, открывающийся за кнопкой — режь. Библиотека графиков на одной вкладке — режь. Карточка на 12 KB, рендерящаяся над сгибом при загрузке — никогда не режь; ты добавил round trip и спиннер ради почти ничего. Эвристика сообщества — резать только компоненты примерно от 30–50 KB; ниже накладные расходы на запрос стоят дороже сэкономленных байтов.
| Кандидат | Резать? | Почему |
|---|---|---|
| Другой маршрут / страница | Да — по маршрутам | Самый высокий рычаг; дефолт фреймворка; waterfall ограничен навигацией |
| Редактор на 200 KB за кнопкой | Да — по компонентам | Тяжёлый, под сгибом, не на первой отрисовке → отложи до взаимодействия |
| Карточка на 12 KB над сгибом | Нет | Round trip + спиннер дороже байтов; отгрузи в чанке маршрута |
Общий node_modules (React, date-либа) | Vendor-чанк | Стабилен между деплоями → долго кэшируется отдельно от кода приложения |
Request waterfall: почему переразбивка проигрывает на мобиле
Failure mode, который кусается в проде, — это waterfall. С React.lazy родительский маршрут рендерится, React упирается в lazy-границу и только тогда запрашивает дочерний чанк — последовательно. Вложи две-три lazy-границы — и получишь зависимую цепочку: чанк A должен прийти и распарситься, прежде чем браузер вообще узнает, что чанк B существует. На канале с RTT 150 ms три последовательных чанка добавляют ~450 ms чистой латентности до того, как хоть один исполнится, поверх загрузки и парсинга.
HTTP/2 помогает, но не спасает. Мультиплексирование позволяет независимым запросам делить одно соединение параллельно, поэтому много мелких чанков, выпущенных разом, скачиваются без head-of-line штрафа HTTP/1.1. Это реально — гранулярное чанкирование в Next.js использовало maxInitialRequests: 25, чтобы безопасно отгружать больше мелких чанков. Но мультиплексирование параллелит только запросы, о которых браузер знает одновременно. Lazy-waterfall последователен по построению — браузер не может загрузить чанк B параллельно, потому что ещё не распарсил чанк A. И крошечные чанки хуже сжимаются: gzip/brotli нужно окно повторяющихся байтов, поэтому разрезание ассета на 115 KB на много кусочков может потерять столько сжатия, что даже мультиплексирование не отыграет (классический тест спрайта: 10 KB вместе против 115 KB по отдельности).
Почему это работает
«HTTP/2 делает бандлинг ненужным» — миф, стоивший командам реальной производительности. Мультиплексирование убирает штраф соединения за много запросов, но не штраф последовательного обнаружения у waterfall и не штраф сжатия у мелких файлов. Вывод самой команды webpack: всё равно собирай в n чанков, где n больше 1 и сильно меньше числа модулей — найди середину, не дроби граф в пыль.
Бюджеты бандла и ловушка кэша при деплое
Сеньор задаёт бюджет и проверяет его в CI, чтобы регрессию ловил билд, а не полевые данные. Частый старт — примерно 200 KB JS и 50 KB CSS (сжатых) на маршрут, причём начальный маршрут держат строже всего, потому что он на критическом пути LCP. Над линией разбивки идёт всё нужное для первой отрисовки и интерактивности; под ней — всё за кликом, маршрутом или скроллом.
Тоньше ловушка — vendor-чанк и инвалидация кэша. Ты изолируешь node_modules в vendor-чанк именно потому, что он редко меняется — чтобы возвращающиеся пользователи держали его в кэше между деплоями. Но если бандлер хэширует vendor и код приложения вместе или инлайнит webpack runtime / мапу module-id в vendor-файл, то каждый деплой меняет хэш vendor, и каждый пользователь перекачивает React на каждый релиз. Фикс — гранулярное чанкирование: стабильный vendor-чанк, runtime в своём крошечном файле и имена с content-hash, чтобы новый URL получали только реально изменившиеся чанки — гранулярное чанкирование Next.js завезло ровно это, чтобы одно изменение приложения не инвалидировало весь vendor.
Preload критичного чанка; prefetch вероятного следующего
Если ты обязан разрезать что-то на критическом пути, не дай браузеру обнаружить это поздно. Два resource hint закрывают разрыв. <link rel="preload"> (или modulepreload для ES-модулей) говорит браузеру загрузить чанк сейчас, параллельно документу, выравнивая waterfall до того, как он сформируется — используй для чанка, который текущая страница точно потребует. <link rel="prefetch"> загружает с низким приоритетом для будущей навигации — например, prefetch чанка дашборда при наведении на его ссылку, чтобы клик ощущался мгновенно. Фреймворки автоматизируют это: Next.js делает prefetch целей <Link> в вьюпорте, а import() внутри next/dynamic позволяет ему внедрить preload до рендера.
Производственная авария, которую это предотвращает, — lazy-загрузка над сгибом. Сделай lazy герой или главную панель — и пользователь сначала видит Suspense-фолбэк (спиннер или пустой блок), потом реальный компонент впрыгивает, сдвигая всё под ним. Это удар по Cumulative Layout Shift; один реальный кейс ушёл с CLS 0.85 до 0.1 просто за счёт отказа от lazy над сгибом и резервирования места под то, что грузится. Пороги, которые ты защищаешь: LCP меньше 2.5s, CLS меньше 0.1, INP меньше 200 ms. Спиннер над сгибом атакует все три разом.
Страница товара должна показать интерактивный 3D-просмотрщик на 180 KB, который сидит под сгибом и открывается, только когда пользователь доскроллит до него. Выбери стратегию загрузки для 4G-аудитории.
На 4G-телефоне с RTT 150 ms почему билд из 40 крошечных lazy-чанков может быть медленнее одного бандла?
Возвращающиеся пользователи перекачивают React на каждый деплой, хотя зависимости не менялись. Вероятная причина?
Расставь решения, которые сеньор принимает, добавляя разбивку кода на маршрут:
- 1 Сначала режь по маршрутам — крупнейший выигрыш, дефолт фреймворка, waterfall ограничен навигацией
- 2 Найди тяжёлые (~30–50 KB+) компоненты, что под сгибом или за взаимодействием
- 3 React.lazy только их; контент над сгибом оставь в чанке маршрута
- 4 Preload критичных отложенных чанков; prefetch вероятных следующих маршрутов на hover/idle
- 5 Изолируй vendor-чанк с content-hash, чтобы деплои не перекачивали зависимости
- 01Коллега хочет обернуть каждый компонент на странице в React.lazy, «чтобы бандл стал меньше». Объясни, почему это может сделать мобильную страницу медленнее, а не быстрее.
- 02Почему изоляция зависимостей в vendor-чанк помогает кэшированию, и как она случайно бьёт по тебе на каждом деплое?
Разбивка кода — это трейдофф латентности, а не бесплатное уменьшение размера: она превращает одну загрузку в много запросов, и каждый запрос стоит round trip, который сильнее всего чувствуешь на мобильном канале с высокой латентностью. Разбивка по маршрутам — безопасный, высокорычажный дефолт: один чанк на страницу, waterfall ограничен навигацией, и фреймворки делают это за тебя. Разбивка по компонентам — скальпель для того, что и тяжёлое (примерно от 30–50 KB), и не на первой отрисовке; резать маленький компонент над сгибом — просто купить round trip и спиннер. Классический производственный провал — переразбивка в последовательный waterfall, проигрывающий бандлу на 4G, или lazy над сгибом со сдвигом макета — следи за LCP меньше 2.5s, CLS меньше 0.1, INP меньше 200 ms. Задай бюджет бандла (примерно 200 KB JS на маршрут) и проверяй его в CI, делай preload критичных отложенных чанков и prefetch вероятных следующих маршрутов, изолируй vendor-чанк с content-hash и вынесенным runtime, чтобы одно изменение приложения не заставляло каждого пользователя перекачивать React. Граф чанков — это бюджет, который ты проектируешь, а не число, которое минимизируешь.