Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В последние годы на instagram.com появилось много нового. Очень много. Например — средства создания историй, фильтры, творческие инструменты, уведомления, прямые сообщения. Однако по мере роста проекта всё это дало один печальный побочный эффект, который заключался в том, что производительность instagram.com начала падать. В течение прошлого года команда разработчиков Instagram прилагала постоянные усилия к тому, чтобы это исправить. Это привело к тому, что общее время загрузки ленты Instagram (feed page) снизилось почти на 50%.
Сегодня мы публикуем перевод первого материала из серии статей, посвящённых рассказу о том, как ускоряли instagram.com.
Улучшение производительности за прошедший год (лента Instagram, показатель Display Done, мс.)
Один из важнейших подходов к улучшению производительности веб-приложений заключается в правильной приоритизации загрузки и обработки ресурсов и в уменьшении времени простоя браузера в процессе загрузки страницы. В нашем случае многие из подобных оптимизаций показали себя более действенными, чем уменьшение размера кода. К размеру кода у нас, как правило, претензий не было. Он был достаточно компактным. Его размеры начали нас беспокоить только после того, как в проект было внесено множество небольших улучшений (об оптимизации размеров кода мы тоже планируем рассказать). Подобные улучшения, кроме того, оказывали меньшее влияние на процесс разработки проекта. Они требовали меньших изменений кода и меньшего объёма работ по рефакторингу. В результате мы изначально сконцентрировали свои усилия именно на этой области, начав с предварительной загрузки ресурсов.
Общий принцип наших оптимизаций заключался в том, чтобы как можно раньше информировать браузер о том, какие ресурсы необходимы для загрузки страницы. Мы, как разработчики проекта, во множестве случаев заранее знали о том, что именно для этого понадобится. Но браузер мог не иметь об этом никакого представления до тех пор, пока не будет загружена и обработана определённая часть материалов страницы. Ресурсы, о которых идёт речь, по большей части, включали в себя те, которые динамически загружаются средствами JavaScript (например — другие скрипты, изображения, материалы, нужные для выполнения XHR-запросов). Дело в том, что браузер не может обнаружить эти зависимые ресурсы до тех пор, пока не разберёт и не выполнит некий JavaScript-код.
Вместо того чтобы ждать до тех пор, пока браузер сам обнаружит эти ресурсы, мы могли дать ему подсказку, следуя которой он мог бы немедленно начать их загрузку. Мы это сделали, пользуясь HTML-атрибутами
Мы используем подобные подсказки для двух типов ресурсов на критически важных путях загрузки страниц. Это — динамически загружаемый JavaScript-код и динамически загружаемые материалы XHR-запросов GraphQL на получение данных. Динамически загружаемые скрипты — это скрипты, которые загружаются с помощью конструкций вида
Например, работая с входной точкой
Аналогично, если мы знаем о том, что некий GraphQL-запрос планируется выполнить для входной точки конкретной страницы, то это значит, что нам нужно произвести предварительную загрузку материалов для ускорения выполнения этого запроса. Это особенно важно из-за того, что выполнение подобных GraphQL-запросов иногда занимает много времени, а страница не может отрендериться до возврата результатов запроса. Из-за этого нам нужно сделать так, чтобы сервер как можно раньше занялся бы формированием ответов на такие запросы.
Изменения в особенностях загрузки страниц особенно заметны на медленных соединениях. Имитируя быстрое 3G-соединение (первый водопадный график, представленный ниже, иллюстрирующий ситуацию, когда предварительная загрузка ресурсов не используется) мы можем видеть, что загрузка
Предварительная загрузка не используется
Предварительная загрузка используется
В дополнение к тому, что использование атрибута
Проблема предварительной загрузки ресурсов заключается именно в том, что она даёт разработчику дополнительные рычаги воздействия на приоритет загрузки ресурсов. Это означает, что на разработчика возлагается больше ответственности за правильную расстановку приоритетов. Например, при тестировании сайта в регионах, в которых скорость мобильных и WiFi-сетей очень низка, и в которых наблюдается большой процент потери пакетов, мы обратили внимание на то, что запрос, выполняемый при обработке конструкции
Источником этой проблемы стало то, как мы расположили preload-теги на наших страницах. А именно, мы добавляли preload-подсказки только для бандлов, представляющих собой часть текущей страницы, которые мы собирались загружать клиентским маршрутизатором асинхронно.
Например, на странице выхода из системы мы загружаем
Вот как выглядит разметка, в которой реализована предварительная загрузка всех JavaScript-бандлов.
Одна из основных рабочих областей instagram.com — это Лента (Feed). Она представляет собой страницу с изображениями и видео, поддерживающую бесконечную прокрутку. Мы заполняем эту страницу так. Сначала загружаем первоначальный набор публикаций, а потом, по мере того, как пользователь прокручивает страницу, загружаем дополнительные наборы материалов. Однако нам не хотелось бы, чтобы пользователь ждал загрузки новых материалов каждый раз, когда он достигает низа ленты. В результате, для того, чтобы с этой страницей было бы удобно работать, мы загружаем новые наборы материалов до того, как пользователь дойдёт до конца ленты.
На практике это, по нескольким причинам, непростая задача:
Подход, который мы использовали для решения этой задачи, заключался в том, чтобы создать абстракцию задачи приоритизации, которая отвечает за постановку в очередь асинхронных заданий (в данном случае — это задачи по предварительной загрузке очередного набора публикаций для вывода в ленте). Подобная задача изначально ставится в очередь с приоритетом
В начале и в середине ленты задача предварительной загрузки данных имеет приоритет idle, а в конце ленты — приоритет high
После завершения загрузки JSON-данных для следующей партии публикаций мы ставим в очередь повторяющуюся фоновую задачу по предварительной загрузке изображений из этой партии. Предварительная загрузка изображений выполняется в том порядке, в котором публикации выводятся в ленте, а не параллельно. Это позволяет нам приоритизировать задачи по загрузке данных и вывести изображения для публикаций, которые ближе всего находятся к тому месту страницы, которое видит пользователь. Для загрузки изображений правильного размера мы используем скрытый медиа-компонент, параметры которого соответствуют параметрам текущей ленты. Внутри этого компонента имеется элемент
Общий эффект вышеописанных улучшений выразился в уменьшении времени, необходимого на загрузку фотографий, на 25%. Речь идёт об отрезке времени между моментом добавления кода публикации в DOM и моментом, когда изображение из публикации оказывается загруженным и выведенным на экран. Кроме того, это привело к тому, что на 56% сократилось время, которое пользователи, достигнув конца ленты, тратили на ожидание загрузки новых материалов.
Уважаемые читатели! Используете ли вы механизмы предварительной загрузки данных при оптимизации своих веб-проектов?
Сегодня мы публикуем перевод первого материала из серии статей, посвящённых рассказу о том, как ускоряли instagram.com.
Об оптимизации производительности веб-проектов
Улучшение производительности за прошедший год (лента Instagram, показатель Display Done, мс.)
Один из важнейших подходов к улучшению производительности веб-приложений заключается в правильной приоритизации загрузки и обработки ресурсов и в уменьшении времени простоя браузера в процессе загрузки страницы. В нашем случае многие из подобных оптимизаций показали себя более действенными, чем уменьшение размера кода. К размеру кода у нас, как правило, претензий не было. Он был достаточно компактным. Его размеры начали нас беспокоить только после того, как в проект было внесено множество небольших улучшений (об оптимизации размеров кода мы тоже планируем рассказать). Подобные улучшения, кроме того, оказывали меньшее влияние на процесс разработки проекта. Они требовали меньших изменений кода и меньшего объёма работ по рефакторингу. В результате мы изначально сконцентрировали свои усилия именно на этой области, начав с предварительной загрузки ресурсов.
Рассказ о предварительной загрузке изображений, JavaScript-кода и материалов, нужных для выполнения запросов, а также о том, в чём нужно проявлять осторожность
Общий принцип наших оптимизаций заключался в том, чтобы как можно раньше информировать браузер о том, какие ресурсы необходимы для загрузки страницы. Мы, как разработчики проекта, во множестве случаев заранее знали о том, что именно для этого понадобится. Но браузер мог не иметь об этом никакого представления до тех пор, пока не будет загружена и обработана определённая часть материалов страницы. Ресурсы, о которых идёт речь, по большей части, включали в себя те, которые динамически загружаются средствами JavaScript (например — другие скрипты, изображения, материалы, нужные для выполнения XHR-запросов). Дело в том, что браузер не может обнаружить эти зависимые ресурсы до тех пор, пока не разберёт и не выполнит некий JavaScript-код.
Вместо того чтобы ждать до тех пор, пока браузер сам обнаружит эти ресурсы, мы могли дать ему подсказку, следуя которой он мог бы немедленно начать их загрузку. Мы это сделали, пользуясь HTML-атрибутами
preload
. Выглядит это примерно так:<link rel="preload" href="my-js-file.js" as="script" type="text/javascript" />
Мы используем подобные подсказки для двух типов ресурсов на критически важных путях загрузки страниц. Это — динамически загружаемый JavaScript-код и динамически загружаемые материалы XHR-запросов GraphQL на получение данных. Динамически загружаемые скрипты — это скрипты, которые загружаются с помощью конструкций вида
import('...')
для конкретных клиентских маршрутов. Мы поддерживаем список соответствий серверных точек входа и скриптов клиентских маршрутов. В результате, когда мы, на сервере, получаем запрос на загрузку страницы, мы знаем о том, скрипты для каких клиентских маршрутов понадобится загрузить. В результате мы можем, при формировании HTML-кода страницы, добавить в него соответствующие подсказки.Например, работая с входной точкой
FeedPage
мы знаем о том, что клиентский маршрутизатор, в итоге, выполнит запрос на загрузку FeedPageContainer.js
. В результате мы можем добавить в код страницы следующую конструкцию:<link rel="preload" href="/static/FeedPageContainer.js" as="script" type="text/javascript" />
Аналогично, если мы знаем о том, что некий GraphQL-запрос планируется выполнить для входной точки конкретной страницы, то это значит, что нам нужно произвести предварительную загрузку материалов для ускорения выполнения этого запроса. Это особенно важно из-за того, что выполнение подобных GraphQL-запросов иногда занимает много времени, а страница не может отрендериться до возврата результатов запроса. Из-за этого нам нужно сделать так, чтобы сервер как можно раньше занялся бы формированием ответов на такие запросы.
<link rel="preload" href="/graphql/query?id=12345" as="fetch" type="application/json" />
Изменения в особенностях загрузки страниц особенно заметны на медленных соединениях. Имитируя быстрое 3G-соединение (первый водопадный график, представленный ниже, иллюстрирующий ситуацию, когда предварительная загрузка ресурсов не используется) мы можем видеть, что загрузка
FeedPageContainer.js
и выполнение связанного с ним GraphQL-запроса начинаются только после завершения загрузки Consumer.js
. Однако в случае, когда используется предварительная загрузка, загрузка скрипта FeedPageContainer.js
и выполнение GraphQL-запроса могут начаться сразу же после того, как окажется доступной HTML-страница. Это, кроме того, уменьшает время, необходимое для загрузки любых второстепенных скриптов, для работы с которыми используются механизмы ленивой загрузки. Здесь FeedSidebarContainer.js
и ActivityFeedBox.js
(которые зависят от FeedPageContainer.js
) начинают загружаться почти сразу после завершения обработки Consumer.js
.Предварительная загрузка не используется
Предварительная загрузка используется
Преимущества приоритизации предварительной загрузки
В дополнение к тому, что использование атрибута
preload
позволяет быстрее приступать к загрузке ресурсов, у применения этого механизма есть ещё одно преимущество. Оно заключается в увеличении сетевого приоритета асинхронной загрузки скриптов. Это становится важным при использовании асинхронно загружаемых скриптов в критически важных путях загрузки страниц, так как по умолчанию они загружаются с низким (Low) приоритетом. В результате приоритет XHR-запросов и изображений, относящихся к области страницы, видимой пользователям, будет выше, чем у материалов, находящихся за пределами области просмотра. Но это может привести к ситуациям, когда критически важные скрипты, необходимые для рендеринга страницы, либо блокируются, либо вынуждены делить полосу пропускания с другими ресурсами. Если интересно — вот обстоятельный рассказ о приоритетах ресурсов в Chrome. Вдумчивое использование механизма предварительной загрузки (подробнее об этом поговорим ниже) даёт разработчику определённый уровень контроля над тем, как браузер распределяет приоритеты в процессе первоначальной загрузки страницы. Это особенно актуально для тех случаев, когда разработчик знает о том, какие ресурсы важны для правильного отображения страницы.Проблемы приоритизации предварительной загрузки
Проблема предварительной загрузки ресурсов заключается именно в том, что она даёт разработчику дополнительные рычаги воздействия на приоритет загрузки ресурсов. Это означает, что на разработчика возлагается больше ответственности за правильную расстановку приоритетов. Например, при тестировании сайта в регионах, в которых скорость мобильных и WiFi-сетей очень низка, и в которых наблюдается большой процент потери пакетов, мы обратили внимание на то, что запрос, выполняемый при обработке конструкции
<link rel="preload" as="script">
получает более высокий приоритет, чем запрос, выполняемый при обработке тега <script />
JavaScript-бандлов, используемых в критически важных путях рендеринга страницы. Это приводит к увеличению общего времени загрузки страницы.Источником этой проблемы стало то, как мы расположили preload-теги на наших страницах. А именно, мы добавляли preload-подсказки только для бандлов, представляющих собой часть текущей страницы, которые мы собирались загружать клиентским маршрутизатором асинхронно.
<!-- предварительная загрузка бандлов, загружаемых маршрутизатором асинхронно -->
<link rel="preload" href="SomeConsumerRoute.js" as="script" />
<link rel="preload" href="..." as="script" />
...
<!-- скрипты, необходимые для загрузки начальной страницы -->
<script src="Common.js" type="text/javascript"></script>
<script src="Consumer.js" type="text/javascript"></script>
Например, на странице выхода из системы мы загружаем
SomeConsumerRoute.js
до Common.js
и Consumer.js
, а так как preload-ресурсы загружаются с более высоким приоритетом, но при этом не выполняется их разбор, это блокирует разбор Common.js
и Consumer.js
. Команда разработчиков Chrome Data Saver нашла похожую проблему с предварительной загрузкой и описала своё решение этой проблемы. В их случае было решено всегда размещать конструкции для предварительной загрузки асинхронных ресурсов после тегов <script />
тех ресурсов, которые пользуются этими асинхронными ресурсами. Мы решили организовать предварительную загрузку всех скриптов и размещать соответствующие конструкции в коде в том порядке, в котором они будут нужны. Это даёт нам возможность приступить к предварительной загрузке всех скриптовых ресурсов страницы настолько быстро, насколько это возможно. Сюда входят и теги для синхронной загрузки скриптов, которые не могут быть добавлены в HTML до тех пор, пока на странице не будут размещены определённые серверные данные. Это позволяет нам контролировать порядок загрузки скриптов.Вот как выглядит разметка, в которой реализована предварительная загрузка всех JavaScript-бандлов.
<!-- предварительная загрузка критически важных скриптов -->
<link rel="preload" href="Common.js" as="script" />
<link rel="preload" href="Consumer.js" as="script" />
<!-- предварительная загрузка бандлов, загружаемых маршрутизатором асинхронно -->
<link rel="preload" href="SomeConsumerRoute.js" as="script" />
...
<!-- скрипты, необходимые для загрузки начальной страницы -->
<script src="Common.js" type="text/javascript"></script>
<script src="Consumer.js" type="text/javascript"></script>
<script src="SomeConsumerRoute.js" type="text/javascript" async></script>
Предварительная загрузка изображений
Одна из основных рабочих областей instagram.com — это Лента (Feed). Она представляет собой страницу с изображениями и видео, поддерживающую бесконечную прокрутку. Мы заполняем эту страницу так. Сначала загружаем первоначальный набор публикаций, а потом, по мере того, как пользователь прокручивает страницу, загружаем дополнительные наборы материалов. Однако нам не хотелось бы, чтобы пользователь ждал загрузки новых материалов каждый раз, когда он достигает низа ленты. В результате, для того, чтобы с этой страницей было бы удобно работать, мы загружаем новые наборы материалов до того, как пользователь дойдёт до конца ленты.
На практике это, по нескольким причинам, непростая задача:
- Нам нужно, чтобы загрузка материалов, не видимых пользователю, не забирала бы сетевые и процессорные ресурсы у тех материалов, которые он просматривает.
- Нам не хотелось бы передавать по сети ненужные данные, слишком сильно стремясь выполнить предварительную загрузку публикаций, которые пользователь может даже и не посмотреть. Но, с другой стороны, если мы не выполним предварительную загрузку достаточного количества материалов, это часто будет означать риск того, что пользователь «упрётся» в конец ленты.
- Проект instagram.com спроектирован в расчёте на работу на различных устройствах и на экранах различных размеров. В результате мы выводим изображения в ленте, используя атрибут
srcset
тега<img>
. Этот атрибут позволяет браузеру, учитывая размер экрана, принять решение о том, изображения какого разрешения нужно использовать. Это означает, что нам не так уж и просто определить разрешения изображений, которые нужно загрузить заранее. К тому же, есть риск предварительно загрузить изображения, которыми браузер пользоваться не будет.
Подход, который мы использовали для решения этой задачи, заключался в том, чтобы создать абстракцию задачи приоритизации, которая отвечает за постановку в очередь асинхронных заданий (в данном случае — это задачи по предварительной загрузке очередного набора публикаций для вывода в ленте). Подобная задача изначально ставится в очередь с приоритетом
idle
(тут используется requestIdleCallback
). Это значит, что выполнение подобной задачи не начнётся до тех пор, пока браузер занят любой другой важной работой. Однако если пользователь прокручивает страницу достаточно близко к тому месту, где кончается текущий набор загруженных публикаций, приоритет этой задачи предварительной загрузки материалов меняется на high
. Делается это путём отмены коллбэка, находящегося в режиме ожидания, после чего немедленно запускается процесс предварительной загрузки материалов.В начале и в середине ленты задача предварительной загрузки данных имеет приоритет idle, а в конце ленты — приоритет high
После завершения загрузки JSON-данных для следующей партии публикаций мы ставим в очередь повторяющуюся фоновую задачу по предварительной загрузке изображений из этой партии. Предварительная загрузка изображений выполняется в том порядке, в котором публикации выводятся в ленте, а не параллельно. Это позволяет нам приоритизировать задачи по загрузке данных и вывести изображения для публикаций, которые ближе всего находятся к тому месту страницы, которое видит пользователь. Для загрузки изображений правильного размера мы используем скрытый медиа-компонент, параметры которого соответствуют параметрам текущей ленты. Внутри этого компонента имеется элемент
<img>
, который использует атрибут srcset
, такой же, который используется для вывода реальных публикаций в ленте. Это означает, что мы можем предоставить браузеру возможность принимать решения о том, предварительную загрузку каких изображений нужно выполнять. В результате браузер будет использовать при выводе изображений ту же логику, которую он использовал при их предварительной загрузке. Это ещё означает и то, что мы, используя подобный медиа-компонент, можем выполнять предварительную загрузку изображений для других областей сайта. Таких, как страницы профиля пользователя.Общий эффект вышеописанных улучшений выразился в уменьшении времени, необходимого на загрузку фотографий, на 25%. Речь идёт об отрезке времени между моментом добавления кода публикации в DOM и моментом, когда изображение из публикации оказывается загруженным и выведенным на экран. Кроме того, это привело к тому, что на 56% сократилось время, которое пользователи, достигнув конца ленты, тратили на ожидание загрузки новых материалов.
Уважаемые читатели! Используете ли вы механизмы предварительной загрузки данных при оптимизации своих веб-проектов?