За пределами размера пакета: выявление причин замедления веб-приложения
Почему удаление килобайт редко помогает ускорить медленное приложение, и как отслеживать реальное время ожидания в сети серверов, через последовательные этапы обработки, механизмы загрузки ресурсов, скрипты сторонних разработчиков и изображения.
В командах, занимающихся фронтендом, часто возникает такая ситуация: недели уходят на сокращение размера JavaScript-бандла на 40 КБ, в то время как запрос к базе данных продолжительностью 900 миллисекунд остается без изменений на критическом пути обработки запросов. Заменяется библиотека иконок, обновляется зависимость, настраивается дополнительный плагин для сборки кода, и начинается спор о том, весит ли пакет 18 КБ или 12 КБ. А потом кто-то загружает приложение на настоящий телефон через реальную сеть, и оно по-прежнему кажется медленным.
В этой статье объясняется, почему размер бандла так часто становится неверной целью, откуда на самом деле берется задержка и как запустить цикл оптимизации, который сначала устраняет самые серьезные проблемы. Меньшие размеры бандлов действительно помогают, иногда даже сильно. Но если страница медленная из-за ожидания ответа от сервера, блокировки процесса отрисовки, выполнения ненужных операций, отправки слишком большого количества запросов или загрузки огромной структуры компонентов, сокращение размера еще на 20 КБ не поможет ей стать быстрее.
Почему размер пакета стал стандартной целью по повышению производительности
Размер пакета привлекателен, потому что это число. Результат сборки выглядит примерно так:
main.js 842 KB
vendor.js 611 KB
styles.css 94 KB
Кто-то предлагает сократить размер JavaScript до 500 КБ, и вдруг у команды появляется цель. Её можно реализовать в системах CI, отслеживать при отправке pull-реквестов и радоваться каждому сокращению. Это кажется прогрессом в инженерии, и иногда так оно действительно и есть.
Проблемы начинаются тогда, когда число перестаёт быть симптомом и становится самой целью. Команды склонны оптимизировать ту часть производительности, которую они могут видеть наиболее чётко, а не ту, которая стоит пользователей больше всего времени. Чтобы понять, почему это важно, сравним два гипотетических приложения.
Приложение А: небольшой пакет, медленная работа всего остального
Первое приложение поставляется с компактным пакетом:
JavaScript: 250 KB
Однако всё остальное в нём работает медленно:
Server response: 1.2s
Database query: 700ms
API calls before rendering: 5
Main-thread work: 900ms
Приложение B: большой пакет, быстрый доступ к контенту
Второе приложение включает почти в три раза больше JavaScript-кода:
JavaScript: 700 KB
Но и сервер, и клиент выполняют гораздо меньше операций до того, как страница становится полезной:
Server response: 150ms
Database query: 40ms
API calls before rendering: 1
Main-thread work: 180ms
Приложение A не обязательно выигрывает. Приложение в стиле B часто кажется быстрее, поскольку оно тратит примерно на секунду меньше времени на обработку на сервере, работу с базой данных, последовательные запросы и операции на главном потоке, что значительно перевешивает дополнительный размер загрузки для большинства пользователей с нормальным соединением. Принцип, на котором должна основываться любая дискуссия о производительности, прост: пользователи не воспринимают количество килобайт, они воспринимают время ожидания.
Загрузка страницы — это длинная цепочка операций, а не три этапа
Многие разработчики имеют в голове упрощенную модель загрузки:
Download JavaScript
↓
Execute JavaScript
↓
Page appears
Настоящий запрос проходит через гораздо больше этапов, каждый из которых может вызвать задержку:
DNS
↓
Connection
↓
TLS
↓
Request
↓
Server processing
↓
Database
↓
Response
↓
HTML parsing
↓
CSS processing
↓
JavaScript download
↓
JavaScript parsing
↓
JavaScript execution
↓
Hydration
↓
API requests
↓
Rendering
↓
Layout
↓
Paint
Поиск DNS, установление соединения и согласование протокола TLS происходят до того, как сервер что-либо увидит. Затем идет обработка данных на сервере и работа с базой данных. После этого браузер парсит HTML, обрабатывает CSS, загружает, парсит и выполняет JavaScript, адаптирует интерфейс, отправляет запросы к API, и только затем отрисовывает элементы, формирует их макет и окрашивает. А когда пользователь нажимает на что-либо, цикл повторяется снова.
Учитывая столь большое количество этапов, бандл является одним из мест, где может потеряться время. Фраза «сделать бандл меньше» — плохой первый шаг, поскольку она предполагает решение проблемы до того, как вы поймете, где именно теряется время.
Сервер может быть самой медленной частью фронтенда
Инженеры фронтенда естественным образом рассматривают производительность как проблему браузера. Они открывают DevTools, просматривают вкладку Network и фрагменты JavaScript, а также запускают инструмент Lighthouse. Но значительная часть воспринимаемой скорости фронтенда определяется ещё до того, как браузер получит что-либо полезное.
Возьмём запрос к панели управления:
GET /dashboard
За его спиной сервер может выполнить всё это перед тем, как ответить:
→ authenticate user
→ fetch organization
→ fetch permissions
→ query projects
→ query project statistics
→ query notifications
→ calculate recommendations
→ render response
Если вся эта последовательность занимает 1,4 секунды, уменьшение размера пакета с 600 КБ до 500 КБ едва влияет на пользовательский опыт, поскольку первые значимые данные всё равно приходят через 1,4 секунды. Браузер не может отрисовать данные, которые он ещё не получил.
Частой причиной является код бэкенда, который ожидает выполнения отдельных операций одну за другой. Всё начинается с получения информации об пользователе:
const user = await getUser();
Далее следует цепочка дополнительных операций ожидания организации, проектов и уведомлений, причем каждая из них ожидает завершения предыдущей:
const organization = await getOrganization(user.orgId);const projects = await getProjects(organization.id);const notifications = await getNotifications(user.id);
Некоторые из этих шагов действительно взаимозависимы: для поиска организации требуется user.orgId, а проектам нужен идентификатор организации. Однако операции, которые не зависят от результата предыдущих шагов, напрасно тратят время в серийном порядке. Когда операции независимы, их одновременное запускание и совместное ожидание позволяют значительно сократить время отклика:
const [user, notifications] = await Promise.all([
getUser(),
getNotifications()
]);
Обратите внимание, что параллельная версия вызывает getNotifications() без идентификатора пользователя. Это работает только в том случае, если уведомления можно получить из уже имеющихся данных, например, сессии; в противном случае этот вызов всё равно должен ждать пользователя. Общее правило заключается в том, чтобы отразить реальную диаграмму зависимостей и выполнять каждый её уровень одновременно. Чтобы узнать больше о выборе между этими подходами, ознакомьтесь с нашим руководством Promise.all, Promise.race и последовательные awaits. Подобные изменения могут значительно улучшить производительность по сравнению с любой работой с бандлами.
Модель «водопада» стоит дороже, чем просто байты
Одним из наиболее эффективных мест для начала исследования является вкладка «Сеть», а не анализатор бандлов. Очень распространённый подход выглядит следующим образом:
HTML
↓
JavaScript
↓
API A
↓
API B
↓
API C
↓
API D
Каждый шаг ожидает предыдущего, и каждая передача данных добавляет задержку из-за обратного пути. Сравните это с дизайном, при котором первый ответ уже содержит всё необходимое для страницы:
HTML
↓
API response containing everything required
Вторая версия может передавать больше байтов, но при этом быть значительно быстрее. Байты и задержки — это разные проблемы. Ответ объемом 100 КБ, приходящий сразу, может превзойти ответ объемом 20 КБ, который требует четырех последовательных обратных путей, прежде чем страница сможет выполнять какие-либо полезные действия, особенно в сетях мобильной связи, где каждый обратный путь стоит дорого.
Поэтому, когда вы замечаете, что конечная точка возвращает 300 КБ, первым порывом является сокращение объема данных. Это может быть целесообразно, но лучшим вопросом будет: почему пользователю вообще нужен этот ответ, прежде чем он сможет взаимодействовать с страницей? Ответ часто показывает задачи, которые можно отложить или полностью устранить.
Эффективность JavaScript важнее его размера
Ещё одна распространенная ошибка — путать размер JavaScript-кода с объемом работы, которую он выполняет. Пакет размером 500 КБ не обязательно является катастрофой. Важно то, что должен сделать браузер с этим кодом:
- Скачать его.
- Разобрать на составные части.
- Скомпилировать его.
- Запустить его.
- Сформировать состояние приложения.
- Создать деревья компонентов.
- Прикрепить обработчики событий.
- Актуализировать маркиупт, сгенерированный на сервере.
- Пересчитать макет.
- Отобразить результат.
Два приложения с похожим размером пакета могут сильно отличаться по стоимости выполнения. Возьмем таблицу с 5 000 строками — проблема редко кроется в самих данных; обычно проблема заключается в отрисовке DOM-узлов для 5 000 интерактивных строк. Решение не в сокращении объема скрипта на 50 КБ, а в отрисовке только тех примерно 30 строк, которые видны в данный момент. Эта техника, называемая виртуализацией, позволяет сохранить то же приложение и те же данные, одновременно снижая объем работы браузера.
Когда гидратация становится узким местом
Это различие особенно заметно в React и других фреймворках компонентов, которые отрисовывают содержимое на сервере. Отрисовка на сервере позволяет быстро отобразить HTML на экране, но затем браузеру может потребоваться обработать большое дерево компонентов, прежде чем он сможет отреагировать на ввод:
HTML arrives quickly
↓
User sees content
↓
Browser starts hydration
↓
Large amount of JavaScript executes
↓
Page becomes interactive
Страница кажется готовой задолго до того, как на самом деле становится готовой. Именно поэтому оценка состояния только при первом появлении контента может ввести в заблуждение. Панель управления с 200 интерактивными компонентами может генерировать абсолютно нормальный HTML, но при обработке данных все равно сильно загружать процессор, из-за чего клики остаются без ответа.
Полезный вопрос здесь заключается не в том, слишком ли большой пакет данных, а в том, почему столько кода должно сразу становиться интерактивным. Некоторым компонентам вообще может не требоваться клиентский JavaScript. Некоторые интеракции можно изолировать в отдельные части. Некоторые виджеты могут загружаться позже, а некоторым компонентам, генерируемым на сервере, вообще может не понадобиться процесс гидратации. Такие подходы, которые рассматриваются в нашем обзоре частичной предварительной обработки и одновременной отрисовки, дают гораздо больший эффект, чем простые споры о размере зависимостей в 30 КБ.
Скрипты от сторонних разработчиков часто имеют больший вес, чем собственный код
Перед запуском кампании большого масштаба проверьте, какая часть кода была написана кем-то ещё. Типичные примеры:
- инструменты аналитики
- виджеты чата
- хит-карты
- тестирование типа A/B
- реклама
- инструменты поддержки клиентов
- запись сессий
- встраиваемые элементы из соцсетей
- маркетинговые пиксели
- управление согласием пользователя
Каждый из этих элементов может добавлять запросы, выполнение скриптов, работу с макетом и сетевую активность. Ирония в том, что эти скрипты часто практически не проходят проверку, в то время как инженеры тратят часы на отладку кода приложения. Страница может загружать подобный набор элементов:
app.js
analytics.js
chat.js
tracking.js
experimentation.js
heatmap.js
Команда радуется сокращению размера файла app.js на 70 КБ, хотя страница по-прежнему выполняет сотни килобайт кода от сторонних поставщиков. Именно поэтому бюджеты на производительность должны иметь более широкий охват. Вместо того чтобы спрашивать, насколько велик размер собранного пакета кода, следует спрашивать, сколько кода должен обработать устройство пользователя, прежде чем страница станет полезной. На эти два вопроса даются совершенно разные ответы.
Изображения могут перекрыть весь бюджет на JavaScript
Чрезмерно большие изображения — ещё одна проблема, которая остаётся без внимания. Одно крупное изображение может превзойти по размеру весь оптимизированный фрагмент JavaScript-кода:
main.js 180 KB
hero.webp 1.4 MB
product.jpg 900 KB
background.png 2.1 MB
Если патч с удалением зависимости размером 12 КБ принимается, в то время как фоновое изображение размером 2,1 МБ остаётся без изменений, команда занимается лишь формальным определением приоритетов, а не реальной оптимизацией. Изображения заслуживают такого же строгого подхода, как и код:
- Предпочитайте современные форматы, такие как WebP или AVIF, там где они поддерживаются и уместны.
Экономия 15 КБ JavaScript имеет незначительное значение, если телефон всё равно загружает 2 МБ изображения, которое пользователь едва замечает.
Многие проблемы с производительностью — это проблемы архитектуры
Чем глубже мы заглядываем, тем яснее становится, что многие проблемы с производительностью вообще не связаны с настройкой кода. Они возникают из-за структуры приложения. Представьте страницу продукта, которой требуется всё это:
Product
Reviews
Recommendations
Inventory
Shipping estimate
User preferences
Related products
Если каждый элемент загружается отдельно после загрузки страницы, можно оптимизировать каждый запрос, но страница всё равно будет работать медленно. Лучше сначала определить, что должен увидеть пользователь. Первоначальный ответ может включать только:
Product
Price
Availability
Primary image
Отзывы, рекомендации и связанные товары могут загружаться позже. В этот момент вы уже не оптимизируете реализацию, а переопределяете понятие «готовности» страницы, и именно здесь часто достигаются наибольшие улучшения.
Измеряйте те этапы, которые действительно замечают пользователи
Настоящая работа над производительностью заменяет вопрос «насколько большой пакет?» на вопрос «когда пользователь сможет выполнить что-то полезное?». Этот вопрос позволяет использовать более точные показатели.
Время до появления первого полезного контента
Когда пользователь видит то, ради чего пришел? Это часто зависит от конкретного продукта: остаток средств на счету, результаты поиска, фото товара.
Время до взаимодействия
Когда пользователь может надежно взаимодействовать, без того чтобы клики терялись из-за выполняющихся операций? В новых версиях Lighthouse TTI больше не учитывается в оценке, но этот показатель по-прежнему важно отслеживать для собственных страниц.
Время отображения основного контента
Когда завершается отрисовка основного видимого контента?
Время от взаимодействия до следующей отрисовки
Насколько быстро интерфейс визуально реагирует после действия пользователя?
Накопленное смещение макета
Изменяется ли макет во время попытки пользователя прочитать текст или совершить клик?
Общее время блокировки
Насколько долго основной поток заблокирован операциями, мешающими браузеру реагировать на ввод?
Ни один из этих факторов сам по себе не раскрывает всей картины, но вместе они гораздо лучше описывают происходящее, чем одна-единственная фраза вроде этой:
bundle.js = 487 KB
Показатель объема данных описывает ресурс. Показатели производительности показывают, что испытывает пользователь.
Цикл оптимизации, заслуживающий доверия
Когда необходимо устранить проблемы с медленным приложением, удаление зависимостей не должно быть первым шагом. Более эффективен дисциплинированный цикл.
1. Воспроизведите ситуацию в реалистичных условиях
Тестируйте на типичных устройствах и сетях, а не только на быстром ноутбуке через офисный Wi-Fi. У многих ваших пользователей нет ни того, ни другого.
2. Измеряйте, чтобы выявить медленные этапы
Определите, куда уходит время. Является ли основной проблемой один из этих факторов?
server response?
network?
rendering?
JavaScript execution?
layout?
images?
third-party scripts?
3. Определите основную причину затрат
Не старайтесь исправить пять проблем сразу. Найдите наиболее значимый фактор, влияющий на производительность.
4. Измените одну вещь
Внесите наименьшие архитектурные или технические изменения, которые помогут устранить этот узкое место, чтобы можно было оценить их результат.
5. Снова измерьте
Если улучшения не отразятся в цифрах, не предполагайте, что они сработали.
6. Закрепите достигнутый результат с помощью проверки на регрессию
Улучшения быстро исчезают: кто-то добавляет зависимости, команда разработки — дополнительные элементы интерфейса, компоненты становятся более сложными в работе, запросы превращаются в последовательные операции, и через три месяца вы снова в исходной точке. Для обеспечения производительности необходимы автоматизированные механизмы контроля в процессах CI и мониторинге, а не эпизодические ручные корректировки.
Размер пакета по-прежнему имеет значение
Ничто из этого не делает размер пакета незначительным. Большие пакеты увеличивают затраты на загрузку, парсинг, компиляцию и выполнение, причем негативные последствия наиболее ощутимы на медленных устройствах и сетях. Разделение кода, оптимизация структуры, отложенная загрузка и устранение ненужных зависимостей — всё это полезно. Главное — применять эти методы там, где есть доказательства их наибольшего влияния на производительность.
Здоровый анализ производительности, проводимый в порядке убывания значимости проблем, может выглядеть следующим образом. Сначала медленный ответ сервера:
Problem:
900ms server response
Проблема решена путем параллельной обработки запросов к бэкенду:
Action:
parallelize backend requestsResult:
-420ms
Далее — дорогостоящая загрузка панели управления:
Problem:
large dashboard hydration
Проблема устранена путем отложенной загрузки компонентов, которые не требуют немедленного взаимодействия:
Action:
defer non-critical interactive componentsResult:
-280ms main-thread work
Затем — слишком большое изображение:
Problem:
hero image is 1.8 MB
Проблема решена с помощью адаптивной доставки в современных форматах:
Action:
responsive WebP/AVIF deliveryResult:
-1.2 MB transferred
Только после всего этого в верхнюю часть списка попадает критически важная зависимость от JavaScript:
Problem:
large JavaScript dependency
Её замена всё равно позволяет существенно сократить размер:
Action:
replace dependencyResult:
-60 KB
Это решение тоже является хорошей работой. Оно просто должно находиться на четвёртом месте в списке, а не на первом.
Основные выводы
- Оптимизируйте время ожидания, а не размер файла. Размер бандла — это лишь один из многих показателей.
- Сначала изучите сервер и процесс обработки запросов, прежде чем использовать анализатор бандлов; задержки и количественные операции часто стоят дороже, чем просто объём данных.
- Оценивайте JavaScript по объёму работы, которую он выполняет: парсинг, выполнение, отрисовка и интеграция с интерфейсом, а не только по его размеру.
- Проводите аудит сторонних скриптов и изображений с такой же строгостью, как и своего собственного кода.
- Переопределите понятие «готовности» для каждой страницы, чтобы критически важный контент появлялся первым, а всё остальное — следом.
Связанные статьи
- Что на самом деле делает фронтенд-разработчиков ценными в эпоху ИИ — Объясняется, почему понимание ситуации, способность к оценке и мышление на уровне системы теперь важнее владения фреймворками, поскольку ИИ берет на себя рутинную работу по написанию фронтенда.
- За пределами P95: измерение задержек, которые действительно испытывают ваши пользователи — Почему хороший показатель P95 может сосуществовать с медленным продуктом, как время ожидания в очереди и распространение запросов скрываются от панелей управления, и как измерение времени на каждый шаг прекращает споры о причинах задержек.
- Что JSON.stringify тихо удаляет, преобразует и отказывается сериализовать — Узнайте, какие значения JavaScript игнорирует или изменяет функция JSON.stringify, как toJSON, заменители и восстановители решают эти проблемы, и когда structuredClone является более подходящим инструментом.
- Место написания функции определяет её видимость: лексический диапазон в JavaScript — Поймите, как JavaScript разрешает имя переменной через лексические среды, почему место вызова никогда не влияет на поиск, и как это проявляется в обработчиках React.
- Что оптимизирует компилятор React и что остаётся на вашей ответственности — Узнайте, какие задачи по улучшению производительности автоматизирует компилятор React, почему работа с медленными API и тяжёлыми пакетами остаётся за вами, и как безопасно внедрить его в существующую базу кода React.
- Разбор процесса сравнения виртуальных DOM: что сравнивает React и почему это полезно — Понять, что на самом деле представляет собой виртуальный DOM React, как процесс согласования сравнивает две структуры элементов, какие изменения происходят на этапе сохранения изменений и откуда берётся прирост производительности.
- Перерасчёт макета, перерисовка и композитирование: сколько каждое изменение CSS стоит браузеру — изучите, как HTML и CSS обрабатываются в DOM, CSSOM, при формировании макета, перерисовке и композитировании, и узнайте, почему изменения ширины стоят дороже, чем изменения цвета, а также как избежать проблем с макетом.