Оптимизация производительности функциональных компонентов React

Разработчики React делают все, чтобы React работал практически молниеносно, Virtual DOM работает на столько быстро, на сколько это возможно. Но случается, что даже небольшие приложения по прежнему работают очень медленно. Все же React не делает чудес и все также многое зависит от разработчика приложения. От него требуется понимание работы React и принципы эффективного использования в том или ином случае.

В React существует уже множество инструментов для оптимизации, такие как React.lazy, PureComponent и т.д. Мы рассмотрим только моменты связанные с оптимизацией применимой функциональным компонентам.

Базовые подходы оптимизации в React

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

При оптимизации классовых компонентов в основном используетя PureComponent либо метод жизненного циклаshouldComponentUpdate. В функциональных компонентах нет ни периода объявления, ни класса, так как же оптимизировать производительность?

Сокращение количества ререндеров

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

По каким причинам происходят повторные рендеринги:

  1. Изменилось состояние компонента
  2. Изменились props компонента

В React, после повторного рендеринга родителя также происходит рендеринг дочерних компонентов, что сильно влияет на производительность.

useEffect

Как известно useEffect включает в себя аналоги классических хуков componentDidMount, componentDidUpdate и componentWillUnmount. Нас интересуют только первые два, которые влияют на количество рендеров.

Для того, чтобы небыло лишних отрисовок нужно понимать в каком случае срабатывает componentDidMount и в каком и как - componentDidUpdate. Стоит проконтролировать зависимости useEffect, которые находятся во втором параметре, если есть таковые.

В случае если параметр отсутствует, useEffect будет срабатывать при каждом рендеринге. Бывают случаи, когда этот параметр просто забывается. Мы таже можем указать конкретную зависимость и будет срабатывать только после изменения ее значения. Все это - аналогия с componentDidUpdate.

export const ExampleComponent = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log(count);
  })
  
  const handleButtonClick = () => setCount(count++);
  
  return (<><button onClick={handleButtonClick}>Кнопка</button><>);
}

В этом случае useEffect будет реагировать на каждое изменение count.

Если же на место второго параметра выставить пустой массив, то будет считаться, что у useEffect нет зависимостей и он выполнится разово. Это аналог componentDidMount.

export const ExampleComponent = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setCount(count++);
  }, [])
  
  return (<><span>count: {count}</span></>);
}

// count: 1

useCallback

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

useCallbak похож на useMemo, но похожесть обманчива. useCallback ближе к useRef, чем к мемоизации.useCallback возвращает мемоизированный колбек без его вызова, useMemo - мемоизированное значение после вызова функции.

‍Возьмем такой пример:

export const FirstComponent = () => {  
  const [count, setCount] = useState(false);  
  return (  
    [...]  
    <SecondComponent onClick={() => { showData(count); }/>  
    [...]  
  );  
}  

Этот компонент будет вызывать повторную визуализацию SecondComponent элемента каждый раз, когда это делает FirstComponent, даже если SecondComponent элемент является PureComponent или завернут в React.memo, потому что при каждом рендеринге onClick будет отличаться. useCallback может справиться с этой ситуацией так:

export const FirstComponent = () => {  
  const [count, setCount] = useState(false);  
  const handleClick = useCallback(  
    () => {  
    showData(count);  
  },  
  [count],  
);  
  return (  
    [...]  
    <SecondComponent onClick={() => {handleClick}/>  
    [...]  
  );  
}  

Теперь handleClick будет иметь то же значение, пока count не изменится, что позволит сократить количество раз, когда SecondComponent рендерится.

Мемоизация

React.memo()

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

Когда компонент обернут в React.memo(), React выводит компонент и запоминает результат. Перед следующим рендерингом производится неглубокое сравнение, если новый props тот же самый, React повторно использует запомненный результат, пропуская следующий рендеринг.

Давайте посмотрим, что за воспоминания в действии. Функциональный компонент Movie обернут в React.memo():

export const ExampleComponent = ({ count }) => {
  return (
    <>
      <span>Счетчик: {count}</span>
    </>
  );
}

export const MemoizedExampleComponent = React.memo(ExampleComponent);

Вы получаете прирост производительности: повторно используя записанный контент, React пропускает рендеринг компонента и не выполняет виртуальную проверку разницы DOM.

По факту, React.memo это полный аналог PureComponent или shouldComponentUpdate, только в функциональном компоненте.

При использовании React.memo React производит shallow-сравнение. Можно изменить стандартное поведение и задать вторым параметром функцию comparer

export const exampleComponentPropsAreEqual(prev, next) {
  return prev.count === next.count;
}

const MemoizedExampleComponent = React.memo(ExampleComponent, exampleComponentPropsAreEqual);

useMemo

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

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

В случае изменения одной зависимостей функция будет пересчитана.

const exampleList = useMemo(  
  () => items.map(  
    item => ({  
      ...item,  
      date: convertToLocalDateFunc(item.date)
    }),  
  ),  
  [items],  
);  

Дополнение

Не использовать анонимные фунцкции и литералы объектов в шаблонах

Обеъкты не имеют постоянного места хранения, но отнимают производительность, так как при каждой перерисовке приходится выделять новое место в памяти.


// так не делать
export const FirstExample = () => {
    return (
      <button onClick={() => console.log('Так не делать')}>
        Кнопка
      </button>
    );
}

// так уже лучше
export const SecondExample = () => {
  handleClick = () => {
    console.log('Так уже лучше');
  }
  
  return (
      <button onClick={this.handleClick}>
        Кнопка
      </button>
    );
}

Тот же принцип и для анонимных функций, JavaScript так же выделяет новую памятьдля при повторном вызове компонента, вместо выделения одной ячейки памяти при использовани “именованных” функций. Так же, в этом случае, для оптимизации, следует использовать useCallback.

Избегать лишнего перемонтирования элементов в DOM

Как пример, отображение какого-либо элемента по определенному условию.

export const Dropdown = () => {
  const [isOpen, setIsOpen] = useState(false);
  
  const toggleDropdown = () => setIsOpen(!isOpen);
  
  return (    
    <a onClick={this.toggleDropdown}>
      Показать 
      {isOpen ? <AnyViewComponent> : null}
    </a>)
}

Поскольку компонент удален из DOM это может вызвать браузером перерисовку. Этот момент может быть особенно дорогостоющим, особенно если это приводит к сдвигу элементов HTML.

Для того, чтобы смягчить последствия, рекомендуется избегать полного демонтажа компонентов. Вместо этого можно использовать средства CSS, такие как display: none, visibilty: hidden или opacity: 0.

Заключение

Теперь есть все, чтобы оптимизировать жадных на производительность компонентов. Можно использовать memo,useMemo, useCallback, чтобы избежать дорогостоящих ререндеров. Но все эти инструменты включают стоимость своих собственных вычислений. К примеру, memo берет некоторые ресурсы для сравнения полей, и для хука тоже нужны дополнительные вычисления для сравнения на каждом ререндере. Инструменты оптимизации нужно использовать с умом и при необходимости, в противном случае можно добавить дополнительную задержку при выполнении. Так что излишней озабоченностью оптимизацией можно навредить. Лучше первый вариант функционала строить без оптимизации и добавлять ее следующей итерацией, при необходимости.

Были рассмотрены только основные подходы оптимизации применимые для функциональных или stateless-комопнентов. Помимо инструментов оптимизации React не стоит забывать про общие, не менее эффективные в том или ином случае, принципы оптимизации, такие как Lazy Loading, SSR, ServiceWorker, webpack оптимизации и многое другое, что выходит за границы темы данной статьи.

Подписка на новости блога: