Головна / Статті / Більше, ніж розмір пакету: як з’ясувати, що насправді уповільнює ваш веб-додаток

Більше, ніж розмір пакету: як з’ясувати, що насправді уповільнює ваш веб-додаток

Чому видалення кілобайтів рідко допомагає вирішити проблему повільної роботи додатка, та як відстежувати справжній час очікування між серверами, послідовними етапами виконання, скриптами сторонніх розробників та зображеннями.

3024 слів

У командах, які працюють з фронтендом, часто трапляється така ситуація: тижні витрачаються на скорочення розміру JavaScript-бандлу на 40 КБ, тоді як запит до бази даних тривалістю 900 мілісекунд залишається недоторканим у критичному маршруті запиту. Замінюється бібліотека іконок, замінюється залежність, налаштовується ще один плагін для об’єднання файлів, і починається суперечка щодо того, чи становить розмір пакету 18 КБ чи 12 КБ. Потім хтось завантажує додаток на справжній телефон через справжню мережу, і він все одно здається повільним.

У цій статті пояснюється, чому розмір бандлу так часто стає неправильною метою, звідки насправді походить затримка та як запустити цикл оптимізації, який спочатку усуває найбільші проблеми. Менші бандли дійсно допомагають, іноді навіть значно. Але якщо сторінка повільна через очікування від сервера, блокування процесу відображення, зайву роботу, надмірну кількість запитів чи обробку величезної структури компонентів, скорочення ще на 20 КБ не допоможе виправити ситуацію.

Чому розмір бандлу став стандартною метою продуктивності

Розмір бандлу є привабливим, тому що це число. Під час збирання проекту виводиться щось на кшталт цього:

main.js       842 KB
vendor.js     611 KB
styles.css     94 KB

Хтось пропонує зробити розмір JavaScript меншим за 500 КБ, і раптово у команди з’являється ціль. Її можна застосувати під час автоматизованих тестів, відстежувати під час подання змін та святкувати кожне зменшення. Це здається проявом прогресу в інженерії, і іноді це справді так.

Проблеми починаються тоді, коли число перестає бути симптомом та стає самою метою. Команди схильні оптимізувати ту частину продуктивності, яку вони бачать найчіткіше, а не ту, яка коштує користувачам найбільше часу. Щоб зрозуміти, чому це важливо, порівняймо два гіпотетичні додатки.

Додаток А: малий бандл, повільність у всьому іншому

Перший додаток постачається з легким бандлом:

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 KB до 500 KB ледве впливає на користувацький досвід, адже перші значущі дані все одно надходять через 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 за роботою, яку він виконує: парсинг, виконання, відображення та інтеграція з інтерфейсом, а не лише за його розміром.
    • Проводьте аудит сторонніх скриптів та зображень з такою ж ретельністю, як і власного коду.
    • Переосмисліть поняття „готовності“ для кожної сторінки, щоб критично важливий контент надходив першим, а все інше — пізніше.
  • Робота у циклі: відтворення, вимірювання, зміна однієї речі, повторне вимірювання та захист кожного досягнення за допомогою перевірки на регресію.
  • Найважливіше питання у роботі з продуктивністю — що змушує користувача чекати та чому.
  • Пов’язані матеріали