Как мы в 4 раза ускорили мобильную версию ВКонтакте

8469fc154e660a88506a6f2d6631184a.png

Для пользователей важно, чтобы сайт загружался быстро, — мало кто готов тратить время на ожидание. А ещё загрузка страниц влияет на оптимизацию в поисковых системах: чем быстрее открывается сайт, тем выше вероятность, что его найдёт целевая аудитория. 

Меня зовут Тарас Иванов, уже семь лет я развиваю фронтенд ВКонтакте, а сейчас руковожу командой, которая занимается производительностью и инфраструктурой. В статье я расскажу, как мы с командой ускорили загрузку m.vk.com в 4 раза, на какие метрики обращали внимание и какие сервисы использовали для оценки эффективности. Описанные способы и инструменты помогут повысить производительность в любом проекте: от многостраничных платформ с большим количеством legacy до свежих лаконичных лендингов. Актуально как для мобильной, так и десктопной версий.  

Несколько слов о мобильной версии ВКонтакте 

Мобильная версия ВКонтакте появилась в 2011 году. Сперва это был максимально простой сайт для WAP-браузеров, который поддерживал базовые функции десктопной версии. Разработчики делали всё, чтобы код работал стабильно даже при очень медленном соединении и на самых простых устройствах, буквально кнопочных телефонах. 

Я присоединился к команде в 2017 году и стал вторым разработчиком на всю мобильную версию ВКонтакте. Для сравнения сейчас над m.vk.com работает больше 10 продуктовых команд, каждая из которых отвечает за свой раздел. Это помогает нам идти в ногу с развитием десктопной версии и приложения ВКонтакте, с которыми у нас единый бэкенд (веб-интерфейс при этом отдельный). 

Сейчас мобильная версия ВКонтакте — это огромное SPA. К нам приходит до 87,7 миллионов человек в месяц только в России, и значительная часть из них — именно мобильная аудитория. Кнопочные телефоны ушли в прошлое: 96% пользователей поддерживают ES2021. Большинство новых разделов написаны на React с использованием UIKit. 

Такой быстрый рост не мог пройти незаметно для производительности. В один момент мы стали собирать данные о перформансе с реальных пользователей и обнаружили, что время отрисовки страницы на 75-м перцентиле составляет 8 секунд. Т.е. у 75% пользователей сайт грузится не дольше, чем за 8 секунд. 

Почему же производительность очень важна?

Есть утверждения гигантов индустрии (можно почитать здесь), что увеличение скорости доставки контента положительно влияет на бизнес-метрики. И это действительно так. На графике ниже вы можете увидеть, как улучшились наши продуктовые показатели за последние 2 года.

93f4cf2500589153598520269d529892.png

Какие метрики производительности существуют и как мы можем на них повлиять

Есть готовый набор метрик Web Vitals, чтобы объективно определить производительность сайта. Я хочу обратить внимание на три основных показателя. 

1. TTFB (Time To First Byte) —время работы сервера и передачи данных по сети. 

2. FCP (First Contentful Paint) — время до момента первой отрисовки. Когда на странице появилось хоть что-то отличное от белого экрана. Нужно уточнить, что этот показатель включает в себя и TTFB.

3. LCP (Largest Contentful Paint) — время до отрисовки уже полезного контента, который браузер определил, как основное на странице. Часто оно совпадает со временем полной отрисовки. 

Есть несколько способов измерить Web Vitals. Самый простой — открыть DevTools, выбрать вкладку Lighthouse и нажать на кнопку «Анализировать страницу». Также можно выбрать вкладку Performance: получится примерно тот же результат, только подробнее. 

Увидеть цельную картину, как реальные пользователи видят наш сайт, помогает Google Chrome. Браузер замеряет скорость загрузки страниц у пользователей и собирает результаты на сайте pagespeed.web.dev. Учитываются и медленные девайсы, и нестабильное соединение, и остальные вводные. Это позволяет увидеть более реальную картину. Важно, что полученные результаты совпадают с нашими замерами. 

Также можно использовать встроенный класс PerformanceObserver из библиотеки web-vitals, которую можно скачать из NPM (N).  Собранные данные можно отправлять себе на сервер, чтобы самостоятельно отслеживать, как оптимизации влияют на производительность. Такой подход помогает убедиться, что именно ваши изменения повлияли на производительность.

Хотел бы еще обратить внимание, что замеры через DevTools не отражают реальную ситуацию: они показывают, как сайт загружается на устройстве разработчика. На MacBook Pro и хорошем wi-fi это может происходить очень быстро. Но у большинства пользователей условия будут другие. В нашем случае результаты замеров на стороне пользователей в разы отличались от показателей DevTools. 2,8 с на 75-м перцентиле у реальных пользователей (тоже с MacBook). Поэтому способ подходит только для дебага, и опираться на него в проде не стоит. 

0ebb8b41631186a71e6366966016b9af.png

Как мы можем улучшить Web Vitals

Чтобы понять, как улучшить метрики Web Vitals, разберёмся, как браузер отрисовывает страницу и что может ему помешать. 

1. Браузер парсит страницу сверху вниз по мере загрузки. Первые сложности возникают, когда он доходит до блокирующих скриптов: скачивает их, исполняет и только потом идёт дальше. Ключевое слово здесь «исполняет», потому что это занимает определенное время, и иногда оно может быть даже дольше, чем время на скачивание файлов.

2. Следующий барьер — CSS. Здесь ситуация чуть лучше, парсинг страницы не блокируется. Тем не менее, браузеру нужно скачать файлы, чтобы построить из них дерево стилей, и это занимает время. 

3. Затем браузер добирается до вёрстки: скачивает HTML и ноду за нодой строит DOM-дерево. 

4. Финальный этап наступает, когда DOM-дерево объединяется с CSS-деревом в RenderTree. Происходит немного магии с расчётом места под элементы — и вуаля, картинка на экране. 

f2d56954e71e010dbcf4bc3a688a78c7.png

Получается, чтобы быстро отрисовать страницу, нужно:

  1. убрать все блокирующие запросы;

  2. заинлайнить CSS, который нужен, чтобы отрисовать лэйаут;

  3. перенести вёрстку лэйаута в самый верх страницы.

Браузеру будет достаточно распарсить первый чанк HTML, чтобы сразу отрисовать страницу.

Всё это звучит несложно, если стоит задача оптимизировать лендинг или недавно созданный сайт. Но когда речь заходит о давно существующих больших проектах, то возникают препятствия: огромное количество legacy-кода, 10–30 блокирующих скриптов, которые нельзя сделать асинхронными, потому что это сломает сайт, 5–10 CSS-бандлов. А если не повезёт, ещё и Inline JS прямо в HTML-разметке, а также inline-обработчики событий на DOM-нодах. 

Мы столкнулись со всеми этими сложностями, но даже несмотря на них, нашли способ ускорить отрисовку сайта. Дальше буду рассказывать, как увеличить производительность в любом проекте — лучше всего советы сработают, если вы используете SSR. 

Что мы делали у себя в проекте для повышения производительности

Шаг 1. Убрали всё лишнее

Мы проанализировали всё, что происходит до начала отрисовки страницы, и обнаружили, что подключается много неиспользуемого кода. Решили от него избавиться, для этого настроили сборщик Webpack

Затришейкали все, что не нужно. В нашем случае это были объёмные бандлы с иконками. Разделили код по модулям и вынесли дублируемые части в отдельные чанки. Так нам удалось сократить объём загружаемого кода на 2 Мб и ускорить рендер страницы на 3 секунды. 

aa9b3b779716f58553c1b242a7011a63.png

Далее мы избавились от дублей кода и в целом стали меньше подключать JS на страницу. Это сократило время отрисовки еще на секунду.

ff0d3bd9318a8e8ce51de7d80f2493d5.png

Шаг 2. Использовали Early Hints

Относительно новая технология (тут можно о ней почитать), которая позволяет загружать любые ассеты, будь это скрипты, CSS или изображения, ещё до 200 ответа страницы. Поддерживается с 103 версии Google Chrome. 

Всё, что нужно сделать, — после получения запроса от браузера на сервере передать заголовки с кодом 103, в которых перечислены пути до файлов для предзагрузки. И дальше, пока сервер вычисляет сложную логику (в нашем случае, например, строит умную ленту), браузер уже начинает скачивать файлы. В результате к моменту получения 200 ответа от сервера ассеты уже загружены, полностью или частично. 

300cb9a54fe8c2cb61aae15ba83aa4b5.png

Шаг 3. Улучшили наш сборщик Web Vitals метрик

Метрики стали собираться не с 10% аудитории, как это было ранее, а со всей. Оказалось, что реальная картинка отличается от того, как мы ее видели до этого. В действительности общая аудитория грузит сайт на 1секунду медленнее, чем мы это видели ранее. Этим обусловлен скачок вверх на графике.

Шаг 4. Внедрили механизм для асинхронной загрузки статики StaticManager

Механизм позволяет сделать загрузку JS не блокирующей и при этом не сломать сайт. 

Мы хотели с помощью StaticManager сделать загрузку бандлов асинхронной, чтобы не блокировать рендер. У нас много InlineJS-кода прямо в верстке — и его исполнение нужно отложить до того, как будет выполнен код бандлов. При этом сайт должен продолжать работать. Если начать исполнять инлайн-код до того, как исполнился код основных бандлов, то сайт сломается. Поэтому дефолтное событие onLoad здесь не подходит. Нужен собственный механизм для отслеживания готовности страницы.

Мы разработали и внедрили собственный механизм для асинхронной загрузки статических элементов StaticManager. Он помогает отслеживать готовность исполнения кода всех бандлов. 

Концептуальный алгоритм реализации

Сначала получаем полный список бандлов для асинхронной загрузки. Добавляем ключ resolved. Чтобы определить, что код бандла исполнился, необходимо в конце каждого бандла написать наш кастомный метод от StaticManager, который будет менять resolved на true. 

Мы сделали плагин для Webpack, который автоматически прописывает строчку для всех бандлов при сборке.

Код, который зависит от подключаемого JS, оборачиваем в staticManager.onReady и добавляем в очередь на исполнение.

4ee53da5ba39f726f36963a7ac1913fa.png

После того, как все файлы будут отмечены выполненными, исполняем все подписки из очереди в порядке их добавления. Если подписки будут созданы после того, как onReady отработает, то StaticManager исполнит их незамедлительно.

Что этот подход нам дает?

Загрузка всех JS-скриптов стала отложенной, кроме самого StaticManager. Что, в свою очередь, ускоряет парсинг и отрисовку страницы. Сайт при этом работает корректно, т.к. порядок выполнения кода не изменился, хотя и стал отложенным. Количество блокирующих бандлов сократилось с 30 до 6. 

Но что если пользователь начнет нажимать на все кнопки до того, как JS загрузился? В нашем случае, например, пытаться поставить лайк, написать сообщение или перейти на другую страницу.

С точки зрения кода это обращение к глобальным переменным, которых ещё не существует, через Inline-события, например onClick или onKeyDown.

В этом случае мы решили использовать Proxy. Применяем его для всех событий, которые генерирует пользователь, и складываем их в очередь. По мере того, как код исполняется, Proxy-объекты заменяются на реальные. А после мы поочередно выполняем события от пользователей — или отменяем их, если загрузка шла слишком долго. 

A/B-тест показал, что после внедрения StaticManager и Proxy время отрисовки страниц мобильной версии ВКонтакте сократилось на 20–30% в зависимости от раздела. И снизилось до 3,6 секунд на 75-м перцентиле.

5a2c41874915234066c8307de8afc08d.png

Шаг 5. Сократили количество CSS-бандлов, обязательных для отрисовки

Раньше, вне зависимости от страницы, мы подключали в среднем 8 CSS-бандлов, в которых содержалось 80% не используемого на странице кода. От него было невозможно достаточно быстро избавиться с помощью рефакторинга. 

Тогда мы решили отложить скачивание бандлов, а в шапку страницы добавить Inline-стили, в которых бы содержались только используемые CSS-правила. Важно, чтобы правила создавались на лету для каждой отдельной страницы. Предгенерация не сработает, потому что одна и та же страница у разных людей может отображаться по-разному: у меня одна лента новостей, у вас — другая. 

Что нужно сделать для Inline-вставки стилей:

  1. собрать список всехCSS-правил для проекта с помощью Webpack;

  2. когда HTML будет полностью построен, разобрать содержимое body на токены;

  3. выделить из токенов все используемые на странице классы, теги, атрибуты и ID;

  4. пересечь их с существующими CSS-правилами. 

В итоге получаем достаточный список CSS-правил для рендера страницы независимо от динамически формируемого содержимого. Список практически не содержит неиспользуемых стилей. Добавляем список в шапку страницы и допиливаем StaticManager, чтобы он дожидался загрузки всех асинхронных CSS-бандлов и только потом вызывал staticManager.onReady.

После обновлений этап вычислений на сервере стал чуть дольше — вырос показатель TTFB. Зато время первой отрисовки на 75-м перцентиле сократилось до 3,3 секунд. 

3bd0dc3e7e3a0c65e678c5fc8e01dfd1.png

Шаг 6. Избавились от блокировки StaticManager

Теперь единственное, что блокирует загрузку страницы, — это StaticManager. Но чтобы получить максимальный результат, нужно полностью избавиться от блокирующих запросов. Это важно, т.к. время, которое у пользователя уходит на загрузку бандла в среднем дольше, чем время его исполнения.

Для этого бандл, который содержит Static Manager, мы решили загружать в асинхронном режиме. Написали маленький Inline-скрипт, единственная задача которого — собирать все вызовы Inline-кода в очередь, игнорируя пользовательские события. А когда Static Manager загрузится, передавать их ему на исполнение. 

А что насчет пользовательских событий?

Мы не проксируем пользовательские события, пока Static Manager не загрузится, что может вызвать много ошибок. Но выход есть: используем CSS свойство «Pointer-events: none.» Таким образом мы блокируем все действия пользователей до момента, пока Static Manager не скачается. Это касается только ссылок и кнопок. Скроллинг страницы остается возможным. Как показали исследования время такой блокировки оказывается несущественным и практически незаметно для пользователя.

A/B-тесты показали, что после описанных действий FCP улучшился ещё на 15%, а LCP — на 12%. Среднее время на первую отрисовку стало занимать до 2,5 секунд на 75-м перцентиле, а локальные замеры показывали ещё более высокую производительность. 

ddfa28af05847c038ce4725a61cafb34.png

Профит

  • Браузер сразу переходит к разбору HTML.

  • CSS строится только из используемых на странице стилей.

  • Как только первый кусок HTML будет разобран, он сразу будет отрисован.

  • Параллельно начинается загрузка изображений с CDN.

  • Эвристики браузера сами определяют, что нужно скачать в первую очередь, чтобы ускорить LCP.

  • Пользователь уже может взаимодействовать со страницей до полной загрузки.

Как это помогло реальным пользователям 

За счёт ускорения отрисовки сайта мы перестали терять 1% аудитории —Это в свою очередь привело к росту всех продуктовых метрик мобильной версии. 

  • Пользователи стали проводить на 1,2% больше времени ВКонтакте.

  • Количество пользователей ленты выросло на 3%.

  • Музыку стали слушать больше на 20%.

  • Переход в раздел приложений увеличился на 4,4%.

4f58542527f3fb90fd15ad35032fbef1.png

Вы можете адаптировать схему под свой проект. Но для начала нужно определить для себя, какой профит вы получите. Сделать это достаточно просто: есть сервис WebPageTest, который позволяет провести эксперименты не модифицируя код.

Нашим примером я хотел доказать, что безвыходных ситуаций не бывает. С какими бы трудностями вы ни столкнулись, всегда можно побороться за перформанс. 

В основе статьи — доклад на профессиональной конференции фронтенд-разработчиков FrontendConf. Здесь можно посмотреть запись выступления.

Habrahabr.ru прочитано 3207 раз