Давайте немного отвлечемся от коронавируса, и поговорим о чем-то техническом. Например, о том, как мы пытались улучшить время холодного старта одного react-приложения и что из этого получилось. Кому интересна тема производительности и быстрого web-а в целом — прошу под кат. В конце будет небольшой опрос о цене/необходимости подобных оптимизаций.
Для начала немного контекста. Обычно мы делаем разные банковские приложения, в основном не доступные для широкой публики. Но недавно свершилось маленькое чудо и нам дали задачу написать публичное приложение (скажем так, почти публичное). Из основных требований только mobile-first, react, и IE можно не поддерживать (даже 11). Из особенностей — аппка одноразовая (т. е. пользователь туда заходит не чаще раза в год) и без данных приложение абсолютно и полностью бесполезно. До такой степени бесполезно, что мы даже футер и тот мы показать не можем, потому что не знаем в каком цвете его показывать и что там писать вообще.
Кстати, о Реакте. С выбором фреймворка получилось смешно. Я предлагал Angular, так как у нас уже была частично написанная библиотека компонент, но клиент просил react, потому что "генеральный курс взят на Реакт", "все там будем" и вообще. Но после старта оказалось, что Реакт, внезапно, "тоже устарел" и теперь генеральный курс взят на Vue. Мы посмеялись, назвали текущий проект legacy и продолжили писать дальше.
Само приложение у нас простое. Роутер — react-router-dom, хранилище данных — mobx. Для стилей — SCSS (в основном для переменных и немного для миксинов) + css.modules для изоляции стилей. Еще взяли из бутстрапа сетку что бы не возиться с адаптивным дизайном. Авторизации у нас нет, сессии нет. Из оптимизаций "по-умолчанию" мы активно используем PureComponent и React.memo, а что бы внезапно не потолстеть используем bundlesize + source-map-explorer. Как видите, все довольно просто.
И вот решили мы, что наше приложение должно быть быстрым*. Все-таки mobile-first, client-facing, в грязь лицом ударить никак нельзя. Путей было несколько. Вариант номер один — прогрессивная загрузка. Т. е. мы грузим приложение, рисуем каркас с плейсхолдерами, параллельно делаем запросы на бек-энд и отрисовываем UI блоки по мере готовности. И все бы хорошо, но запрос у нас всего один, параллелится он плохо, + нужно кусок кода переписать. Второй путь лежал через Server Side Rendering. Но, опять же, без данных рендерить нам нечего, а бек у нас на .NET, и тут все тоже не просто. Поэтому мы пошли другим путем — ускорением загрузки самого приложения.
* Очень конкретное требование, не находите?
Глава 0, в которой все начинается
Перед тем как что-то менять, нужно разобраться с тем, что уже есть. Для этого мы развернули все локально, мокнули сервер (что бы он возвращал нам ответы за константное время — 1000ms) и запустили Performance Audit из devtools (online и 4x CPU slowdown). Вот что он нам показал:
- Firts Paint (FP): 618ms
- First Meaningful Paint (FMP): 2090ms
В нашем случае FP это момент, в который показывается индикатор загрузки приложения (спиннер), а FMP это момент, когда мы уже отрисовали UI (основную часть) и клиент видит что-то полезное. В целом все выглядит очень неплохо, если учесть, что минимум 1000 ms (+транспорт) откушивает сервер. Однако, напомню, это идеальные условия с толстым каналом в интернет. А у мобильных телефонов с этим могут быть проблемы. Поэтому я выбрал еще два режима — 4g* и fast 3g и повторил тесты**.
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
First Paint | 618ms | 815ms | 2389ms |
First Contentful Paint | 2090ms | 2270ms | 3811ms |
И снова все оказалось не плохо, особенно для 4g — 2270ms достаточно хороший результат. Впринципе, тут можно было бы и остановиться, но мы решили, что можем лучше.
* 4g preset-а в хроме по-умолчанию нет. Если что — вот параметры которые использовал я: download 4000kb/s, upload 3000kb/s, latency 20ms:
** Каждый тест прогонялся три*** раза, в результаты записывалось среднее значение.
*** По-хорошему, это нужно автоматизировать и прогонять не по 3 теста, а хотя бы 10. Если вы знаете инструменты, которые в этом помогают — напишите в комментариях, буду благодарен.
Глава 1, в которой появляется React.lazy
Стартовая точка есть, теперь нужно разобраться как грузится наше приложение. Эта схема довольно проста и примерно выглядит так:
Грузим Index.html, он грузит React, стили и код самого приложения. Потом js парсится и выполняется — приложение стартует. Запускается индикатор загрузки, стартует запрос за данными. Приходит ответ, индикатор прячется, и появляется страница с данными. Все понятно, но не оптимально.
Начнем с того, что мы грузим сразу все приложение*, а можем разбить его на модули и грузить по мере надобности. Для этого в Реакте есть стандартный метод — React.lazy, который позволяет грузить компоненты не сразу, а только по необходимости. Обычно, в lazy оборачивают, все кроме текущей страницы, но в нашей ситуации мы можем обернуть даже ее. Данных у нас все равно нет и кроме спинера нам показывать нечего.
Результаты:
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | 658ms (+6%) | 791ms (-3%) | 2258ms (-5%) |
FMP | 2135ms (+2%) | 2286ms (без изменений) | 3725ms (-3%) |
Явно не сильно лучше (а на хорошем канале даже хуже). Причина оказалась в том, что бандл уменьшился не так сильно, как нам хотелось бы, а издержки на отложенную загрузку съели весь профит. Но почему бандл уменьшился так слабо? Причина нашлась в shared модуле, который мы используем из App.tsx. А кода там оказалось чересчур много и шатание дерева не очень помогло. Если мы хотим идти дальше, придется от него избавляться.
* На момент написания статьи мы грузили только основную страницу, но ради интереса я вернул загрузку одним бандлом.
Глава 2, в которой Core модуль встречается с LazyComponent
Да, грузить весь shared module мы не можем. Значит придется немного поработать руками и выделить из shared модуля core модуль, в котором будет лежать только то, без чего приложение не сможет стартовать — код, который грузит данные, спинер и еще кое-что по мелочи.
Тут, кстати, есть еще одна проблема, связанная уже с React.Lazy. "Ленивый" компонент начнет загружать свой код только тогда, когда Реакт попытается его отрендерить. А это произойдет только послое того, как к нам приедут данные. Итого, мы получаем не нужную дополнительную задержку перед отрисовкой основной страницы. Бороться с этим просто: нужно начать загрузку нужного чанка заранее, например сразу после старта запроса за данным. Для этого мы сделали примерно такой компонент:
export const LazyComponent = <T extends {}>(importStatement: () => Promise<{ default: ComponentType<T> }>) => {
const component = React.lazy(importStatement);
return { component, load: importStatement };
};
И посмотрим на новые результаты:
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | 487ms (-21%) | 570ms (-30%) | 1829ms (-23%) |
FMP | 1965ms (-6%) | 2048ms (-10%) | 3468ms (-9%) |
В абсолютных числах звучит, конечно, не так приятно, но 400ms на fast3g мы все-таки выиграли. И спиннер теперь появляется гораздо раньше. Можно двигаться дальше.
Глава 3, в которой Spinner выселяют из дома
Итак, код мы разделили, все что могли из стартового бандла убрали. Что еще можно сделать, чтобы ускорить загрузку? Можно "выселить" спинер! Ведь если подумать, загружая index.html мы уже знаем, что будем показывать индикатор загрузки. Так почему бы его не переместить в сам index.html и сразу во "включенном" виде? А когда приложение загрузится, оно его скроет. Заодно переместим туда критический css, которого у нас и так почти не осталось.
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | 212ms (-56%) | 226ms (-60%) | 845ms (-53%) |
FMP | 2150ms (+9%) | 2387ms (+17%) | 4593ms (+32%) |
Что? То, что FP станет лучше это я ожидал. Но почему ухудшились показатели FMP? Спустя несколько минут я обнаружил закомментированную строку:
// AppComponent.load();
По ошибке мы снова вернулись к последовательной загрузке чанков для основной страницы. Как видно, поочередная загрузка действительно влияет на результаты.
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | 208 (-57%) | 221 (-61%) | 782 (-57%) |
FMP | 2002 (-2%) | 2065 (без изменений) | 3487 (без изменений) |
Вот это больше похоже на правду, спиннер теперь появляется очень быстро даже на fast3g. Правда, мы проиграли в поддерживаемости приложения, но, я бы сказал, что размен все-таки в нашу пользу.
Глава 4, в которой выселения продолжаются
Только что наш несчастный спиннер лишился дома. Но зачем останавливаться на достигнутом… Ведь мы можем выселить еще и загрузку данных! А точнее, предзагрузить их. В нормальном приложении это может быть не легко и/или нам пришлось бы тащить в index.html слишком много логики. А это и поддерживать сложнее и тестировать и багами чревато. Но для нас это мог бы быть почти идеальный выход. Fetch поддерживается всеми кроме IE (а он нам и не надо), promise тоже. Сложной логики перед запросом тоже нет. Давайте пробовать.
Добавляем в index.html микрокусочек кода:
(function(){const path = `${window.app.api}`; window.app.data = fetch(path); }());
И получаем предзагрузку данных практически даром. Теперь, в самом приложении достаточно проверить наличие promis-а и, если он существует, прицепиться уже к нему. Если нет — сделать обычный запрос как мы делали до этого. Если мы решим отказаться от этого подхода, достаточно будет просто удалить эту строку предзагрузки из index.html и все будет работать как и работало.
Посмотрим к чему это приведет:
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | 245ms (+17%) | 219ms (без изменений) | 786ms (без изменений) |
FMP | 1740ms (-13%) | 1735ms (-16%) | 3441ms (-1%) |
Вполне не плохо. На быстрых соединениях мы стали показывать контент на 300ms раньше пожертвовав немного временем появления спиннера. Но вот стоило ли оно того с точки зрения поддержки проекта, вопрос остается открытым. Работа с данными, вне контекста основного приложения решение спорное. Но оптимизация она такая, редко удается добиться красивого и быстрого решения одновременно.
Кстати, если ваш API лежит на другом домене, незначительно ускорить загрузку данных можно и без таких сомнительных решений. Для этого можно использовать директиву preconnect, чтобы заранее установить соединение с API. Preconnect поддерживается всеми кроме IE 11
Но давайте продолжим.
Глава 5, в которой он отказывается
Помните, с чего мы начинали? Единый бандл для всего приложения, от которого мы отказались, потому что хотели как можно быстрее начать загрузку данных. Но теперь загрузка данных начинается еще даже до старта самого Реакта. Может теперь стоит убрать ленивую загрузку основной страницы, чтобы минимизировать издержки?
Удаляем наш LazyComponent (это же еще меньше кода, подумал я), удаляем suspense, возвращаем синхронные импорты. И вот что мы видим:
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | 262ms (+7%) | 387ms (+76%) | 1630ms (+107%) |
FMP | 1729ms (без изменений) | 1826ms (+5%) | 3280ms (-4%) |
FP сильно просел (почти в два раза на слабом соединении). А с FMP выиграть нам почти ничего не удалось. Почему? Причина проста — из-за того, что мы объединились с основной страницей, у нас появились стили. А пока стили не загрузятся, никакой JS выполняться не может, в том числе и заинлайненая в index.html предзагрузка данных. Вот иллюстрация:
Придется от этого решения отказаться.
Кстати, про стили и критический css. Когда-то я наслушался и начитался разных умных людей, которые говорили, что критический CSS это плохо. Он блокирует выполнение JS тем самым откладывая старт нашего приложения (что мы только что и увидели). Но так ли страшен черт как его малюют и надо ли его убирать везде? На самом деле — нет. Браузеры достаточно умные для того, чтобы грузить CSS и JS параллельно. И, если блок css меньше, чем блок JS (а такое бывает часто, если у вас не мегабайты стилей), то CSS успевает загрузиться и примениться еще до того, как мы полностью подготовим JS для выполнения.
Глава 6, в которой слышится глас
Позади долгий путь. Но пора бы поговорить и о наболевшем — о шрифтах.
Господа дизайнеры, если вы читаете этот пост, пожалуйста, ограничьте свою фантазию двумя не стандартными шрифтами. Ну… тремя, если совсем не в терпёж. Но не стоит добавлять в приложение пятый! шрифт просто потому, что font-weight 100 для вас недостаточно тонкий.
Пожалуйста, помните, что шрифты:
- Конкурируют за траффик. Т. е. на узком канале все приложение грузится медленнее из-за дополнительной нагрузки.
- Конкурируют за пул запросов (а их всего 5-6 можно держать открытыми в параллели, если у вас HTTP v 1.x)
- Блокируют отрисовку текста. Пока шрифт не загрузится, вы будете видеть белый экран с картинками вместо текста.
- В конце концов едят трафик клиента, который, может быть и ограничен.
Давайте сделаем интернет чуточку быстрее (А мы, в свою очередь, постараемся не отправлять мегабайты JS).
Но хватит о грустном. Как вы догадались, у нас в приложении есть немного кастомных шрифтов. И они не такие маленькие как бы мне хотелось. Конечно, голь на выдумки хитра и почти первым делом после появления шрифтов мы включили font-swap (ооооочень рекомендую к изучению, если вы можете себе позволить динамическую смену шрифтов). Но несмотря на такой финт, LightHouse продолжает нам советовать использовать прелоад.
Так почему бы и не попробовать. Оборачиваем шрифты в link/preload:
<link rel="preload" href="best_font_ever.otf" as="font" type="font/otf" crossorigin="anonymus">
И снова запускаем тесты
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | 136ms (-44%) | 201ms (-8%) | 666ms (-15%) |
FMP | 1519ms (-12%) | 1418ms (-18%) | 3743ms (+8%) |
Как видим, улучшение пошло на пользу всем кроме fast3g (и, естественно более медленным соединениям). Именно там мы достигли максимума пропускной способности, в результате чего замедлилась загрузка всего приложения. Но, тем не менее размен выглядит нормально. В качестве бонуса, теперь UI не дергается из-за перерисовки шрифтов.
Если сравнить его с тем, с чем мы начинали то получится такая картина в выигранных миллисекундах:
Event | WIFI | 4G | Fast 3G |
---|---|---|---|
FP | -480ms | -614ms | -1700ms |
FMP | -570ms | -852ms | -68ms |
Выигрыш 500-850ms для контента для меня звучит убедительно. Кроме этого, мы получили почти мгновенный индикатор загрузки и убрали "дергающиеся" шрифты.
Глава 7, заключительная
В этой главе я экспериментировал с defer атрибутом (когда-то он давал прирост page speed у LightHouse и мне стало интересно проверить), но поскольку ничего интересного эксперименты не показали, а статья и так уже слишком разрослась, я заменил ее на выводы.
Итого, за полтора сомнительных решения (0.5 сомнительных попугаев за спиннер и 1 целый сомнительный попугай за предзагрузку данных из-под index.html) мы получили значительное ускорение появления индикатора загрузки (от 480ms до 1700ms), и выиграли от 500-800ms для контента на хороших соединениях. Как по мне, это достаточно хороший результат.
Вот таблицы прогресса для наглядности (кликабельно):
Надеюсь, вам было интересно.
Что мы еще не пробовали, но хотим:
- Объединение чанков
- Ревью рантайм рендера (что и сколько раз рендерится)
- Brotli, сейчас все тесты проходили на gzip.
- HTTP2.0. и параллельная загрузка множества чанков
Спасибо Jeff Cooper за предоставленные изображения.
Послесловие. А зачем вообще это нужно? На самом деле тут не только желание доказать свою проф. пригодность. Скорость загрузки сайта/площадки/приложения напрямую влияет на его конверсию (а значит и на деньги, которые зарабатываете вы или ваш клиент). Когда-то Google провел эксперимент и увеличил latency на 100-400ms, что уменьшило количество поисков на 0.2%-0.6% (что в его масштабах довольно значительно). CloudFlare в своей статье утверждают, что увеличение времени загрузки с 2.4ms до 3.3ms снижает конверсию на 0.4%, и чем дальше тем быстрее будет падать конверсия. Вот еще одна статья с инфографикой и примерами влияния производительности на рейтинг конверсии. Возможно, эти статьи пригодятся и вам, когда вы будете обосновывать необходимость выделения времени на оптимизации и рефакторинг.
Всем хорошего дня и берегите себя!
monochromer
А не пробовали еще использовать оптимизированный формат для шрифтов — woff2 или woff и удалять неиспользуемые глифы?
Drag13 Автор
Это в процессе.
А удаление глифов еще не пробовали, но думаем над этим. Если получится, то даже на слабых соединениях мы будем себя хорошо чувствовать.