Оптимизация производительности React на странице сведений о листинге Airbnb

React.js

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

Эта статья была впервые опубликована 5 декабря 2017 г. (Оригинальная ссылка), который в основном представляет процесс оптимизации производительности одной из самых посещаемых страниц на веб-странице Airbnb, страницу сведений о листинге, а также методы, инструменты и опыт, используемые в нем.
автор:Joe Lencioni, инженер веб-инфраструктуры Airbnb
Переводчик: Иван Чжун, инженер-стажер Airbnb в Китае
Вычитка: Лоуренс Лин, инженер полного стека Airbnb China

Мы используемReact Routerа такжеHypernovaРазрабатывайте одностраничные приложения, поддерживающие рендеринг на стороне сервера. Первый сценарий примененияairbnb.comосновной процесс бронирования. В начале этого года (примечание: эта статья изначально была написана в 2017 году) мы завершили миграцию главной страницы и страницы результатов поиска с отличными результатами. Следующий план — добавить страницу сведений о листинге в одностраничное приложение.

Страница сведений о объявлении на airbnb.com: https://www.airbnb.com/rooms/8357

Это страница сведений о нашем листинге. В процессе поиска пользователи могут посещать эту страницу несколько раз, просматривая разные списки. эта страницаairbnb.comОдна из самых посещаемых и важных страниц в Интернете, поэтому мы хотели выяснить все детали, влияющие на производительность!

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

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

метод

Анализ страницы был записан с помощью инструментов производительности Chrome:

  1. Откройте окно в режиме инкогнито (чтобы плагины моего браузера не мешали анализу)
  2. Посетите страницу, которую вы хотите проанализировать в вашей локальной среде разработки, и используйте ?react_perf в поле запроса (чтобы включить аннотации времени пользователя React), отключив при этом некоторые функции, предназначенные только для разработчиков, которые замедляют работу страницы, напримерaxe-core)
  3. Нажмите кнопку записи ⚫️
  4. взаимодействовать со страницей (например, прокручивать, щелкать, печатать)
  5. Нажмите кнопку записи еще раз 🔴 и проанализируйте результат

Как правило, я выступаю за профилирование на мобильном оборудовании, таком как Moto C Plus, или установку ограничения ЦП на 6-кратное замедление, чтобы понять работу на более медленных устройствах. Однако, поскольку проблемы с производительностью на этой странице достаточно серьезны, на моем очень высококлассном ноутбуке проблемы с производительностью отчетливо видны даже без настройки дроссельной заслонки.

первоначальный рендер

Когда я начал оптимизировать эту страницу, я заметил предупреждение в консоли: 💀

webpack-internal:///36:36 Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server:
(client) ut-placeholder-label screen-reader-only"
(server) ut-placeholder-label" data-reactid="628"

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

К сожалению, сообщение об ошибке не дает четкого представления о том, где именно возникла проблема или что может быть ее причиной, но оно дает нам некоторые подсказки. 🔎Я заметил текст, похожий на классы CSS, поэтому набрал в терминале:

~/airbnb ❯❯❯ ag ut-placeholder-label
app/assets/javascripts/components/o2/PlaceholderLabel.jsx
85:        'input-placeholder-label': true,

app/assets/stylesheets/p1/search/_SearchForm.scss
77:    .input-placeholder-label {
321:.input-placeholder-label,

spec/javascripts/components/o2/PlaceholderLabel_spec.jsx
25:    const placeholderContainer = wrapper.find('.input-placeholder-label');

Он быстро сузил поиск до o2/PlaceHolderLabel.jsx, компонента поиска в верхней части раздела комментариев. 🔍

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

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

Снова запустив профилировщик, вы увидите, что обновляется сразу после первоначального монтирования.

Redux перерисовывает соединение SummaryContainer, потребляя 101,63 мс

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

export default class SummaryIconRow extends React.Component {
 ...
}

Измените его на:

export default class SummaryIconRow extends React.PureComponent {
 ...
}

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

Повторный рендеринг BookIt занял 103,15 мс

Интересно, что эти компоненты невидимы, если клиенту не требуется ввод данных 👻.

Решение этой проблемы состоит в том, чтобы не выполнять рендеринг до тех пор, пока компонент не будет использован. Это увеличивает скорость первоначального рендеринга и повторного рендеринга. 🐎 Если мы копнем немного глубже и используем больше PureComponents, мы сможем ускорить рендеринг.

Повторная рендеринг Бронит занял 8,52 мс

прокручивать вверх и вниз

Выполняя некоторую работу по оптимизации плавной анимации прокрутки, я заметил, что страница при прокрутке была очень прерывистой. 📜 Когда анимация не достигает 60 fps (кадров в секунду), дажеПользователи могут чувствовать заикание, пока не будет достигнута скорость 120 кадров в секунду..Прокрутка — это особый вид анимации, который напрямую связан с движением пальцев, поэтому он более чувствителен, чем другие анимации, когда производительность низкая.

Немного проанализировав, я обнаружил, что мы делаем много ненужного повторного рендеринга компонентов React в обработчиках событий прокрутки! Это очень плохо:

Производительность прокрутки страницы сведений о листинге Airbnb очень низкая, пока не будут внесены какие-либо исправления.

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

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

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

Повторный рендеринг StickyNavigationController за 58,80 мс

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

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

const anchors = React.Children.map(children, (child, index) => {     
  return React.cloneElement(child, {
    selected: activeAnchorIndex === index,
    onPress(event) { onAnchorPress(index, event); },
  });
});

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

const anchors = React.Children.map(children, (child, index) => {     
  return React.cloneElement(child, {
    selected: activeAnchorIndex === index,
    index,
    onPress: this.handlePress,
  });
});

В :

class NavigationAnchor extends React.Component {
  constructor(props) {
    super(props);
    this.handlePress = this.handlePress.bind(this);
  }

  handlePress(event) {
    this.props.onPress(this.props.index, event);
  }

  render() {
    ...
  }
}

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

Повторный рендеринг StickyNavigationController за 32,85 мс

FlexportизDounan ShiисследовалReflective Bind, который использует Плагин Babel для выполнения этого типа оптимизации. Этот проект все еще находится в зачаточном состоянии и еще не готов к официальному релизу, но я с нетерпением жду его будущего.

Глядя на главную панель инструментов производительности, я заметил, что у нас есть очень подозрительный блок _handleScroll, который занимает 19 мс на событие прокрутки. Если мы хотим достичь 60 кадров в секунду, у нас может быть только 16 мс времени рендеринга, что, очевидно, слишком много. 🌯

_handleScroll занимает 18,45 мс

Виновник, похоже, находится внутри onLeaveWithTracking. Поискав код, я проследил его до . Присмотревшись к стеку вызовов, я заметил, что большая часть времени была проведена в setState React, но, как ни странно, мы фактически не видели никаких повторных рендеров. Эм...

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

this.state = { inViewport: false };

но,Мы никогда не используем inViewport в пути рендеринга, и нам никогда не нужно запускать повторный рендеринг при изменении inViewport, что означает, что мы платим ненужные накладные расходы на производительность.. 💸 Преобразование всех похожих вариантов использования состояния React в простые переменные экземпляра помогает ускорить анимацию прокрутки.

this.inViewport = false;
Обработчик событий прокрутки занимает 1,16 мс

Я также заметил, что повторный рендеринг вызвал дорогостоящий и ненужный повторный рендеринг компонента .

Повторный рендеринг в AboutThisListingContainer занял 32,24 мс.

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

render() {
  ...
  const finalExperiments = {
    ...experiments,
    ...this.state.experiments,
  };
  return (
    <WrappedComponent
      {...otherProps}
      experiments={finalExperiments}
    />
  );
}

я представилreselectИсправлена ​​проблема, из-за которой он кэшировал последний результат, сохраняя равенство ссылок между последовательными рендерингами.

const getExperiments = createSelector(
  ({ experimentsFromProps }) => experimentsFromProps,
  ({ experimentsFromState }) => experimentsFromState,
  (experimentsFromProps, experimentsFromState) => ({
    ...experimentsFromProps,
    ...experimentsFromState,
  }),
);
...
render() {
  ...
  const finalExperiments = getExperiments({
    experimentsFromProps: experiments,
    experimentsFromState: this.state.experiments,
  });
  return (
    <WrappedComponent
      {...otherProps}
      experiments={finalExperiments}
    />
  );
}

Вторая часть вопроса аналогична. Мы использовали функцию getFilteredAmenities, которая принимает массив в качестве первого аргумента и возвращает отфильтрованную версию этого массива, например:

function getFilteredAmenities(amenities) {
 return amenities.filter(shouldDisplayAmenity);
}

Хотя это выглядит нормально, каждый раз, когда он запускается, даже если результат один и тот же, создается новый экземпляр массива, и любые чистые компоненты, которые принимают массив в качестве параметра, не могут быть оптимизированы. Я решил эту проблему, введя повторный выбор для кэширования этого фильтра. На данный момент графика пламени больше нет, потому что весь повторный рендеринг полностью исчез! 👻

В дополнение к этому могут быть дополнительные возможности оптимизации (например,CSS containment), но производительность прокрутки значительно улучшилась!

Исправлена ​​производительность прокрутки страницы с объявлениями Airbnb.

нажмите действие

Продолжая больше взаимодействовать со страницей, я заметно ощутил задержку нажатия кнопки «Полезно» в комментариях ✈️.

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

Повторный рендеринг ReviewsContent за 42,38 мс

После использования React.PureComponent в нескольких местах обновления страниц стали более эффективными.

Повторный рендеринг контента отзывов за 12,38 мс

действие ввода

Возвращаясь к старой проблеме несоответствия сервер/клиент, я заметил вялую реакцию при вводе в поле ввода.

После анализа было обнаружено, что каждое нажатие клавиши приводит к повторному отображению всего заголовка области комментариев и каждого комментария! 😱 Это издеваешься?

Повторный рендеринг ReviewsContainer, подключенного к Redux, за 61,32 мс

Чтобы исправить это, я извлек часть заголовка раздела комментариев как компонент, чтобы использовать его как React.PureComponent, а затем разбросал эти React.PureComponent по дереву. Это заставляет каждое нажатие клавиши повторно отображать только компонент, который необходимо повторно отобразить: поле ввода.

Повторный рендеринг ReviewsHeader за 3,18 мс

Чему мы научились?

  1. Мы хотим, чтобы страницы запускались быстро и оставались плавными.
  2. Это означает, что нам нужно сосредоточиться не только на времени до интерактива, когда пользователь инициирует запрос к странице, но и на анализе интерактивных действий на странице, таких как прокрутка, нажатие и ввод текста.
  3. React.PureComponent и reselect — два очень полезных инструмента в процессе оптимизации приложений React.
  4. Следует избегать состояния реакции, когда переменные экземпляра — это именно то, что вам нужно.
  5. Хотя React является мощным, также легко написать код, который влияет на производительность.
  6. Выработайте привычку анализировать, оптимизировать и снова анализировать.