Взгляд на устранение распространенных и необычных узких мест в производительности одного из крупнейших в мире PWA на React.js, Twitter Lite.

Создание быстрого веб-приложения включает в себя множество циклов измерения того, на что тратится время, понимания того, почему это происходит, и применения потенциальных решений. К сожалению, быстрого решения нет. Производительность - это непрерывная игра, в которой нужно наблюдать и оценивать области, которые нужно улучшить. В Twitter Lite мы внесли небольшие улучшения во многие области: от времени начальной загрузки до рендеринга компонентов React (и предотвращения повторного рендеринга), загрузки изображений и многого другого. Большинство изменений, как правило, небольшие, но они складываются, и в результате мы получаем одно из самых больших и быстрых прогрессивных веб-приложений.

Прежде чем продолжить чтение:

Если вы только начинаете измерять и работать над повышением производительности своего веб-приложения, я настоятельно рекомендую научиться читать графики пламени, если вы еще не знаете, как это сделать.

В каждом разделе ниже приведены примеры снимков экрана с записями временной шкалы из Инструментов разработчика Chrome. Чтобы прояснить ситуацию, я выделил каждую пару примеров, показывая, что плохо (левое изображение) и что хорошо (правое изображение).

Особое примечание относительно временных шкал и графиков пламени: поскольку мы нацелены на очень широкий спектр мобильных устройств, мы обычно записываем их в смоделированной среде: в 5 раз медленнее ЦП и сетевое соединение 3G. Это не только более реалистично, но и делает проблемы более очевидными. Они также могут быть искажены, если мы используем Профилирование компонентов React v15.4.0. Фактические значения на графике производительности настольных компьютеров, как правило, намного быстрее, чем показано здесь.

Оптимизация для браузера

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

Webpack - это мощный инструмент, но его сложно освоить. Некоторое время у нас были проблемы с CommonsChunkPlugin и тем, как он работал с некоторыми из наших зависимостей циклического кода. Из-за этого у нас осталось всего 3 файла ресурсов JavaScript общим размером более 1 МБ (размер передачи gzip 420 КБ).

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

После долгих споров мы наконец смогли разбить общие области маршрутами на отдельные части (пример ниже). Наконец настал день, когда этот обзор кода попал в наши почтовые ящики:

Добавляет детальное разделение кода на основе маршрута. Более быстрый начальный рендеринг и рендеринг HomeTimeline обмениваются на больший общий размер приложения, который распределяется по 40 блокам по запросу и амортизируется в течение всего сеанса. - Николас Галлахер

Наша первоначальная установка (вверху слева) заняла более 5 секунд для загрузки нашего основного пакета, а после разделения кода по маршрутам и общим фрагментам (вверху справа) это займет всего 3 секунды (в моделируемой сети 3G).

Это было сделано на ранних этапах наших спринтов, ориентированных на производительность, но это единственное изменение имело огромное значение при запуске инструмента аудита веб-приложений Google Lighthouse:

Избегайте функций, вызывающих джанк

На протяжении многих итераций наших шкал времени с бесконечной прокруткой мы использовали разные способы для расчета положения и направления прокрутки, чтобы определить, нужно ли нам запрашивать у API отображение большего количества твитов. До недавнего времени мы использовали react-waypoint, что нам хорошо работало. Однако в погоне за максимально возможной производительностью одного из основных компонентов нашего приложения оно было недостаточно быстрым.

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

Но сначала мы должны понять, что означают инструменты разработчика, когда говорят нам, что существует «мусор».

Большинство современных устройств обновляют свои экраны 60 раз в секунду. Если выполняется анимация или переход, или пользователь прокручивает страницы, браузер должен соответствовать частоте обновления устройства и отображать 1 новое изображение или кадр для каждого из этих обновлений экрана.

Каждый из этих кадров имеет бюджет чуть более 16 мс (1 секунда / 60 = 16,66 мс). На самом деле, однако, браузеру нужно выполнять служебную работу, поэтому вся ваша работа должна быть завершена за 10 мс. Когда вы не можете уложиться в этот бюджет, частота кадров падает, и контент на экране дрожит. Это часто называют джанком, и это негативно влияет на восприятие пользователем. - Пол Льюис о производительности рендеринга

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

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

Используйте изображения меньшего размера

Сначала мы начали настаивать на использовании меньшей пропускной способности в Twitter Lite, работая с несколькими командами, чтобы получать новые изображения меньшего размера, доступные на наших CDN. Оказывается, уменьшив размер изображений, которые мы рендерили, чтобы они были только тем, что нам абсолютно необходимо (как с точки зрения размеров, так и качества), мы обнаружили, что не только уменьшили использование полосы пропускания, но и смогли повысить производительность в браузере, особенно при прокрутке временных шкал твитов с большим количеством изображений.

Чтобы определить, насколько лучше изображения меньшего размера для производительности, мы можем взглянуть на временную шкалу Raster в Инструментах разработчика Chrome. До того, как мы уменьшили размер изображений, для декодирования одного изображения могло потребоваться 300 мс или более, как показано на временной шкале записи ниже слева. Это время обработки после загрузки изображения, но до его отображения на странице.

Когда вы прокручиваете страницу и стремитесь к стандарту рендеринга 60 кадров в секунду, мы хотим, чтобы как можно больше обработки не превышало 16,667 мс (1 кадр). У нас уходит почти 18 кадров только на то, чтобы отобразить одно изображение во вьюпорте, а это слишком много. Еще одна вещь, на которую следует обратить внимание на временной шкале: вы можете видеть, что Основная временная шкала в основном заблокирована от продолжения, пока это изображение не завершит декодирование (как показано пробелом). Это означает, что у нас здесь довольно узкое место в производительности!

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

Оптимизация React

Используйте shouldComponentUpdate method

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

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

Ниже вы увидите два графика этого действия. Без shouldComponentUpdate (слева) мы можем видеть все его дерево обновленным и перерисованным, просто чтобы изменить цвет сердца где-нибудь еще на экране. После добавления shouldComponentUpdate (справа) мы предотвращаем обновление всего дерева и предотвращаем потерю более одной десятой секунды на ненужную обработку.

Отложить ненужную работу до componentDidMount

Это изменение может показаться несложным, но при разработке большого приложения, такого как Twitter Lite, легко забыть о мелочах.

Мы обнаружили, что в нашем коде есть много мест, где мы выполняем дорогостоящие вычисления ради аналитики во время метода жизненного цикла componentWillMount React. Каждый раз, когда мы это делали, мы немного больше блокировали рендеринг компонентов. 20 мс здесь, 90 мс там, все быстро складывается. Первоначально мы пытались записать, какие твиты обрабатывались нашей службой анализа данных в componentWillMount, до того, как они были фактически обработаны (временная шкала внизу слева).

Перенеся это вычисление и сетевой вызов в метод componentDidMount компонента React, мы разблокировали основной поток и уменьшили нежелательный мусор при рендеринге наших компонентов (вверху справа).

Избегайте опасно SetInnerHTML

В Twitter Lite мы используем значки SVG, поскольку они являются наиболее портативным и масштабируемым вариантом из доступных для нас. К сожалению, в более старых версиях React большинство атрибутов SVG не поддерживалось при создании элементов из компонентов. Итак, когда мы впервые начали писать приложение, мы были вынуждены использовать dangerouslySetInnerHTML, чтобы использовать значки SVG в качестве компонентов React.

Например, наш оригинальный HeartIcon выглядел примерно так:

Мало того, что не рекомендуется использовать dangerouslySetInnerHTML, но оказывается, что это на самом деле источник медлительности при монтировании и рендеринге.

Анализируя приведенные выше графики пламени, наш исходный код (слева) показывает, что на медленном устройстве требуется около 20 мсек, чтобы смонтировать действия в нижней части твита, содержащего четыре значка SVG. Хотя это может показаться не таким уж большим, зная, что нам нужно отображать многие из них одновременно, все время прокручивая шкалу бесконечных твитов, мы поняли, что это огромная трата времени.

Поскольку в React v15 добавлена ​​поддержка большинства атрибутов SVG, мы пошли дальше и посмотрели, что произойдет, если мы избежим dangerouslySetInnerHTML. Проверяя исправленный график пламени (вверху справа), мы получаем в среднем 60% экономии каждый раз, когда нам нужно смонтировать и отобразить один из этих наборов значков!

Теперь наши значки SVG представляют собой простые компоненты без сохранения состояния, не используют «опасные» функции и монтируются в среднем на 60% быстрее. Они выглядят так:

Отложить рендеринг при монтировании и размонтировании многих компонентов

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

Обратите внимание на изображение ниже, как значок «Домой» обновляется почти за 2 секунды и показывает, что он был нажат:

Нет, это был не просто GIF с низкой частотой кадров. На самом деле это было так медленно. Но все данные для главного экрана уже загружены, так почему же так много времени, чтобы что-то показать?

Оказывается, монтирование и размонтирование больших деревьев компонентов (например, шкалы времени твитов) в React очень дорого обходится.

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

После применения к нашей HomeTimeline мы увидели почти мгновенный отклик панели навигации, что привело к ощутимым улучшениям в целом.

const DeferredTimeline = deferComponentRender(HomeTimeline);
render(<DeferredTimeline />);

Оптимизация Redux

Избегайте слишком частого сохранения состояния

В то время как контролируемые компоненты кажутся рекомендуемым подходом, создание контролируемых входов означает, что они должны обновляться и повторно визуализироваться при каждом нажатии клавиши.

Хотя для настольного компьютера с тактовой частотой 3 ГГц это не очень сложно, небольшое мобильное устройство с очень ограниченным ЦП заметит значительную задержку при наборе текста, особенно при удалении большого количества символов из ввода.

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

Внизу (слева) на типичном устройстве Android 5 каждое нажатие клавиши, приводящее к изменению, может вызвать около 200 мс накладных расходов. Добавьте к этому быстрый наборщик, и мы оказались в очень плохом состоянии, когда пользователи часто сообщали, что их точка вставки символа перемещалась повсюду, что приводило к беспорядочным предложениям.

Удалив черновое состояние твита из обновления основного состояния Redux при каждом нажатии клавиши и сохранив все локально в состоянии компонента React, мы смогли снизить накладные расходы более чем на 50% (вверху справа).

Пакетные действия в одной отправке

В Twitter Lite мы используем redux с react-redux, чтобы подписать наши компоненты на изменения состояния данных. Мы оптимизировали наши данные в отдельные области более крупного магазина с помощью Normalizr и combReducers. Все это прекрасно работает, предотвращая дублирование данных и сохраняя небольшие размеры наших магазинов. Однако каждый раз, когда мы получаем новые данные, мы должны отправлять несколько действий, чтобы добавить их в соответствующие хранилища.

С учетом того, как работает react-redux, это означает, что каждое отправленное действие заставит наши подключенные компоненты (называемые контейнерами) пересчитывать изменения и, возможно, повторно отрисовывать.

Хотя мы используем собственное промежуточное ПО, есть другое пакетное промежуточное ПО доступно. Выберите тот, который вам подходит, или напишите свой.

Лучший способ продемонстрировать преимущества пакетных действий - использовать Chrome React Perf Extension. После начальной загрузки мы предварительно кешируем и вычисляем непрочитанные DM в фоновом режиме. Когда это происходит, мы добавляем множество различных сущностей (разговоры, пользователей, записи сообщений и т. Д.). Без пакетной обработки (внизу слева) вы можете видеть, что в итоге мы получаем удвоенное количество визуализаций каждого компонента (~ 16) по сравнению с с пакетной обработкой (~ 8) ( внизу справа).

Сервисные работники

Хотя сервис-воркеры доступны не во всех браузерах, они являются бесценной частью Twitter Lite. Когда доступно, мы используем наши для push-уведомлений, для предварительного кеширования ресурсов приложений и многого другого. К сожалению, поскольку это довольно новая технология, производительность еще предстоит многому.

Предварительно кэшированные активы

Как и большинство продуктов, Twitter Lite никоим образом не готов. Мы все еще активно его развиваем, добавляем функции, исправляем ошибки и делаем его быстрее. Это означает, что нам часто нужно развертывать новые версии наших ресурсов JavaScript.

К сожалению, это может стать обузой, когда пользователи возвращаются к приложению и им необходимо повторно загрузить кучу файлов сценариев только для просмотра одного твита.

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

Так что это значит для пользователя? Последующие приложения загружаются практически мгновенно, даже после того, как мы развернули новую версию!

Как показано выше (слева) без предварительного кэширования ServiceWorker, каждый актив для текущего представления принудительно загружается из сети при возврате в приложение. В хорошей сети 3G для завершения загрузки требуется около 6 секунд. Однако, когда ресурсы предварительно кэшируются ServiceWorker (вверху справа), та же сеть 3G занимает менее 1,5 секунд до завершения загрузки страницы. Улучшение на 75%!

Задержка регистрации рабочего

Во многих приложениях можно безопасно зарегистрировать ServiceWorker сразу при загрузке страницы:

<script>
window.navigator.serviceWorker.register('/sw.js');
</script>

Хотя мы стараемся отправить в браузер как можно больше данных, чтобы страница выглядела полноценно, в Twitter Lite это не всегда возможно. Возможно, мы отправили недостаточно данных или страница, на которую вы попадаете, может не поддерживать данные, предварительно заполненные с сервера. Из-за этого и различных других ограничений нам необходимо сделать некоторые запросы API сразу после начальной загрузки страницы.

Обычно это не проблема. Однако, если в браузере еще не установлена ​​текущая версия нашего ServiceWorker, нам нужно сообщить ему об установке - и вместе с этим поступает около 50 запросов на предварительное кэширование различных ресурсов JS, CSS и изображений.

Когда мы использовали простой подход, заключающийся в немедленной регистрации нашего ServiceWorker, мы могли видеть, что в браузере происходит конфликт в сети, что привело к максимальному пределу параллельных запросов (внизу слева).

Отложив регистрацию ServiceWorker до тех пор, пока мы не завершим загрузку дополнительных запросов API, CSS и изображений, мы позволяем странице завершить рендеринг и реагировать, как показано на следующем снимке экрана (справа вверху).

В целом, это лишь некоторые из многих улучшений, которые мы со временем внесли в Twitter Lite. Конечно, впереди еще больше, и мы надеемся и дальше рассказывать о проблемах, которые мы находим, и о способах их преодоления. Чтобы узнать больше о том, что происходит в режиме реального времени, а также узнать больше о React и PWA, подписывайтесь на меня и команду в Twitter.