Продуктивність старту JavaScript



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

Історично склалося так, що ми не витрачаємо багато часу на оптимізацію парсинга/компілювання JavaScript. Ми вважаємо, що скрипти будуть моментально отпарсены і виконані, як тільки парсер дійде до тега
<script>
. Але це не так. Ось спрощена схема роботи V8:



Це ідеалізоване уявлення нашого робочого конвеєра.

Давайте розглянемо деякі ключові фази.

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


Тривалість парсинга і компілювання для популярного сайту з використанням статистики runtime-викликів V8 в Chrome Canary. Зверніть увагу, як і без того нешвидкі на настільних комп'ютерах парсинг і компілювання можуть стати ще повільніше на смартфонах

Час запуску важливо для коду, чутливого до продуктивності. За фактом JS-движок V8 витрачає багато часу на парсинг і компілювання таких сайтів, як Facebook, Wikipedia і Reddit:


Рожеві області (JavaScript) відображають час, витрачений на роботу V8 і Blink C++, жовтогарячі і жовті — тривалість парсинга і компілювання

У багатьох сайтів і фреймворків тривалість парсинга і компілювання — слабке місце. Нижче цитуються Себастьян Маркбейдж (Facebook) і Роб Вормалд (Google):

Парсинг/компілювання — величезна проблема. Я попрошу наших хлопців поділитися даними. Проте вимірювати потрібно дисконект.
@sebmarkbage

Я розумію ці дані так, що головні витрати на запуск в Angular припадають в основному на парсинг JS, до того як ми взагалі торкаємося DOM.
@robwormald

Сем Сакконе виявляє вартість JS-парсингу в «Planning for Performance»

«Мобільність» веба збільшується, і важливо розуміти, що на смартфонах парсинг/компілювання може бути в 2-5 разів довше, ніж на настільних комп'ютерах. Причому продуктивність топових смартфонів, дуже сильно відрізняється від якого-небудь Moto G4. Це підкреслює важливість тестування на репрезентативному обладнанні (а не тільки на топовому!), щоб перевірити якість користувацького досвіду.


Тривалість парсинга 1-пакету (bundle) JavaScript на настільних і мобільних пристроях різних класів. Зверніть увагу, що смартфони на зразок iPhone 7 по продуктивності близькі до MacBook Pro, і порівняйте падіння показників на пристроях середнього рівня

Якщо наші веб-додатки використовують величезні пакети, то застосування сучасних методик поставки, таких як code-splitting, tree-shaking та кешування Service Worker, може мати дуже сильний вплив. З іншого боку, навіть маленький пакет, кострубато написаний або використовує посередні бібліотеки, може призвести до того, що основний потік надовго застрягне на компіляцію або викликах функцій. Важливо оцінювати картину цілісно, розуміючи, де саме знаходяться вузькі місця.

Тривалість парсинга і компілювання JavaScript — це вузьке місце для середньостатистичного сайту?
Напевно зараз ви думаєте: «Але я ж не Facebook». Ви можете запитати: «Наскільки велика тривалість парсинга і компілювання для середньостатистичних сайтів?» Давайте вивчимо це питання!

Я витратив два місяці на вимір продуктивності великої кількості працюючих сайтів (понад 6 тис.), побудованих з використанням різних бібліотек і фреймворків — React, Angular, Ember і Vue. Більшість тестів були відтворені на WebPageTest, так що ви легко можете прогнати їх самостійно або уважно вивчити наявні результати. Ось деякі висновки.

Програми стають інтерактивними через 8 секунд на десктопах (кабельне з'єднання) і через 16 секунд на смартфонах (Moto G4 з 3G):



З чим це пов'язано? На десктопах на запуск більшості додатків витрачається в середньому близько 4 секунд (парсинг/складання/виконання).



На смартфонах тривалість парсинга була приблизно на 36 % вище, ніж на десктопах.



Всі використовували величезні JS-пакети? Не настільки великі, як я передбачав, але є ще куди прагнути. В середньому розробники застосовували для своїх сторінок пакети розміром 410 Кб, стислі з допомогою gzip. Це узгоджується з даними HTTP Archive — 420 Кб JS в середньому на сторінку. Деякі фріки передавали по кабелю до 10 Мб.


Статистика HTTP Archive: в середньому на сторінку припадає 420 Кб JavaScript

Розмір скриптів має значення, але не вирішальне. Тривалість парсинга і компілювання необов'язково лінійно залежить від розміру скриптів. Більш компактні JavaScript-пакети в цілому демонструють більш швидку завантаження (в залежності від браузера, пристрої та підключення), але 200 Кб JS !== 200 Кб чогось іншого, так що тривалість парсинга і компілювання може сильно варіюватися.

Сучасний вимір тривалості парсинга і компілювання JavaScript
Інструментарій розробників Chrome

Зайдіть в Timeline (панель Performance) > Bottom-Up/Call і побачите Tree/Event Log, з якого ви отримаєте уявлення про час, витрачений на парсинг і компілювання. Заради більш докладної картини (наприклад, тривалість парсинга, препарсинга або ледачого компілювання) можна включити статистику runtime-викликів V8. В Canary це робиться так: Experiments > V8 Runtime Call Stats on Timeline.



Трейсінг в Chrome

about:tracing — низькорівневий інструмент трасування в Chrome дозволить використовувати категорію disabled-by-default-v8.runtime_stats, щоб зробити більш глибокі висновки щодо того, на що витрачається час роботи V8. У движку є свіже покрокове керівництво з використання інструменту.



WebPageTest



Сторінка Processing Breakdown на WebPageTest містить дані про тривалість компілювання в V8, EvaluateScript і FunctionCall, коли ми виконуємо трасування за допомогою включеного інструменту Chrome > Capture Dev Tools Timeline.

Також можна отримати статистику runtime-викликів, задавши кастомний категорію трасування disabled-by-default-v8.runtime_stats (Пет Минен з WPT робить це за замовчуванням!).



Як можна отримати з цього користь: https://gist.github.com/addyosmani/45b135900a7e3296e22673148ae5165b.

User Timing

Можна вимірювати тривалість парсинга за допомогою User Timing API:

Measuring this stuff is extremely tricky! In short, this is the error I made: pic.twitter.com/O1uisPXxEG  Nolan Lawson (@nolanlawson) January 5, 2017

Третій
<script>
тут неважливе. Але він знаходить важливість, будучи першим
<script>
, відокремленим від другого (performance.mark() починається до
<script>
).

Такий підхід здатний вплинути на подальші перезавантаження з боку препарсера V8. Це можна обійти, додаючи випадкове значення рядка у кінці скрипта, щось подібне зробив у своїх бенчмарках Нолан Лоусон.

Для вимірювання впливу тривалості парсинга JavaScript я використовую аналогічний підхід, застосовуючи Google Analytics:


Кастомні вимірювання parse дозволяють вимірювати тривалість парсинга JavaScript для реальних користувачів і пристроїв, що заходять на мої сторінки

DeviceTiming

Інструмент DeviceTiming допоможе у вимірі тривалості парсинга/компілювання скриптів у контрольованому середовищі. Локальні сценарії поміщаються в інструментальну обгортку, і при кожному зверненні до сторінок з різних пристроїв (ноутбуків, смартфонів, планшетів) ми можемо локально порівнювати тривалість парсинга/виконання. У виступі Даніеля Эспесета «Benchmarking JS Parsing and Execution on Mobile Devices» цей інструмент розглядається детальніше.



Як можна зменшити тривалість парсинга JavaScript?
  • Менше JavaScript. Чим менше скриптів потрібно парсити, тим коротша фаза парсинга/компілювання.
  • Використовуйте методику code-splitting тільки для поставки коду, який необхідний для направлення користувача по сторінці, а решта подгружайте в лінивому режимі. В багатьох випадках це допоможе уникнути парсинга великої кількості JS. В реалізації такого підходу корисні патерни зразок PRPL, який сьогодні використовують Flipkart, Housing.com і Twitter.
  • Використовуйте потокове завантаження скриптів (script streaming). Раніше V8 пропонував розробникам використовувати async/defer, щоб за допомогою потокового завантаження скриптів на 10-20 % зменшити тривалості парсингу. Це як мінімум дозволяє HTML-парсеру раніше виявляти джерело, передавати завдання потоку потокового завантаження і не сповільнювати парсинг документа. Тепер це робиться і для скриптів, що блокують парсер (parser-blocking), і я не думаю, що є якісь інші завдання. При наявності одного streamer-потоку V8 рекомендує вантажити спершу більш великі пакети.
  • Вимірюйте вартість парсинга залежностей — бібліотек і фреймворків. Скрізь, де можливо, переходьте на залежності з більш швидким парсингом (наприклад, замість React краще скористатися Preact або Inferno, які вимагають менше байтів для початкового завантаження і швидше парсятся/компілюються). Пол Льюїс нещодавно в своїй статті підняв питання вартості початковій завантаження фреймворків. Себастьян Маркбейдж відзначив хороший спосіб вимірювання вартості початкового завантаження фреймворків — спочатку отрендерить view, стерти і отрендерить знову, це дасть вам розуміння його масштабованості. Перша побудова виконує роль прогріву для ліниво компилируемого коду, більш велике дерево якого може отримати вигоду від масштабування.
Якщо вибраний вами JavaScript-фреймворк підтримує режим компілювання перед виконанням (ahead-of-time compilation, AoT), то це істотно допоможе зменшити тривалість парсинга/компілювання. Наприклад, це йде на користь Angular-додатками:


Виступ Нолана Лоусона «Solving the Web Performance Crisis»

Що роблять браузери для прискорення парсинга/компілювання?
Не тільки розробники збирають реальну статистику, щоб знайти, як б ще поліпшити швидкість запуску. Завдяки V8 виявилося, що Octane, один з найстаріших бенчмарків, був поганим проксі для вимірювання реальної продуктивності 25 популярних сайтів. Octane може бути проксі:

  1. для JS-фреймворків (зазвичай не моно-/полиморфический код),
  2. для запуску сторінкових додатків (real-page app) (більша частина коду «холодна»).
Обидва ці випадки досить важливі для вебу, але все-таки Octane не підходить для всіх типів робочого навантаження.
Команда розробників V8 витратила багато сил на поліпшення часу запуску:

Рік від року продуктивність V8 при запуску JavaScript поліпшується приблизно на 25 %. Щільніше зайнялися продуктивністю реальних додатків.
@addyosmani
Судячи за даними Octane-Codeload, приблизно на 25 % покращилася продуктивність V8 і при парсингу численних сторінок:



В цьому відношенні покращилися результати і Pinterest. Також в останні роки є ряд інших підтверджень якісного зростання V8 з точки зору тривалості парсинга і компілювання.

Кешування коду


З статті «Використання кешування коду V8».

У Chrome 42 з'явилося кешування коду — спосіб зберігання локальної копії скомпільованого коду, щоб при поверненні на сторінку пропускалися етапи витягання скриптів, парсинга і компілювання. При повторних візитах це дає в Chrome приблизно 40-відсоткове прискорення компілювання, але потрібно уточнити дещо що:

  • Кешування коду спрацьовує для скриптів, які виконувалися двічі протягом 72 годин.
  • Для скриптів Service Worker: те ж саме умова.
  • Для скриптів, які зберігаються у сховищі скриптів за допомогою Service Worker, кешування спрацьовує при першому виконанні.
Так якщо код повинен бути закеширован, то V8 пропустить парсинг і компілювання з третьої завантаження.

Можна пограти з цим механізмом: chrome://flags/#v8-cache-strategies-for-cache-storage. Також можна запустити Chrome з прапорами
js-flags=profile-deserialization
і подивитися, чи завантажуються об'єкти з кеша (в балці вони представлені як події десеріалізації).

Одне пояснення: кешується лише той код, який компілюється жадібно (eagerly compiled). В цілому це код верхнього рівня, який виконується лише раз, для налаштування глобальних значень. Визначення функцій зазвичай компілюються ліниво і не завжди кешируются. IIFE (для користувачів optimize-js ;)) також включені в кеш коду V8, оскільки вони вже жадібно скомпільовані.

Потокова завантаження скриптів (Script Streaming)

Потокова завантаження скриптів дозволяє парсити скрипти асинхронно або з затримкою, переміщаючи їх у окремий фоновий потік з початком завантаження. Це дозволяє приблизно на 10 % прискорити завантаження сторінки. Як вище зазначалося, цей механізм тепер працює і для синхронізації скриптів.



Сьогодні V8 дозволяє парсити у фоновому потоці всі скрипти, навіть блокуючі парсер (parser blocking)
<script src="">
, тому що це всім піде на користь. Нюанс в тому, що фоновий потік — єдиний, так що має сенс у першу чергу обробляти великі / критично важливі скрипти. Обов'язково проводьте вимірювання, щоб визначити, де можна домогтися поліпшень.

Оскільки
<script defer>
на
<head>
, ми можемо заздалегідь визначити ресурс і потім отпарсить його у фоновому потоці.


З допомогою DevTools Timeline можна також перевіряти, чи застосовувалася потокова передача до правильним скриптам. Якщо у вас один великий скрипт, на який припадає більша частина часу парсинга, то має сенс (зазвичай) перевірити, чи застосовується до нього стрімінг.



Поліпшення парсинга і компілювання

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

(function (global, module) { ... })(this, function module() { my functions })

V8 не знає, що module точно потрібен, так що ми не будемо його компілювати в ході компілювання основного скрипта. Коли ми нарешті вирішимо скомпілювати module, то нам потрібно буде репарсить всі внутрішні функції. Це призводить до нелінійності тривалості парсингу в V8. Кожна функція на глибині N парс N разів, що призводить до затримок.

Розробники V8 вже працюють над збором інформації про внутрішні функції в ході початкового компілювання, так що будь-які наступні компілювання можуть ігнорувати свої внутрішні функції. Це повинно привести до великого зростання продуктивності модульних (module-style) функцій.

Детальніше про це у статті The V8 Parser(s) — Design, Challenges, and Parsing JavaScript Better.

Також розробники V8 досліджують можливість перенесення частини компілювання JavaScript під час запуску в фон.

Прекомпилирование JavaScript?

Кожні кілька років в движках пропонуються способи прекомпилирования скриптів, щоб нам не доводилося витрачати час на парсинг або компілювання виникає коду (code pops up). Замість цього під час складання або на стороні сервера можна просто генерувати байткод. Я вважаю, що передача додатком байткода може уповільнити завантаження (байткод займає більше місця), та задля забезпечення безпеки вам напевно доведеться підписувати код і процес. Сьогодні розробники V8 вважають, що прекомпилирование не дасть особливого виграшу. Але вони відкриті для обговорення ідей, які призведуть до прискорення фази запуску. Розробники намагаються зробити V8 більш агресивним з точки зору компілювання і кешування скриптів, коли ви оновлюєте сайт Service Worker.

Обговорення прекомпилирования з Facebook і Akamai, а також мої нотатки з цього питання можна знайти на тут.

«Хак» з дужками Optimize JS для ледачого парсинга

JavaScript-движки оснащені евристикою ледачого парсинга: велика частина функцій у скриптах препарсятся до завершення повного циклу парсинга (наприклад, для пошуку синтаксичних помилок). Цей підхід базується на ідеї, що більшість сторінок містять JS-функції, які якщо і виконуються, то ліниво.



Препарсинг може прискорити запуск за рахунок того, що функції перевіряються тільки на наявність мінімально необхідної браузеру інформації. Це суперечить використанню IIFE. Хоча для них движки намагаються пропустити препарсинг, евристика не завжди спрацьовує безпомилково, і в таких ситуаціях корисні інструменти на зразок optimize-js.

optimize-js заздалегідь парсити скрипти і вставляє дужки, якщо знає (або передбачає завдяки евристиці), що там функції будуть виконані негайно. Це прискорює виконання. З деякими функціями (наприклад, з IIFE!) такий хак працює вірно. З іншими все залежить від евристики (наприклад, в Browserify або Webpack передбачається, що всі модулі завантажуються жадібно, а це не завжди відповідає дійсності). Розробники V8 сподіваються, що в майбутньому потреба в подібних хаках відпаде, але сьогодні це корисна оптимізація, якщо ви знаєте, що робите.

Автори V8 також працюють над зниженням вартості помилок, що в майбутньому повинно знизити користь від хака з дужками.

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

А ми, зі свого боку, продовжимо працювати над поліпшенням стартовою продуктивності V8. Ми обіцяємо ;)

Корисні посилання
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.