Преждевременная оптимизация - корень всех зол.

Перед тем, как рассуждать о производительности, я настоятельно рекомендую прочитать Optimization.guide

Для начала вспомним, что Virtual Dom — это уже оптимизация, которая позволяет Реакту из коробки работать достаточно быстро, чтобы мы могли вообще не задумываться о производительности долгое время. Многим проектам этого хватает за глаза на протяжении всей жизни.

Напомню, что Реакт работает так:

  1. Маунт вызывает рендеринг приложения.
  2. Получившийся DOM вставляется в реальный DOM целиком, так как там еще ничего нет. А виртуальный DOM, в свою очередь, сохраняется внутри Реакта для последующего обновления.
  3. Изменение состояния приводит к вычислению нового виртуального DOM.
  4. Вычисляется разница между старым виртуальным DOM и новым.
  5. Разница применяется к реальному DOM.

React: Reconciliation

Reconciliation

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

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

Для решения этой проблемы Реакт настоятельно просит для всех элементов списков использовать аттрибут key, который не меняется для конкретного элемента списка. Подобное требование позволяет оптимизировать работу алгоритма уменьшив сложность до О(n).

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

Rendering

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

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

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

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

Напомню, что обновление компонентов запускает следующую цепочку функций:

  1. componentWillReceiveProps(nextProps)
  2. shouldComponentUpdate(nextProps, nextState)
  3. componentWillUpdate(nextProps, nextState)
  4. render()
  5. componentDidUpdate(prevProps, prevState)

Остановить перерисовку можно благодаря наличию коллбек-функции shouldComponentUpdate(). Если эта функция вернет false, то компонент не будет рендерится вообще. А так как мы подразумеваем, что компонент ведет себя как чистая функция, то достаточно внутри этой функции проверить, что не изменился props и state. Выглядит это примерно так:

shouldComponentUpdate(nextProps, nextState) {
  return !shallowEqual(this.props, nextProps)
    || !shallowEqual(this.state, nextState);
}

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

Поскольку большинство компонентов в типичных приложениях действительно ведут себя как чистые функции, а состояние хранится в общем корневом компоненте, подобную технику можно применять повсеместно, и Реакт нам в этом активно помогает. До сих пор в классах мы наследовались только от React.Component, но можно наследоваться и от React.PureComponent, в котором, за нас, правильно реализовали shouldComponentUpdate.

See the Pen js_react_performance_pure_component by Hexlet (@hexlet) on CodePen.

Если нажимать кнопку, то видно, что корневой компонент перерендеривается, а вложенный нет.

Но не все так просто. Очень легко незаметно для самого себя сломать работу PureComponent.

Default Props

Первая засада ожидает нас при неправильной работе со свойствами по умолчанию:

class Table extends React.Component {
  render() {
    return (
      <div>
        {this.props.items.map(i =>
          <Cell data={i} options={this.props.options || []} />
         )}
       </div>
     );
  }
}

Казалось бы, безобидный код, но вызов [] каждый раз генерирует новый объект. Проверяется это легко: [] === [] ложно. То есть данные не поменялись, но <Cell> будет отрисован заново.

Вывод: используйте встроенный механизм для свойств по умолчанию.

Callbacks

class App extends React.PureComponent {
  render() {
    return <MyInput
      onChange={e => this.props.update(e.target.value)} />;
  }
}

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

Immutable.js

Еще один интересный способ решить проблему перерендеринга приложения - использовать персистентные структуры данных, а конкретно библиотеку immutable.js. Это отдельная тема, рассмотрение которой находится за рамками текущего курса.


Дополнительные материалы

  1. Продуманная оптимизация