awesome-everything EN
↑ Обратно к восхождению

Архитектура фронтенда

Разбивка кода: граф чанков — это бюджет латентности, а не выигрыш в размере

Суть Разбивка меняет одну большую загрузку на много мелких. По маршрутам она окупается; по компонентам на телефоне с высокой латентностью она строит request waterfall и проигрывает тому самому бандлу, который ты пытался победить.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Команда «оптимизирует» дашборд: каждая панель получает свой 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. 1 Сначала режь по маршрутам — крупнейший выигрыш, дефолт фреймворка, waterfall ограничен навигацией
  2. 2 Найди тяжёлые (~30–50 KB+) компоненты, что под сгибом или за взаимодействием
  3. 3 React.lazy только их; контент над сгибом оставь в чанке маршрута
  4. 4 Preload критичных отложенных чанков; prefetch вероятных следующих маршрутов на hover/idle
  5. 5 Изолируй vendor-чанк с content-hash, чтобы деплои не перекачивали зависимости
Вспомните перед уходом
  1. 01
    Коллега хочет обернуть каждый компонент на странице в React.lazy, «чтобы бандл стал меньше». Объясни, почему это может сделать мобильную страницу медленнее, а не быстрее.
  2. 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. Граф чанков — это бюджет, который ты проектируешь, а не число, которое минимизируешь.

Продолжить восхождение ↑Code splitting: тест с множественным выбором
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.