Twitter Lite и высокопроизводительные прогрессивные веб-приложения на React
Взгляд на то, как удаляли обычные и необычные узкие места в производительности при создании одного из крупнейших в мире прогрессивных веб-приложений (PWA) на React.js — Twitter Lite
Создание быстрого веб-приложения требует многих циклов измерений, куда тратится время. Нужно понять, почему это происходит, и применить потенциальные исправления. К сожалению, не бывает одного простого решения. Производительность — это бесконечная игра, где мы ищем и измеряем области для улучшения. В Twitter Lite сделано много маленьких улучшений в разных сферах: от первоначального времени загрузки до рендеринга компонентов React (и предотвращения повторного рендеринга), загрузки изображений и много другого. Большинство изменений небольшие, но они складываются, и в конечном результате мы получили одно из самых больших и быстрых прогрессивных веб-приложений.
Перед началом чтения
Если вы только начали измерения и работу над улучшением производительности своего веб-приложения, то настоятельно рекомендую изучить, как читать flame-графики, если вы ещё этого не знаете.
В каждом разделе приводятся примеры скриншотов с записью таймлайнов из Chrome Developer Tools. Чтобы сделать примеры понятнее, я выделяю на каждой паре скришотов, что плохо (сверху), а что хорошо (снизу).
Особое замечание по поводу таймлайнов и flame-графиков. Поскольку мы ориентируемся на очень большой диапазон мобильных устройств, то обычно записываем их в искусственном окружении: в 5 раз замедленным CPU и соединением 3G. Это не только более реалистично, но и проблемы проявляются гораздо чётче. Перекос может усугубиться, если мы используем компонент профилирования React v15.4.0. Реальные значения производительности в таймлайнах на десктопе будут гораздо выше, чем в наших иллюстрациях.
Оптимизация для браузера
Разделяйте код на основе маршрутов
Webpack мощен, но труден в изучении. Некоторое время у нас были проблемы с CommonsChunkPlugin и тем, как он работал с некоторыми из наших кольцевых зависимостей кода. Из-за этого мы закончили только с тремя файлами ресурсов JavaScript общим размером более 1 МБ (420 КБ gzip при передаче).
Загрузка одного или даже нескольких очень больших файлов JavaScript, необходимых для работы, — огромное узкое место для мобильных пользователей, не позволяющее быстро увидеть и начать взаимодействие с сайтом. С размером скриптов увеличивается не только время их передачи по сети, но и время парсинга в браузере.
После длительных споров, мы наконец-то смогли разделить CommonsChunk на отдельные куски на основе маршрутов (пример ниже). Этот день наступил, когда разбор кода упал в наши почтовые ящики:
const plugins = [
// extract vendor and webpack's module manifest
new webpack.optimize.CommonsChunkPlugin({
names: [ 'vendor', 'manifest' ],
minChunks: Infinity
}),
// extract common modules from all the chunks (requires no 'name' property)
new webpack.optimize.CommonsChunkPlugin({
async: true,
children: true,
minChunks: 4
})
];
Используйте гранулированное разделение кода на основе маршрутов. Более быстрый первоначальный и HomeTimeline рендеринг достигаются ценой увеличения общего размера приложения, которое разбивается на 40 с лишним кусочков и амортизируется на протяжении всей сессии. — Николас Галлахер
Таймлайны до (сверху) и после (снизу) разделения кода
Наша изначальная конфигурация требовала более 5 секунд для загрузки основного пакета, а после разбиения на куски на основе маршрутов время загрузки едва достигает 3 секунд (в симуляции сети 3G).
Такое изменение мы сделали в самом начале работ по оптимизации производительности, но одно-единственное изменение сразу привело к кардинальному улучшению результатов в инструменте для аудита веб-приложений Google Lighthouse:
Результаты проверки в Lighthouse сайта до (слева) и после (справа) оптимизации
Избегайте функций, которые создают подтормаживания
В течение многих итераций таймлайнов бесконечной прокрутки мы использовали разные способы для вычисления положения и направления прокрутки — это нужно, чтобы принять решение, запрашивать ли API для загрузки и отображения дополнительных твитов. До недавнего времени мы использовали react-waypoint, который вполне нас устраивал. Однако он просто недостаточно быстр для достижения максимальной производительности одного из основных компонентов.
Точки (waypoints) вычисляют много разных показателей по высоте, ширине и расположению элементов, чтобы определить текущую позицию прокрутки, как далеко вы находитесь от каждого края, в каком направлении осуществляете прокрутку. Вся эта информация полезна, но она вычисляется на каждое событие прокрутки и поэтому дорого обходится: эти вычисления вызывают подтормаживания (jank) — и много.
Но сначала нужно понять, что инструменты разработки имеют в виду, когда информируют нас о подтормаживаниях.
Большинство современных устройств обновляют экран 60 раз в секунду. Если встречается анимация или эффект перехода или пользователь прокручивает страницу, то браузеру нужно подстроиться под скорость обновления и выдавать одну новую картинку, или фрейм, на каждое из этих обновлений экрана.
У каждого из этих фреймов бюджет чуть более 16 мс (1 секунда / 60 = 16,66 мс). Однако в реальности браузеру нужно выполнить вспомогательные задачи, так что вся ваша работа должна уложиться в 10 мс. Если вы не укладываетесь в бюджет, то частота выдачи кадров снижается, и контент двигается рывками на экране. Это часто называют подтормаживаниями (jank), и оно отрицательно влияет на удобство работы для пользователя. — Пол Льюис в «Производительности рендеринга»
Со временем мы разработали новый компонент для бесконечной прокрутки под названием VirtualScroller. С этим новым компонентом мы точно знали, какой фрагмент ленты твитов рендерится в таймлайне в каждый момент времени, так что исчезла необходимость производить ресурсоёмкие вычисления визуального положения.
Это может показаться не очень значительным, но раньше во время прокрутки (сверху) возникали подтормаживания при рендеринге, потому что вычислялась высота различных элементов. Теперь (снизу) не возникает никаких подрагиваний контента или пауз, когда прокрутка осуществляется на высокой скорости
После отказа от вызовов, из-за которых возникали дополнительные подтормаживания, теперь прокрутка ленты твитов выглядит и чувствуется плавной и цельной, что даёт более богатое, почти нативное впечатление от приложения. Хотя всегда остаётся место для оптимизации, это изменение стало заметным улучшением в плавности прокрутки по лентам. Хорошее напоминание, что важна каждая мелочь, если речь идёт о производительности.
Используйте картинки поменьше
Оптимизацию трафика для Twitter Lite мы начали с совместной работы нескольких групп над новыми картинками меньшего размера, которые загружаются с наших CDN. Выяснилось, что с уменьшением изображений приложение осуществляет только минимальный рендеринг, который абсолютно необходим (как по размеру, так и по качеству), и мы не только сократили трафик, но также увеличили производительность в браузере, особенно во время прокрутки ленты твитов с большим количеством изображений.
Чтобы определить, какой эффект маленькие картинки оказывают на производительность, можно посмотреть на таймлайн Raster в Chrome Developer Tools. До оптимизации размера изображений декодирование одного изображения занимало 300 мс или больше, как показано в верхнем из двух таймлайнов внизу. Это отрезок времени между тем, когда изображение скачалось, и тем, когда оно отобразится на странице.
Если вы стремитесь к стандарту 60 кадров в секунду при прокрутке страницы, то нужно как можно больше обработки втиснуть в 16,667 мс (1 фрейм). Получается, что рендеринг одной картинки занимает 18 фреймов, что слишком много. По поводу ленты следует заметить ещё следующее: как видно, таймлайн Main почти полностью заблокирована от продолжения работы, пока не закончится декодирование изображения (как показывают белые промежутки). Это значит, что у нас здесь бутылочное горлышко производительности!
Большие изображения (сверху) могут блокировать основной поток в течение 18 фреймов. Маленькие изображения (снизу) отнимают всего 1 фрейм
Теперь, когда мы уменьшили размер картинок, декодирование самых больших изображений требует всего чуть больше одного фрейма.
Оптимизация React
Используйте метод shouldComponentUpdate
Популярный совет для оптимизации производительности приложений на React — это использовать метод shouldComponentUpdate. Мы пытались сделать это где возможно, а иногда исправляли серьёзные ляпы.
Если поставить лайк первому твиту, то и он, и всё обсуждения внизу заново рендерились!
Пример компонента, который всегда обновляется. Если нажать на значок сердечка под твитом, чтобы поставить лайк в своей ленте, то любой компонент Conversation
на экране тоже отрисуется заново. В анимированном примере можно заметить зелёные прямоугольники, которые показывают, где браузер заново производит заливку цветом, потому что мы заставили заново обновиться весь компонент Conversation
снизу от твита.
Ниже показаны два flame-графика этого действия. Без shouldComponentUpdate
(сверху) всё дерево обновляется и заново отрисовывается просто чтобы изменить цвет сердечка где-то на экране. После добавления shouldComponentUpdate
(снизу) мы предотвратили обновление целого дерева и сэкономили целую 0,1 секунды ненужной обработки данных.
Раньше (вверху) при постановке лайка постороннему твиту всё обсуждение обновлялось и отрисовывалось заново. После добавления логики shouldComponentUpdate (внизу) компонент и дочерние процессы больше не тратят циклы CPU
Переносите ненужную работу в componentDidMount
Это изменение может показаться очевидным, но легко забыть о таких маленьких вещах, когда разрабатываешь крупное приложение вроде Twitter Lite.
Мы заметили, что во многих местах кода производятся ресурсоёмкие вычисления ради аналитики во время выполнения метода жизненного цикла в React, то есть componentWillMount. Каждый раз при этом ненадолго блокировалась отрисовка компонентов. 20 мс здесь, 90 мс там, всё быстро складывалось. Поначалу мы пытались записывать и передавать сервису аналитики в componentWillMount
информацию, какие твиты должны отрисоваться, ещё до начала реального рендеринга (верхний из двух скриншотов).
Перенеся несущественные части кода из `componentWillMount` в `componentDidMount`, мы сэкономили много времени для рендеринга твитов на экране
Перенеся эти вычисления и сетевые вызовы в метод componentDidMount
компонента React, мы разблокировали основной поток и уменьшили подтормаживания во время рендеринга компонентов (нижний таймлайн).
Избегайте dangerouslySetInnerHTML
В Twitter Lite мы используем пиктограммы SVG, поскольку это самый компактный и масштабируемый из доступных форматов. К сожалению, в старых версиях React большинство атрибутов SVG не поддерживались при создании элементов из компонентов. Поэтому, когда мы только начали писать приложение, то пришлось использовать dangerouslySetInnerHTML
, чтобы использовать пиктограммы SVG в качестве компонентов React.
Например, наша первоначальная пиктограмма сердечка HeartIcon выглядела примерно так:
const HeartIcon = (props) => React.createElement('svg', {
...props,
dangerouslySetInnerHTML: { __html: '<g><path d="M38.723 12c-7.187 0-11.16 7.306-11.723 8.131C26.437 19.306 22.504 12 15.277 12 8.791 12 3.533 18.163 3.533 24.647 3.533 39.964 21.891 55.907 27 56c5.109-.093 23.467-16.036 23.467-31.353C50.467 18.163 45.209 12 38.723 12z"></path></g>' },
viewBox: '0 0 54 72'
});
Использование dangerouslySetInnerHTML
не поощряется с точки зрения безопасности, и к тому же это замедляет процессы монтирования и рендеринга.
Раньше (вверху) требовалось примерно 20 мс для монтирования четырёх пиктограмм SVG, а сейчас (внизу) это занимает около 8 мс
Анализ flame-графиков показывает, что в первоначальной версии требовалось около 20 мс на медленном устройстве для монтирования четырёх пиктограмм SVG внизу каждого твита. Кажется немного, но такие пиктограммы монтируются в большом количестве во время прокрутки бесконечной ленты твитов — и мы поняли, что это гигантские потери времени.
Когда в React v15 добавили поддержку большинства атрибутов SVG, мы решили посмотреть, что будет, если отказаться от dangerouslySetInnerHTML
. Как видно на втором flame-графике (нижний из верхней пары графиков), мы экономим в среднем 60% каждый раз, когда нужно монтировать и отрисовать этот набор пиктограмм!
Сейчас наши пиктограммы SVG представляют собой простые компоненты без состояния. Они не используют «опасные» функции и монтируются в среднем на 60% быстрее. Выглядят примерно так:
const HeartIcon = (props = {}) => (
<svg {...props} viewBox='0 0 ${width} ${height}'>
<g><path d='M38.723 12c-7.187 0-11.16 7.306-11.723 8.131C26.437 19.306 22.504 12 15.277 12 8.791 12 3.533 18.163 3.533 24.647 3.533 39.964 21.891 55.907 27 56c5.109-.093 23.467-16.036 23.467-31.353C50.467 18.163 45.209 12 38.723 12z'></path></g>
</svg>
);
Откладывайте рендеринг во время монтирования и размонтирования большого количества компонентов
На более медленных устройствах мы заметили, что наша основная панель навигации не сразу появляется в ответ на нажатие, что часто ведёт к многократным нажатиям — пользователь думает, что первое нажатие не зафиксировалось.
Обратите внимание на анимации внизу, что пиктограмме Home требуется около двух секунд, чтобы обновиться и показать, что на неё нажали:
Без отложенного рендеринга панель навигации реагирует не сразу
Нет, здесь не задержка анимации GIF. Обновление действительно настолько медленное. Но ведь все данные для экрана Home уже загрузились, так почему они так долго не выводятся на экран?
Оказалось, что монтирование и размонтирование деревьев компонентов (вроде лент твитов) в React отнимает очень много ресурсов.
Мы хотели хотя бы устранить впечатление, что панель навигации не реагирует на нажатие пользователя. Для этого создали маленький компонент более высокого порядка:
import hoistStatics from 'hoist-non-react-statics';
import React from 'react';
/**
* Allows two animation frames to complete to allow other components to update
* and re-render before mounting and rendering an expensive `WrappedComponent`.
*/
export default function deferComponentRender(WrappedComponent) {
class DeferredRenderWrapper extends React.Component {
constructor(props, context) {
super(props, context);
this.state = { shouldRender: false };
}
componentDidMount() {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => this.setState({ shouldRender: true }));
});
}
render() {
return this.state.shouldRender ? <WrappedComponent {...this.props} /> : null;
}
}
return hoistStatics(DeferredRenderWrapper, WrappedComponent);
}
Наш компонент HigherOrderComponent, написанный Кэти Зиверт
После применения его на HomeTimeline отклик панели навигации стал почти мгновенным, что привело к кажущемуся общему ускорению.
const DeferredTimeline = deferComponentRender(HomeTimeline);
render(<DeferredTimeline />);
С отложенным рендерингом панель навигации реагирует мгновенно
Оптимизация Redux
Избегайте слишком частого сохранения состояния
Хотя вроде рекомендуется использовать контролируемые компоненты, но если контролировать ввод данных, то придётся заново рендериться на каждое нажатие клавиши.
На настольном компьютере с процессором 3 ГГц это не заметно, но у маленьких мобильных устройств с очень ограниченными ресурсами CPU появится существенная задержка между нажатиями, особенно при удалении большого количества символов из поля.
Чтобы сохранить удобство составления твитов, а также оставить счётчик количества символов, мы использовали контролируемый компонент, а также передавали текущее значение поля ввода в наше состояние Redux на каждое нажатие клавиши.
На верхнем из пары скриншотов — типичное устройство под Android 5, где каждое нажатие приводит к изменению, которое отнимает примерно 200 мс лишнего времени. Если человек действительно быстро набирает текст, мы получим очень плохое состояние, когда пользователь будет жаловаться, что курсор произвольно перемещается по форме, путая предложения.
Сравнение времени, которое нужно на обновление после каждого нажатия клавиши до передачи состояния в Redux и после оптимизации
Мы ускорили время выполнения на 50%, когда запретили состоянию черновика твита обновлять основное состояние Redux, а вместо этого сохраняя его локально в состоянии компонента React.
Группируйте действия в единые пакеты рассылки
В Twitter Lite мы используем redux и react-redux для назначения компонентам изменений состояний данных. Мы оптимизировали наши данные по разным областям большого хранилища с помощью Normalizr и combineReducers. Это всё прекрасно работало, предотвращая дупликацию данных и сохраняя малый размер хранилищ. Однако каждый раз при получении новых данных нам приходилось рассылать многочисленные действия, чтобы добавить их в соответствующие хранилища.
Учитывая механизм работы react-redux, это означало, что каждое действие после отправки приводило к повторному вычислению изменений и возможному повторному рендерингу связанных компонентоа (которые называются контейнерами).
Хотя мы используем специально разработанное промежуточное ПО, существуют другие доступные middleware для пакетной работы. Используйте подходящий или напишите собственный модуль.
Наилучший способ продемонстрировать преимущества пакетной обработки действий — это использовать расширение Chrome React Perf Extension. После предварительной загрузки мы выполняем упреждающее кэширование и вычисляем непрочтённые личные сообщения в фоне. В это время добавляем много различных объектов (беседы, пользователи, записи сообщений и др.). Без пакетной обработки (верхний из пары скриншотов) каждый компонент рендерится вдвое чаще (примерно 16 раз), чем с пакетной обработкой (примерно 8 раз).
Сравнение работы расширения React Perf для Chrome без пакетной обработки в Redux (вверху) и с пакетами (снизу)
Сервис-воркеры
Хотя сервис-воркеры пока доступны не во всех браузерах, они являются бесценной частью Twitter Lite. По возможности мы используем их для пуш-уведомлений, предварительного кэширования ресурсов и другого. К сожалению, для достаточно новой технологии возникает много вопросов с производительностью.
Предварительное кэширование ресурсов
Как и большинство продуктов, Twitter Lite далёк от совершенства. Мы всё ещё активно разрабатываем его, добавляем функции, исправляем баги, делаем его быстрее. Это значит, что часто требуется выложить новые версии наших ресурсов JavaScript.
Может возникнуть неприятная ситуация, если пользователь открывает приложение — а ему нужно заново скачать кучу файлов скриптов, чтобы просто посмотреть один твит.
В браузерах с поддержкой сервис-воркеров у нас есть полезная возможность провести автоматическое обновление, скачивание и кэширования любых изменённых файлов в фоновом режиме. Это происходит само собой, до того как откроется приложение.
Что это означает для пользователя? Практически мгновенную последующую загрузку программы, даже если мы выкатили новую версию!
Время загрузки сетевых ресурсов без сервис-воркеров (вверху) и с сервис-воркерами (внизу)
На иллюстрации вверху без сервис-воркеров каждый ресурс для текущего окна просмотра должен скачаться из сети, когда открывается приложение. На хорошей сети 3G это занимает около 6 секунд. Но если ресурсы предварительно кэшированы сервис-воркерами (нижний скриншот), то на том же соединении 3G та же страница заканчивает загрузку за 1,5 секунды. Ускорение на 75%!
Задерживайте регистрацию сервис-воркера
Во многих приложениях безопаснее зарегистрировать сервис-воркер немедленно при загрузке страницы:
<script>
window.navigator.serviceWorker.register('/sw.js');
</script>
Хотя мы пытаемся отправить браузеру как можно больше данных для рендеринга готовой страницы, в Twitter Lite это не всегда возможно. Бывает, что мы отправили недостаточно данных или эта страница не поддерживает предварительный приём данных с сервера. Из-за этих и разных других ограничений нам приходится делать некоторые запросы API немедленно после первоначальной загрузки страницы.
Обычно это не проблема. Но если браузер ещё не установил текущую версию нашего сервис-воркера, то нужно сообщить ему о необходимости установки — и это ведёт примерно к 50 запросам с предварительным кэшированием различных ресурсов JS, CSS и изображений.
Когда мы использовали простой подход с немедленной регистрацией нашего сервис-воркера, мы наблюдали сетевой затор в браузере, с достижением максимального лимита на количество разрешённых параллельных запросов (верхний из пары скриншотов).
Обратите внимание, что при немедленной регистрации сервис-воркера он может блокировать все остальные сетевые запросы (вверху). Если отложить его (внизу), то страница может загрузиться и сделать необходимые сетевые запросы, не подвергнувшись блокировке из-за лимита на количество одновременных соединений в браузере
Мы задержали регистрацию сервис-воркера до окончания выполнения дополнительных запросов API, загрузки ресурсов CSS и изображений. Это позволило закончить отрисовку страницы и уменьшить время отклика, как показано на нижнем скриншоте.
В целом, здесь перечислены всего несколько из множества улучшений, которые мы со временем сделали в Twitter Lite. Определённо, будут и другие улучшения, а мы надеемся, что продолжим рассказывать о найденных проблемах и способах их решения. Для получения информации в реальном времени и новых инсайтов о React и PWA подписывайтесь на меня и группу разработки в твиттере.
Автор: m1rko