Обзор техник кэширования в React

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

В статье мы рассмотрим различные техники, обращая внимание на все их нюансы и неуловимые различия. Что выбрать: useMemo или мемоизацию, хранение данных с помощью useState или контекста? Изучив материал, вы сможете принимать осознанные решения относительно кэширования данных и будете владеть подробной информацией по данной теме. А еще вас ждет много GIF-анимации. 

Итак, приступим! 

Данные 

Перед тем, как углубиться в код, быстро просмотрим данные, которые нам предстоит получать в большинстве компонентов. Файл, выступающий в роли API, выглядит следующим образом: 

export default function handler(req, res) {
  setTimeout(
    () =>
      res.status(200).json({
        randomNumber: Math.round(Math.random() * 10000),
        people: [
          { name: "John Doe" },
          { name: "Olive Yew" },
          { name: "Allie Grater" },
        ],
      }),
    750
  );
}

Этот код выполняется при осуществлении запроса к пути проекта /api/people. Как видно, мы возвращаем объект с двумя свойствами:

  • randomNumber: произвольное число в диапазоне 0–10000. 
  • people: статический массив с тремя вымышленными именами. 

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

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

Компонент People 

Отображая данные из API, мы передаем их в компонент с именем PeopleRenderer. Выглядит это следующим образом: 

Компонент PeopleRenderer
2 компонента, отображающие точно такие же (кэшированные) данные 
2 компонента, отображающих разные (некэшированные) данные 

Имея в виду эту вводную информацию, рассмотрим первую технику. 

useEffect

Внутри компонентов для получения данных можно задействовать хук useEffect, после чего сохранять эти данные локально (внутри компонента) с помощью useState:

import { useEffect, useState } from "react";
import PeopleRenderer from "../PeopleRenderer";

export default function PeopleUseEffect() {
  const [json, setJson] = useState(null);

  useEffect(() => {
    fetch("/api/people")
      .then((response) => response.json())
      .then((data) => setJson(data));
  }, []);

  return <PeopleRenderer json={json} />;
}

При передаче пустого массива в качестве второго параметра (строка 11) хук useEffect будет выполнен после встраивания компонента в DOM. При новом отображении компонента повторно он выполняться уже не будет. Назовем этот хук “одноразовым”. 

В связи с применением useEffect таким способом следует упомянуть о том, что при наличии нескольких экземпляров компонента в DOM все они будут получать данные по отдельности (при встраивании).

Компоненты получают данные по отдельности 

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

Мемоизация 

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

При первом вызове такой мемоизованной функции результаты вычисляются (или получаются  —  все зависит от выполняемых операций внутри тела функции). Прежде чем вернуть результаты, вы сохраняете их в кэше в ключе, который создается с входными параметрами: 

const MyMemoizedFunction = (age) => {
  if(cache.hasKey(age)) {
    return cache.get(age);
  }
  const value = `You are ${age} years old!`;
  cache.set(age, value);
  return value;
}

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

import memoize from "lodash.memoize";

const MyFunction = (age) => {
  return `You are ${age} years old!`;
}
const MyMemoizedFunction = memoize(MyFunction);

Мемоизация для получения данных 

Данная техника подходит для получения данных. Мы создаем функцию getData, возвращающую обещание Promise, разрешение которого происходит по окончании fetch-запроса. Мы сохраняем этот Promise:

import memoize from "lodash.memoize";

const getData = () =>
  new Promise((resolve) => {
    fetch("http://localhost:3000/api/people")
      .then((response) => response.json())
      .then((data) => resolve(data));
  });

export default memoize(getData);

Обратите внимание, что в данном примере мы не проводим обработку ошибок. Эта тема заслуживает отдельной статьи, особенно в связи с мемоизацией (поскольку отклоненное обещание Promise тоже бы сохранилось, что чревато проблемами). 

Теперь заменим хук useEffect на другой, который выглядит следующим образом: 

import { useEffect, useState } from "react";
import getData from "./getData";
import PeopleRenderer from "../PeopleRenderer";

export default function PeopleMemoize() {
  const [json, setJson] = useState(null);

  useEffect(() => {
    getData().then((data) => setJson(data));
  }, []);

  return <PeopleRenderer json={json} />;
}

Поскольку результат getData сохраняется, то при встраивании все компоненты получат одинаковые данные: 

Компоненты используют один и тот же сохраненный Promise

Стоит также отметить, что при открытии страницы memoize.tsx (до добавления первого экземпляра компонента) данные уже предварительно получаются. Дело в том, что мы определили функцию getData в отдельном расположенном в верхней части страницы файле, при загрузке которого создается Promise

Можно аннулировать (очистить) кэш мемоизованной функции, присвоив ее свойству cache нового Cache.

getData.cache = new memoize.Cache();

В качестве альтернативного варианта можно очистить существующий кэш (это экземпляр Map).

getData.cache.clear();

Однако данная функциональность характерна лишь для Lodash. Другие же библиотеки требуют иных решений. Ниже представлен наглядный пример очистки кэша в действии.

Сброс кэша мемоизованной функции getData

React Context

React Context  —  еще один известный и подробно изученный инструмент (в отношении которого однако часто происходит недопонимание). В связи с этим в очередной раз напоминаю, что он не заменяет такие инструменты, как Redux, поскольку не является средством управления состоянием. 

Итак, что такое Context? Это механизм для внедрения данных в дерево компонентов. Если у вас есть некие данные, то их можно сохранить, к примеру, с помощью хука useState, внутри компонента, находящегося на более высоком уровне иерархии. Затем с помощью Provider Context внедрить эти данные в дерево, что позволит считывать (использовать) их в любом нижестоящем компоненте. 

Для большей ясности приведем пример. Сначала создаем новый контекст.

import { createContext } from "react";

export const PeopleContext = createContext(null);

После этого обертываем компонент, отображающий компоненты People, в Provider Context.

export default function Context() {
  const [json, setJson] = useState(null);

  useEffect(() => {
    fetch("/api/people")
      .then((response) => response.json())
      .then((data) => setJson(data));
  }, []);

  return (
    <PeopleContext.Provider value={{ json }}>
        ...
    </PeopleContext.Provider>
  );
}

В 12 строке у нас есть возможность отобразить любой элемент. В определенный момент, опускаясь вниз по дереву, отобразим компонент(ы) People.

import { useContext } from "react";
import PeopleRenderer from "../PeopleRenderer";
import { PeopleContext } from "./context";

export default function PeopleWithContext() {
  const { json } = useContext(PeopleContext);

  return <PeopleRenderer json={json} />;
}

Можно применить значение из Provider с помощью хука useContext, получая следующий результат.

Использование данных из Context

Обратите внимание на одно важное здесь отличие! На последнем этапе анимации мы нажимаем кнопку “Set new seed”. При этом повторно получаются данные, хранящиеся в Provider Context. По завершении этого (спустя 750 мс) полученные данные становятся новым значением Provider, и компоненты People отображаются еще раз. Как видите, все они пользуются одними и теми же общими данными. 

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

useMemo

В последнем, но не маловажном разделе, проведем беглый обзор useMemo. От предыдущих техник этот хук отличается тем, что он является лишь формой кэширования на локальном уровне: внутри одного экземпляра компонента. useMemo не предназначен для совместного использования данных несколькими компонентами  —  по крайней мере, не без обходных решений в виде пробрасывания (prop-drilling) пропсов или внедрения зависимостей (например, React Context). 

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

export default function Memo() {
  const getRnd = () => Math.round(Math.random() * 10000);

  const [age, setAge] = useState(24);
  const [randomNumber, setRandomNumber] = useState(getRnd());

  const pow = useMemo(() => Math.pow(age, 2) + getRnd(), [age]);

  return (
    ...
  );
}
  • getRnd (строка 2): функция, возвращающая случайное число в диапазоне 0–10000. 
  • age (строка 4): с помощью useState сохраняет число, обозначающее возраст. 
  • randomNumber (строка 5): сохраняет случайное число посредством useState

И наконец, в строке 7 применяется useMemo. Мы запоминаем результат вызова функции и сохраняем его в переменной pow. Функция возвращает значение суммы age, возведенной во вторую степень, и случайного числа. Поскольку она зависит от переменной age, мы передаем эту переменную в аргумент зависимости вызова useMemo

Функция выполняется только в случае изменения значения age. Если компонент отображается повторно, а значение age не изменилось, useMemo просто вернет мемоизованный результат. 

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

Две последние анимации показывают, что происходит. Сначала мы обновляем randomNumber, не затрагивая значение age, вследствие чего наблюдаем useMemo в действии (значение pow не меняется при повторном отображении компонента). При каждом нажатии на кнопку компонент заново отображается.

Многочисленные повторные отображения, но значение pow запоминается с помощью useMemo

Однако изменение значения age повлечет за собой изменение pow, поскольку вызов useMemo зависит от значения age.

Мемоизованное значение обновляется вслед за обновлением зависимости

Заключение 

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

С полным вариантом кода, представленного в статье, можно ознакомиться в репозитории на GitLab

Спасибо за внимание! 

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Gerard van der Put: Exploring Caching Techniques in React

Предыдущая статьяКак удаленно отлаживать сайты для Android с помощью Chrome DevTools
Следующая статьяКак инструменты дизайна интерфейса и визуализации способствуют развитию Machine Teaching?