Главная / Статьи / За пределами размера пакета: выявление причин замедления веб-приложения

За пределами размера пакета: выявление причин замедления веб-приложения

Почему удаление килобайт редко помогает ускорить медленное приложение, и как отслеживать реальное время ожидания в сети серверов, через последовательные этапы обработки, механизмы загрузки ресурсов, скрипты сторонних разработчиков и изображения.

3024 слов

В командах, занимающихся фронтендом, часто возникает такая ситуация: недели уходят на сокращение размера 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 КБ не обязательно является катастрофой. Важно то, что должен сделать браузер с этим кодом:

  1. Скачать его.
  2. Разобрать на составные части.
  3. Скомпилировать его.
  4. Запустить его.
  5. Сформировать состояние приложения.
  6. Создать деревья компонентов.
  7. Прикрепить обработчики событий.
  8. Актуализировать маркиупт, сгенерированный на сервере.
  9. Пересчитать макет.
  10. Отобразить результат.

Два приложения с похожим размером пакета могут сильно отличаться по стоимости выполнения. Возьмем таблицу с 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 по объёму работы, которую он выполняет: парсинг, выполнение, отрисовка и интеграция с интерфейсом, а не только по его размеру.
    • Проводите аудит сторонних скриптов и изображений с такой же строгостью, как и своего собственного кода.
    • Переопределите понятие «готовности» для каждой страницы, чтобы критически важный контент появлялся первым, а всё остальное — следом.
  • Работа в цикле: воспроизведение, измерение, изменение одного параметра, повторное измерение и защита всех достижений с помощью проверки на регрессию.
  • Самый важный вопрос при работе над производительностью — что заставляет пользователя ждать и почему.
  • Связанные статьи