Производительность фронтенда: разбираем важные метрики
Обычно под производительностью понимают количество операций за определенный интервал времени и чем их больше, тем лучше. Но такое определение, да и подход в целом, мало применим к фронтенду, потому что у каждого пользователя будет свой «фронтенд». Именно об этом я и хочу поговорить, что же происходит «там», у пользователя, на другой стороне, в реальности, а не на вашем топовом MacBook.
Кроме это, я постараюсь вскользь рассмотреть общие правила оптимизации кода и некоторые ошибки на которые стоит обратить внимание. Ещё расскажу про инструмент, который помогает не только в профилировании, но и «из коробки» собирает кучу базовых метрик о производительности вашего приложения (и надеюсь, вы дочитаете этот пост до конца).
Первым делом определим, что же такое производительность фронтенда, а затем уже перейдем к тому, как её измерять. Итак, как я уже сказал, мы не будет мерять некие ops/sec, нам нужны реальные данные, которые бы могли ответить на вопрос, что именно происходит с нашим проектом на каждой стадии его работы. Для этого нам понадобиться следующий набор метрик:
- скорость загрузки;
- время первой отрисовки и интерактивности (Time To Interactive);
- скорость реакции на действия пользователя;
- FPS при прокрутке и анимациях;
- инициализация приложения;
- если у вас SPA, то надо мерить время, которое тратится на переход между маршрутами;
- потребление памяти и трафика;
- и… пока хватит.
Всё это базовые метрики, без которых невозможно понять, что именно происходит на фронтенде. И не просто на фронтенде, а в реальности, у конечного пользователя. Но чтобы начать собирать эти метрики, для начала нужно научиться их измерять, поэтому давайте вспомни, какие способы есть для аналитики производительности.
Первое, с чего стоит начать, — это конечно Performance API. А именно performance.timing, через который вы можете узнать, сколько время заняло у пользователя открытие вашего проекта. Но Performance API покрывает только часть метрики, остальные нужно будет измерять самому, и для этого у нас есть следующие инструменты:
Вот в этот момент я понял, что нужно пилить инструмент, который будет совмещать плюсы вышеперечисленного и по возможности не иметь минусов. Так и появился PerfKeeper.
PerfKeeper
- Полный контроль над началом и концом.
- Можно отправить на сервер.
- Выводится в консоли.
- Поддерживает DevTools → Performance → User Timing.
- Есть группировка.
- Есть цветовое кодирование (а также единицы измерения, т.е. измерять можно не только время).
- Поддерживает расширения.
Сейчас я не буду расписывать тут API, не для этого писал документацию, да и статья не об этом, а продолжу про то, как собирать метрики.
Скорость загрузки страницы
Как я уже говорил, скорость загрузки вы можете узнать из performance.timing, который позволит узнать полный цикл от начала загрузки страницы (время на резолв DNS, установку HTTP Handshake, обработку запроса) и до полной загрузки страницы (DomReady и OnLoad):
В итоге должен получиться следующий набор метрик:
Пример работы расширения navigation для @perf-tools/keeper.
Но этого недостаточно, мы получили только базовые значения и до сих пор не знаем, что же именно заняло столько времени. А чтобы это узнать, надо нашпиговать и HTML метриками.
Как я уже говорил, примеры я буду показывать с использование PerfKeeper, поэтому первым делом инлайним в
сам PerfKeeper (2,5 Кб) и дальше:
В результате в консоли вы увидите вот такую красоту:
Это классический дедовский способ замера, 100% работает. Но мир не стоит на месте, и для более точных измерений у нас теперь есть Resource Timing API (а если ресурсы находятся на отдельном домене Timing-Allow-Origin вам в помощь).
И тут стоит поговорить о классических ошибках при первоначальной загрузке страницы, а именно:
- отсутствие GZip и HTTP/2 (да-да, такое до сих пор встречается);
- необоснованное использование шрифтов (бывает шрифт подключают только ради одного заголовка или даже номера телефона в футере 0_o);
- слишком общие/большие бандлы CSS/JS.
Способы оптимизации загрузки страницы:
- используйте Brotli (или даже SDCH) вместо GZip, включайте HTTP/2;
- собирайте только необходимый CSS (сritical) и не забывайте про CSSO;
- минимизируйте размер JS-бандла, отделив минимальный CORE-бандл, а остальное грузите по требованию, т.е. ассинхронно;
- грузите JS и CSS в не блокирующем режиме, динамически создавая
/> и
, в идеале грузите JS уже после основного контента; - используйте SVG вместо PNG, а если совместить с JS, то это позволит избавиться от избыточного XML (например как у font-awesome);
- применяйте lazy loading как для изображений, так и iframe (кроме этого, в ближайшем будующем появиться нативная поддержка).
Время первой отрисовки и интерактивности (TTI)
Следующий этап после загрузки — это момент, когда пользователь увидел результат, и интерфейс перешел в интерактивный режим. Для этого нам понадобится Performance Paint Timing и PerformanceObserver.
С первым всё просто, вызываем performance.getEntriesByType('paint')
и получаем две метрики:
- first-paint — первая отрисовка;
- first-contentful-paint — и полная первая отрисовка.
Пример работы расширения paint для @perf-tools/keeper.
А вот со следующей метрикой, Time To Interactive, всё немного интереснее. Нет точного способа определить, когда ваше приложение перешло сало интерактивным, т.е. доступным для пользователя, но это можно косвенно понять по отсутствию longtasks:
// TTI
let ttiLastEntry: PerformanceEntry | undefined;
let ttiPerfObserver: PerformanceObserver;
try {
ttiPerfObserver = new PerformanceObserver((list) => {
ttiLastEntry = list.getEntries().pop();
});
ttiPerfObserver.observe({
entryTypes: ['longtask'],
});
} catch (_) {}
domReady(() => {
// TTI Check
if (ttiPerfObserver) {
let tti: number;
const check = () => {
if (ttiLastEntry) {
tti = ttiLastEntry.startTime + ttiLastEntry.duration;
if (now() - tti >= options.ttiDelay) {
// Последний logntask был давно, будем считать,
// что эра интерактивности настала ;]
send('tti', 'value', 0, tti);
ttiPerfObserver.disconnect();
} else {
setTimeout(check, options.ttiDelay);
}
} else if (tti) {
send('tti', 'value', 0, tti);
ttiPerfObserver.disconnect();
} else {
// Не было logntask, поэтому делаем паузу и если их опять не будет,
// то считает, что приложение уже готово на момент DOMReady!
tti = now();
setTimeout(check, 500);
}
}
// Запускаем проверку
check();
}
});
Пример работы расширения performance для @perf-tools/keeper.
Кроме этих базовым метрик ещё нужна именно ваша метрика готовности приложения, т.е. где-то в вашем коде должно быть подобное:
Import { system } from '@perf-tools/keeper';
export function applicationBoot(el, data) {
const app = new Application(el, data);
// Подписываемся на готовность приложения
app.ready(() => {
system.add('application-ready', 0, system.perf.now());
// ️application-ready: 3074.000ms
});
return app;
}
Скорость реакции на действия пользователя
Тут огромное поле для метрик и они очень индивидуальны, поэтому расскажу о двух базовых, которые подходят любому проекту, а именно:
first-event — время первого события, например первый click (с делением куда пользователь ткнул), такая метрика особенно актуальна для разного рода поисковых выдачей, списка товаров, новостных лент и т.п. С помощью неё вы сможете контролировать, как меняется время реакции и флоу пользователя от ваших действий (изменений в: дизайн/новые фичи/оптимизации и т.п.)
Пример работы расширения performance для @perf-tools/keeper.
latency — задержка при обработке некоторых событий, например: click
, input
, submit
, scroll
и т.д.
Чтобы измерить задержку, достаточно повесить обработчик события на window
с capture = true
и через requestAnimationFrame
посчитать разницу, это и будет задержка:
window.addEventListener(eventType, ({target}) => {
const start = now();
requestAnimationFrame(() => {
const latency = now() - start;
if (latency >= minLatency) {
// ….
}
});
}, true);
Пример работы расширения performance для @perf-tools/keeper когда на клик вычисляется Число Фибонначи.
FPS при прокрутке и анимациях
Это самая интересная метрика, обычно её измеряют через requestAnimationFrame
, и если вам нужно делать постоянный замер FPS, то классический FPSMeter подойдет (хоть он излишне оптимистичен). Но он совсем не годится, если нужно измерить плавность прокрутки страницы, т.к. ему нужен «прогрев». И тут я наткнулся на очень интересный способ.
Гениально, на самом деле, просто создаём прозрачный div (1×1 px), добавляем ему transition: left 300ms linear
и запускаем его из одного угла в другой, а пока он анимируется, через requestAnimationFrame
проверяем его реальный left, и если новая длина отличается от предыдущей, то увеличиваем количество отрисованных кадров (иначе имеем просадку FPS).
И это ещё не всё, если вы пользуетесь FF, то там просто есть mozPaintCount, который отвечает за количество отрисованных кадров, т.е. запоминаем «ДО», а на transitionend
вычисляем разницу.
Итого, без какого-либо прогрева мы точно знаем, перерисовал ли браузер кадр или нет.
Ещё в скором времени обещают нормальное API: http://wicg.github.io/frame-timing/
Пример работы расширения fps для @perf-tools/keeper.
Оптимизация скрола:
- самое простое — это ничего не делать на scroll, либо откладывать выполнение через
requestAnimationFrame
, либо дажеrequestIdleCallback
; - очень осторожно используйте
pointer-events: none
, включение и отключение его может дать обратный эффект, поэтому лучше провести A/B-эксперимент с использованиемpointer-events
и без; - не забывайте про виртуализированные списки, практически все View-движки сейчас имеют такие компоненты, но опять же будьте аккуратнее, элементы такого списка должны быть максимально простыми, либо используйте «пустышки», которые будут заменены на реальные элементы после завершения прокрутки. Если вы сами пишите виртуализированный список, то никакого inner HTML и не забывайте про DOM Recycling (это когда вы не создаете DOM-элементы на каждый чих, а переиспользуете их).
Инициализация приложения
Тут есть только одно правило: детализируйте так, чтобы вы точно могли ответить, что именно съело время от инициализации приложения до финального запуска. В итоге должно получиться как минимум следующие метрики:
- сколько времени ушло на резолв каждой зависимости;
- время на получение и подготовку данных для приложения;
- рендер приложения с детализацией по блокам.
Т.е. на выходе у вас должны получиться такие метрики, по которым вы точно сможете отследить, на какой именно фазе у вас идет просадка.
User Timing
Если у вас SPA, то надо измерять время маршрутизации
Во-первых, должна быть общая метрика для оценки производительности (время перехода по маршруту) в целом, но также обязательно нужно иметь метрику по каждому маршруту (например у нас это «Список тредов», «Чтение треда», «Поиск» и т.д.), сама метрика должна быть разбита на подметрики:
- Получение данных (с разбиением, каких именно)
- Обработка
- Обновление
- Рендер
- Всего приложения
- Блоков (например у нас, это будет: «Левая колонка» (она же «Список папок»), «Умная строка поиска», «Список писем» и в том же духе)
Без всего этого невозможно понять, в каком месте начинаются проблемы, поэтому у нас многие модули из коробки имеют тайминги (например тот же модуль для XHR имеет startTime
и endTime
, которые автоматически журналируются).
Но и этих метрик недостаточно, чтобы адекватно оценить, что же происходит. Они слишком общие, т.к. мы говорим про SPA, то у вас точно имеется какой-либо Runtime Cache (чтобы не ходить на сервер лишний раз, если вы уже там были), поэтому наши метрики дополнительно разделены на маршрутизацию с cache и без. Ещё, конкретно в нашем случае, мы делим метрику по количеству сущностей в ней. Иначе говоря, нельзя складывать в одну метрику просмотр «Треда» с 1, 5, 10 или 100+ письмами, поэтому если у вас есть вывод какого-либо списка, надо выбрать контрольные точки и дополнительно разделить метрику.
Потребление памяти и трафика
Начнем с памяти. И тут нас ждет большое разочарование. На данный момент есть только нестандартизированный (Chrome only) performance.memory, который выдает до смешного низкие числа. Но всё же их нужно измерять и смотреть, как «течет» приложение со временем: Пример работы расширения memory для @perf-tools/keeper
Трафик. Чтобы считать трафик, вам понадобится Timing-Allow-Origin (если ресурсы находятся отдельном домене) и Resource Timing API, это поможет не просто посчитать трафик, но и детализировать его:
- какой протокол используется (HTTP/1, HTTP/2 и т.п.);
- типы загружаемых ресурсов;
- сколько времени потребовалось на их загрузку;
- размер, притом ещё можно понять, загружен ли ресурс по сети или взят из кеша.
Пример работы расширения resource для @perf-tools/keeper.
Что даёт подсчет трафика?
- Самое главное — это позволяет увидеть реальную картину, а не как обычно CSS + JS и кроме этого, как эта «картина» изменяется по времени.
- Далее вы можете проанализировать, что именно грузится, разделить ресурсы на группы и т.п.
- Насколько хорошо у вас работает кеширование.
- Нет ли аномалий, например через 15 минут работы, например код вошел в рекурсию и бесконечно грузит какой-нибудь ресурс, мониторинг трафика поможет и в этом.
Ну в догонку доклад от моего коллеги Игоря Дружинина на эту тему: Оценка качества работы приложения — мониторинг потребления трафика
Аналитика
Метрики мы расставили, а что дальше? А дальше их нужно куда-то отправить. И тут либо вы поднимаете у себя какой-нибудь Graphite, либо, для начала, можно использовать в корыстных целях Google Analytics или подобные для агрегации данных.
И не забывайте, недостаточно просто получить график, по всем важным метрикам должны быть процентили, которые позволят понять, например, у какого процента аудитории проект загружается за <1s, <2s, <3s, <5s, 5s+ и т.п.
Пишем высокопроизводительный код
Сначала я хотел, написать тут что-то осмысленное, мол используйте WebWorker, не забывайте requestIdleCallback
или что-то из экзотики, например сквозной Runtime Cache сквозь вкладки браузера при помощи SharedWorker или ServiceWorker (который не только про кеширование, если что). Но это всё очень абстрактно, да и многие темы избиты до невозможности, поэтому просто напишу следующее:
- Изначально покрывайте ваш код метриками, которые позволят измерять его производительность.
- Не верьте бенчмаркам с jsperf. Подавляющее большинство из них написаны плохо, да и просто вырваны из контекста. Лучший бенчмарк — это реальная метрика на проекте, по которой вы увидите эффект от ваших действий.
- Помните про восприятие производительности, а точнее Закон Вебера — Фехнера. А именно, если вы начали оптимизацию, то не выкатывайте изменения, пока лучше не станет хотя бы на 20%, иначе пользователи просто не заметят. Так же закон работает и в обратную сторону.
- Бойтесь регулярок, особенно генерируемых. Ими не только можно подвесить браузер, но и получить XSS, именно поэтому у нас в Почте запрещен разбор HTML с помощью них, только через обход DOM«а.
- Не нужно использовать массивы для вхождения значения в ту или иную группу, для этого есть
object
илиSet
(например вместоsuccessSteps.includes(currentStep)
нужноsuccessSteps.hasOwnProperty(currentStep)
), O (1) наше всё. - Выражение «Преждевременная оптимизация — корень всех зол» — это не про то, что пишите как хотите. Если вы знаете, как оптимальнее, пишите оптимально.
Но если говорить про DOM, то например вместо удаления фрагмента из DOM, лучше его скрыть или deattach-нуть. Если всё же нужно удалить, то вынесите эту операцию в requestIdleCallback
(если возможно), или разделить процесс уничтожения на две фазы: синхронную и асинхронную.
Сразу оговорюсь, используйте с умом такой подход, а то можно и колено прострелить.
Ещё одну интересную технику мы используем на списках, например «Списке Тредов». Суть техники в том, что вместо одного глобального «Списка» и обновления его данных, мы генерируем «Список Тредов» под каждую «Папку». В итоге когда пользователь переходит между «Папками», один список вынимается из DOM (не удаляется), а другой обновляется либо частично, либо не обновляется вовсе. А не весь, как в случае с «Единым Списком».
Всё это даёт мгновенный отклик на действия пользователя.
Математика. Всю математику с легкостью убираем либо в Worker, либо в WebAssembly, это давно уже работает.
Транспиллеры. Ох, многие даже не задумываются о том, что код, который они пишут, проходит через транспиллер. Да, они знают про него, но на этом всё. А вот что же он превратится их уже не волнует. Ведь в DevTools они видят результат source map.
Поэтому изучайте инструменты, которые вы используете, например у того же babel в playground есть возможность посмотреть, во что он генерирует код в зависимости от выбранных пресетов, просто гляньте на тот же yeild
, await
или for of
.
Тонкости языка. Ещё меньше людей знает про мономорфность кода, или банально почему bind медленный и… используйте вы наконец handleEvent
!
Данные и прекеширование. Меньше запросов, больше кеширования. Кроме этого, очень часто мы используем технику «предвидения», это когда в фоне мы подгружаем данные. Например, мы после рендера «Списка тредов» начинаем подгрузку N-непрочитанных тредов в текущей «Папке», чтобы при клике на них пользователь сразу перешел на «Чтение», а не очередной «лоудер». Подобную технику мы используем не только для Данных, но и JS. Например, «Написание Письма» — это огромный бандл (из-за редактора), а пишут письма не все и не сразу, поэтому грузим его в фоне, после инициализации приложения.
Лоудеры. Не знаю почему, но я не видел статей, в которых бы учили, как не делать лоудер, а наоборот, взять хоть презентацию «будущего» React, в которой этой проблеме в рамках Suspense уделено очень много времени. Но ведь идеальное приложение именно без лоудеров, мы в Почте уже очень давно стараемся показывать его только в экстренных ситуациях.
В целом политика у нас такая, нет данных, нет view, нечего рисовать полу-интерфейс, сначала загружаем данные и только потом «рисуем». Именно поэтому мы используем «предвидение» того, куда пользователь собирается пойти и подгружаем эти данные, чтоб юзер не увидел лоудер. Кроме этого, очень сильно в этой задаче помогает наш дата-слой, который обладает персистентностью, т.е. если вы где-то в одном месте запросили «Тред», то при следующем запросе из другого или того же места, запроса не будет, мы возьмем данные из Runtime Cache (точнее ссылку на данные). И так во всем, коллекции тредов тоже всего лишь ссылки на данные.
Но если вы всё же решили делать лоудер, то не забывайте основные правила, которые сделают ваш лоудер менее раздражающим:
- не нужно показывать лоудер сразу, в момент отправки запроса, перед показом должна быть задержка как минимум 300–500 мс;
- после получения данных не нужно резко убирать лоудер, тут опять же должна быть задержка.
Эти нехитрые правила нужны, чтобы лоудер появлялся только на тяжелых запросах и не «мигал» по завершению. Ну, а главное, лучший лоудер — это лоудер, который не появился.
Спасибо за внимание, на это всё, измеряйте, анализируйте и используйте PerfKeeper (Live example), а так же вот мой github и twitter, на случай вопросов!