Использование глобальной мемоизации в React

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

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

  1. Во-первых, мы должны понять, как именно работает useMemo — и почему.
  2. Каковы некоторые случаи использования, когда useMemo не слишком помогает?
  3. Затем мы рассмотрим четыре метода глобального кэширования, при которых кэш разделяется между компонентами. Как обычно, они имеют различные компромиссы, а некоторые даже опасны при неосторожном использовании.

В конце вас ждет аккуратная шпаргалка. Давайте погрузимся!

Внутри UseMemo

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

  1. Кэш инициализируется при монтировании экземпляра компонента и уничтожается при размонтировании.
  2. Кэш никогда не разделяется между различными экземплярами компонента.
  3. В кэше хранится только одно значение — последнее.

Это разумное значение по умолчанию. Хранение одного значения никогда не приводит к утечке памяти, даже если вы используете нестабильную зависимость. Скажем, наша памятка (а useCallback — это просто обертка над useMemo) зависит от нестабильной стрелки, onClick:

const onClick = (id) => console.log('click', id);
const handleClick = useCallback(() => {
  onClick(props.id);
}, [onClick, props.id]);
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы создаем новый handleClick на каждом рендере. Если бы useMemo хранил все предыдущие значения, то каждый handleClick занимал бы память вечно — плохо. Кроме того, хранение N значений требует N сравнений зависимостей при чтении, что в N раз медленнее, чем проверка один раз. Конечно, useMemo здесь бесполезен, но по крайней мере он не взрывается.

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

const [clicks, setClicks] = useState(0);
const handleClick = useCallback(() => { 
  setClicks(c => c + 1);
}, []);
Войти в полноэкранный режим Выйти из полноэкранного режима

Если бы кэш был разделен между несколькими компонентами, разные handleClick вызывали бы один и тот же setClicks, поэтому увеличивался бы только один счетчик — неожиданно!

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

Подводные камни useMemo

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

const RouteItem = () => { 
  const cities = useMemo(() => [{ 
    label: 'Moscow', value: 'MOW' 
  }, { 
    label: 'Saint Petersburg', value: 'LED' 
  }, // 1000 more cities], []); 
  return <select> 
    {cities.map(c => 
      <option value={c.value}>{c.label}</option>
    )} 
  </select>;
};
Войти в полноэкранный режим Выход из полноэкранного режима

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

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

const SchemePicker = (props) => { 
  const [isDark, setDark] = useState(false); 
  const colors = useMemo(() => ({ 
    background: isDark ? 'black' : 'white', 
    color: isDark ? 'white' : 'black', 
  }), [isDark]); 
  return <div style={colors} {...props}> 
    <button onChange={() => setDark(!isDark)}> 
      toggle theme 
    </button> 
    {props.children} 
  </div>;
};
Войти в полноэкранный режим Выход из полноэкранного режима

Здесь у нас только два возможных значения зависимости, true и false, поэтому нет риска утечки памяти. Тем не менее, при каждом изменении флажка мы вычисляем новую цветовую схему. Старая будет в самый раз, спасибо.

Итак, в некоторых случаях мы хотели бы:

  1. Делить кэш между различными экземплярами компонента.
  2. Запоминать несколько значений, а не только последнее.

Нет проблем, с мощью JS в нашем распоряжении мы можем это сделать.

Глобальная памятка

Если мы хотим повторно использовать значение между экземплярами компонента, то никакой хук нас не спасет, потому что и useState и useRef локальны для экземпляра компонента. Но мы можем извлечь кэш в область видимости модуля и работать оттуда:

// this is shared between all components
const cache = /* some cache */;
const Component = () => { 
  // cache is always the same object 
  const value = cache.get(deps);
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Предварительно вычисленная глобальная константа

Самый простой вид «кэша» не имеет зависимостей — это константа, которая используется в каждом компоненте. И самое простое решение — просто объявить эту константу сразу:

const cities = [
  { label: 'Moscow', value: 'MOW' }, 
  { label: 'Saint Petersburg', value: 'LED' }, 
  // 1000 more cities
];
// yay, every RouteItem refers to the same cities
const RouteItem = () => { 
  return <select> 
    {cities.map(c => 
      <option value={c.value}>{c.label}</option>
    )} 
  </select>;
};
Вход в полноэкранный режим Выйти из полноэкранного режима

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

const schemes = { 
  dark: { background: 'black', color: 'white' }, 
  light: { background: 'white', color: 'black' },
};
const SchemePicker = (props) => { 
  const [isDark, setDark] = useState(false); 
  // we only have 2 values, each one is stable 
  const colors = schemes[isDark ? 'dark' : 'light']; 
  return <div style={colors} {...props}> 
    <button onChange={() => setDark(!isDark)}> 
      toggle theme 
    </button> 
    {props.children} 
  </div>;
};
Вход в полноэкранный режим Выход из полноэкранного режима

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

Ленивая глобальная константа

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

let citiesCache;
// getCities intercepts accessing cities
const getCities = () => { 
  // use cached value if it exists 
  if (citiesCache) { 
    return citiesCache; 
  } 
  // otherwise put the array into the cache 
  citiesCache = [
    { label: 'Moscow', value: 'MOW' }, 
    { label: 'Saint Petersburg', value: 'LED' }, 
    // 1000 more cities
  ]; 
  return citiesCache;
};
const RouteItem = () => { 
  return <select> 
    {getCities().map(c => 
      <option value={c.value}>{c.label}</option>
    )}
  </select>;
};
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы откладываем создание значения до тех пор, пока оно нам действительно не понадобится. Отлично! И мы даже можем передать строителю некоторые данные из API, если они никогда не меняются. Забавный факт: хранение данных в менеджере состояний или кэше API на самом деле является примером этой техники.

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

Истинная памятка

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

const cities = [
  { label: 'Moscow', value: 'MOW' }, 
  { label: 'Saint Petersburg', value: 'LED' }, 
  // 1000 more cities
];
const filterCache = {};
const getCitiesExcept = (exclude) => { 
  // use cached value if it exists 
  if (filterCache[exclude]) { 
    return filterCache[exclude]; 
  } 
  // otherwise put the filtered array into the cache
  filterCache[exclude] = cities
    .filter(c => c.value !== exclude); 
  return filterCache[exclude];
};
const RouteItem = ({ value }) => { 
  return <select> 
    {getCitiesExcept(value) 
      .map(c => <option value={c.value}>{c.label}</option>)}
  </select>;
};
Войти в полноэкранный режим Выход из полноэкранного режима

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

Кэш LRU

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

Мы будем придерживаться самого простого метода — наименее часто используемого кэша, или кэша LRU. Мы запоминаем только N последних значений. Например, после передачи чисел 1, 2, 3, 1 в кэш LRU размера 2, мы сохраняем только значения для 3 и 1, а значение для 2 выбрасываем. Реализация не интересна, надеюсь, вы верите, что это выполнимо (подробнее см. flru). Стоит отметить, что оригинальный useMemo на самом деле является LRU-кэшем размера 1, потому что он хранит только одно последнее значение.

Хотя на бумаге это звучит хорошо, на самом деле глобальный ограниченный кэш работает не так хорошо для наших случаев использования. Чтобы понять почему, давайте рассмотрим кэш размером 1. Если у нас есть несколько экземпляров компонентов, работающих одновременно, они, скорее всего, имеют разные значения зависимостей. Если они отображаются в чередующемся порядке, каждый экземпляр столкнется со значением предыдущего, что является пропуском в кэш, и ему придется вычислять заново. Таким образом, в итоге мы перевычисляем при каждом рендере и делаем несколько бесполезных сравнений.

В более общем случае, кэш размером N, скорее всего, будет иметь промахи, когда N+1 компонентов с разными значениями будут живы, и станет бесполезным при 2N компонентах. Это не очень хорошее качество — кэш не должен заботиться о том, сколько потребителей существует. Мы могли бы поэкспериментировать с другими политиками замещения — скажем, с кэшем на основе частоты — но их гораздо сложнее реализовать, и мне кажется, что у приложений React нет таких моделей использования кэша, которые могли бы выиграть от них.

Однако есть один случай, когда это работает: если у вас есть N возможных значений зависимостей, и N невелико — скажем, true / false, или число 1…10, то кэш размером N полностью покрывает 100% попаданий в кэш, и вычисляет значения только при необходимости. Но если это так, простой глобальный кэш работает точно так же, без накладных расходов на отслеживание порядка использования.


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

Однако это делает useMemo непригодным для использования в некоторых случаях:

  1. Когда вы хотите повторно использовать значение между компонентами (например, всегда один и тот же большой объект).
  2. Когда ваша зависимость быстро чередует несколько значений (например, true / false / true и т.д.).

Затем мы рассмотрели 4 (4 с половиной? 5?) техники кэширования с глобально разделяемым кэшем, которые позволяют решить эти проблемы:

  1. Просто используйте константу модуля. Просто, надежно, но создает объект во время начального выполнения скрипта — неоптимально, если объект тяжелый и не нужен во время начального рендеринга.
  2. Precomputed map — простое расширение модульной константы, которая хранит несколько значений. Те же недостатки.
  3. Ленивая константа — задержка создания объекта до тех пор, пока он не понадобится, затем кэширование навсегда. Устраняет задержку инициализации модульной константы во время инициализации скрипта.
  4. Полная памятка — сохраняет все результаты вызовов функций со всеми аргументами. Утечка памяти при большом количестве возможных значений / комбинаций зависимостей. Хорош, когда есть несколько возможных входов. Используйте с осторожностью.
  5. Ограниченный кэш (например, LRU). Устраняет проблему утечки памяти, но бесполезен, если количество живых компонентов с различными зависимостями больше, чем размер кэша. Не рекомендуется.

Вот шпаргалка, которая поможет вам запомнить эти приемы:

Эти техники полезны в обычных приложениях react и могут повысить производительность. Но нам не всегда нужно, чтобы кэш разделялся между экземплярами компонентов. К счастью, все эти методы также работают при масштабировании на компонент — следите за новостями в следующем посте об альтернативных реализациях useMemo.

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

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