Більше, ніж розмір пакету: як з’ясувати, що насправді уповільнює ваш веб-додаток
Чому видалення кілобайтів рідко допомагає вирішити проблему повільної роботи додатка, та як відстежувати справжній час очікування між серверами, послідовними етапами виконання, скриптами сторонніх розробників та зображеннями.
У командах, які працюють з фронтендом, часто трапляється така ситуація: тижні витрачаються на скорочення розміру 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 КБ не обов’язково є катастрофою. Важливо те, що браузер мусить з ним зробити:
- Завантажити його.
- Розпарсити його.
- Компілювати його.
- Виконати його.
- Сформувати стан додатку.
- Створити дерева компонентів.
- Приєднати обробники подій.
- Оживити маркапт, отриманий на сервері.
- Перерахувати макет.
- Намалювати результат.
Два додатки з схожими розмірами пакетів можуть значно відрізнятися за витратами на виконання. Уявіть таблицю з 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.
- Розкриття таємниць Virtual DOM Diffing: Що порівнює React та чому це корисно — Зрозумійте, що насправді таке Virtual DOM у React, як він порівнює два дерева елементів під час синхронізації, які зміни відбуваються на етапі збереження, та звідки походить покращення продуктивності.
- Reflow, Repaint, Composite: Що кожна зміна CSS коштує браузеру — Дізнайтеся, як HTML та CSS працюють у DOM, CSSOM, під час формування макету, фарбування та композиції елементів, і чому зміни ширини коштують браузеру більше, ніж зміни кольору, а також як уникнути проблем із макетом.