Оптимизации в React часть 1

Нужна ли нам вообще оптимизация?

Поскольку большинство людей покидают сайт в течение первых 5 секунд, нам следует убедиться, что мы находимся в хорошем месте, мы можем использовать Lighthouse для запуска отчета о производительности на вкладке inspect.

Посмотрев на наш сайт Workiz.com.
мы заметили, что нам есть что улучшать, поэтому мы решили переделать некоторые вещи и оптимизировать некоторые другие.

Рендеринг

Давайте начнем с самого начала: когда React-компонент перерисовывается?

  1. При изменении реквизитов или состояния
  2. Когда рендерится родительский компонент
  3. Когда изменяется хук

Давайте посмотрим на следующий компонент:

const Counter = () => {
    const initialCount = 
parseInt(window.localStorage.getItem("count") ?? "0");
    const [count, setCount] = useState(initialCount);
    const increment = () => {
        window.localStorage.setItem('count', count + 1);
        setCount(count + 1);
    }
    return (
      <>
        Count: {count}
        <button onClick={increment}>+</button>
      </>
    );
  }
Вход в полноэкранный режим Выход из полноэкранного режима

У нас есть компонент, который имеет некоторое начальное состояние initialCount, которое он получает из localStorage, и функцию «increment», которая увеличивает счет на 1, а затем сохраняет этот счет в localStorage.

Для удобства чтения я переименую некоторые функции.

const getCountFromLS = () => parseInt(window.localStorage.getItem("count") ?? "0");
const setCountToLS = (count) =>
window.localStorage.setItem('count', count);
Вход в полноэкранный режим Выход из полноэкранного режима
const initialCount = getCountFromLS();
const [count, setCount] = useState(initialCount);
Вход в полноэкранный режим Выход из полноэкранного режима

Каждый раз, когда мы «инкрементируем», мы замечаем, что initialCount снова считывается из localStorage, хотя мы даже не используем его после первого рендера.

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

const initialCount = () => getCountFromLS();
const [count, setCount] = useState(()=>initialCount());
Вход в полноэкранный режим Выход из полноэкранного режима

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

const initialCount = () => getCountFromLS();
const [count, setCount] = useState(initialCount);
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь каждый раз, когда наш компонент рендерится, это не влияет на initialCount, так как теперь она вызывается только один раз во время первой инициализации компонента и больше никогда…

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

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

const expensiveInputOperation = getCountFromLS();
const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
...
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы выполняем дорогостоящую операцию вне жизненного цикла нашего компонента и просто передаем значение в useState.
Поскольку «expensiveInputOperation» является константой, нет необходимости использовать функцию обратного вызова.

Теперь давайте представим новый компонент под названием CoolButton.
CoolButton — это просто очень простая кнопка, которая выполняет некоторые действительно важные вычисления каждый раз, когда мы нажимаем на нее.

const CoolButton = ({ clickHandler }) => {
    const handler = () => {
        ReallyImportantCalculation();
        clickHandler();
    };
    return <button onClick={handler}></button>;
  };
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте заменим кнопку в нашем счетчике на нашу новую CoolButton:

const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
    const increment = () => {
        setCountToLS(count + 1);
        setCount(count + 1);
    }
    return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
    );
  }
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь у нас есть счетчик, внутри которого находится кнопка CoolButton.
Когда мы нажимаем на кнопку, мы фактически отображаем и счетчик, и CoolButton, хотя в CoolButton ничего не изменилось.

Как мы можем предотвратить это?

React.memo

К счастью для нас, React дает нам возможность противостоять рендерингу родителя, позволяя дочернему объекту рендерить в своем собственном темпе и не полагаться на рендеринг родителя.
Это то же самое, что использовать React.PureComponent вместо обычного React.Component.

const CoolButton = React.memo(({ clickHandler }) => {
    const handler = () => {
        ReallyImportantCalculation();
        clickHandler();
    };
    return <button onClick={handler}></button>;
  });
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь мы нажимаем на кнопку, и все работает правильно, но мы все еще продолжаем повторно отображать CoolButton…

Разве мем не должен был остановить повторные рендеринги?

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

{'test':true} == {'test':true} // FALSE
Вход в полноэкранный режим Выход из полноэкранного режима

Javascript проверяет, одинаковы ли ссылки, а не то, что внутри них находятся одинаковые значения.
Возвращаясь к нашему компоненту, что произошло, что вызвало повторный рендеринг?

Давайте снова посмотрим на родительский компонент:

const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
    const increment = () => {
        setCountToLS(count + 1);
        setCount(count + 1);
    }
    return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
    );
  }
Вход в полноэкранный режим Выход из полноэкранного режима

Каждый раз, когда мы нажимаем на кнопку, мы снова рендерим Counter.

При рендеринге счетчика все функции выполняются заново, что означает, что каждый раз мы получаем новую анонимную функцию под названием «increment».
Затем мы передаем этот новый «инкремент» нашему CoolButton в качестве реквизита, что означает, что «инкремент», полученный при рендеринге ранее, — это не тот же самый «инкремент», который мы имеем сейчас, поэтому вполне естественно, что мы снова рендерим нашу кнопку.

Что мы можем сделать?

React.useCallback

useCallback на помощь!
Этот хук react гарантирует, что мы получим ссылку на функцию, которая изменится только при изменении одной из зависимостей в квадратных скобках. Мы можем использовать это для мемоизации нашей функции «increment», чтобы при повторном рендеринге Counter мы получили тот же «increment» и передали его нашей CoolButton.

Попытка 1

const Counter = () => {
    const [count, setCount] = useState(expensiveInputOperation);
    const increment = useCallback(() => {
        setCountToLS(count + 1);
        setCount(count + 1);
    },[])
    return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
    );
  }
Войти в полноэкранный режим Выйти из полноэкранного режима

Хорошо, теперь мы нажимаем на кнопку, но она не срабатывает больше одного раза, почему?
Потому что наша функция никогда не меняется, поэтому какое бы значение count она не получила в начале, это же значение будет у нее до тех пор, пока она не будет уничтожена, то есть оно всегда будет равно 0 🙁

Я полагаю, мы должны просто добавить наш count в массив зависимостей, верно?
Ну… да, мы можем это сделать, но тогда мы будем получать другой «инкремент» каждый раз, когда count меняется… что означает, что нам нужно будет перерисовать наш CoolButton… вернемся к началу.

Попытка 2

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

Это означает, что мы можем сделать что-то вроде этого:

 const increment = useCallback(() => {
        setCountToLS(count + 1);
        setCount(prevCount => prevCount + 1);
    },[])
Войти в полноэкранный режим Выйти из полноэкранного режима

Круто, теперь у нас есть функция обратного вызова setCount.

А что насчет localStorage?
Оно по-прежнему получает одно и то же значение каждый раз, как мы можем это исправить? Ну, это достаточно просто —
Давайте просто поместим этот вызов внутрь нашего обратного вызова setCount:

 const increment = useCallback(() => {
        setCount(prevCount => {
        setCountToLS(prevCount + 1);
        return prevCount + 1;
        })
    },[])
Вход в полноэкранный режим Выйти из полноэкранного режима

И теперь все работает правильно!

const CoolButton = React.memo(({ clickHandler }) => {
    const handler = () => {
        ReallyImportantCalculation();
        clickHandler();
    };
    return <button onClick={handler}></button>;
  });
const expensiveInputOperation = 
parseInt(window.localStorage.getItem("count") ?? "0");
const Counter = () => {
   const [count, setCount] = useState(expensiveInputOperation);
   const increment = useCallback(() => {
   setCount(prevCount => {
          window.localStorage.setItem("count", prevCount + 1);
          return prevCount + 1;
        });
    }, []);
   return (
      <>
        Count: {count}
        <CoolButton clickHandler={increment}>+</CoolButton>
      </>
      );
  }

Вход в полноэкранный режим Выход из полноэкранного режима

Если вы спрашиваете себя, почему мы не обернули нашу функцию «обработчик» в useCallback, то мы должны помнить, что мемоизация не бесплатна.
Все в программировании — это компромисс, вы получаете одно, но теряете другое. Для мемоизации нам нужно где-то хранить эти данные, чтобы использовать их позже.
Примитивные типы, такие как <button>, <input>, <div> и т.д. очень дешевы для рендеринга, поэтому нам не нужно сохранять их все.
Мы должны использовать эти техники только тогда, когда видим влияние на пользовательский опыт, по большей части React делает довольно хорошую работу даже с повторными рендерами.

Следующая часть будет посвящена UseMemo, следите за новостями!

Оставьте комментарий

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