Как использовать memoization, contexts, useMemo, useState, и useEffect

Для будущих учащихся на курсе "React.js Developer" подготовили перевод материала. Также приглашаем всех желающих на открытый вебинар «ReactJS: быстрый старт. Сильные и слабые стороны».


То, что мы создадим сегодня! Фотография - автора статьи
То, что мы создадим сегодня! Фотография - автора статьи

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

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

И много анимированных 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.  Это выглядит так:

Учитывая все вышесказанное, давайте посмотрим на первое решение ниже.

.              .             .

useEffect

Внутри наших компонентов мы могли бы использовать хук-эффект useEffect Hook для получения данных. Затем мы можем хранить их локально (внутри компонента), используя 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  Hook (хук-эффект) будет выполнен, когда наш компонент будет установлен в DOM (Document Object Model) — и только после этого. Он не будет выполняться снова, когда наш компонент будет перезагружен. Это называется  "выполнить один раз" Hook (хук).

Ограничение использования useEffect в этом случае заключается в том, что когда мы имеем несколько экземпляров компонента в нашем DOM, все они будут получать данные по отдельности (когда они установлены):

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

.              .             .

Memoization (Мемоизация) 

Memoization — это причудливое слово для очень простой техники. Это означает, что вы создаете функцию, и каждый раз, когда она вызывается заново, она сохраняет результаты выполнения  в кэше для предотвращения повторных вычислений.

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

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, предоставляют утилиты, которые можно использовать для легкого создания memoized функций:

import memoize from "lodash.memoize";

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

Использование memoization для получения данных

Мы можем использовать эту технологию при получении данных. Создаем функцию getData, которая возвращает Promise, доступный по окончании запроса на получение данных. Мемоизуем (memoize) объект 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);

Обратите внимание, что в этом примере мы не работали с ошибками. Это заслуживает отдельной статьи, особенно когда мы используем мемоизацию (memoization) (также будет мемоизован (memoized) отклонённый Promise , что может быть проблематично).

Теперь мы можем заменить наш useEffect Hook на другой, который выглядит так:

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 мемоизуется (memoized), все наши компоненты получат одни и те же данные, когда они будут смонтированы:

Анимация: В наших компонентах используется один и тот же memoized Promise.
Анимация: В наших компонентах используется один и тот же memoized Promise.

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

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

getData.cache = new memoize.Cache();

Как вариант, вы можете очистить существующий кэш (с помощью функции Map):

getData.cache.clear();

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

Анимация: Сброс кэша memoized функции getData.
Анимация: Сброс кэша memoized функции getData.

.              .             .

React Context (React Контекст)

Еще одним популярным и широко обсуждаемым (но часто непонятным) инструментом является React Context. И на заметку, еще раз, он не заменяет такие инструменты, как Redux. Это не инструмент управления состоянием. 

Mark Erikson ведет тяжелую битву в Интернете и продолжает объяснять, почему это так. Настоятельно рекомендую прочитать его последнюю статью на эту тему.

И если вам это действительно интересно, прочитайте и мою статью по этой теме:

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

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

import { createContext } from "react";

export const PeopleContext = createContext(null);

Затем мы заворачиваем (wrap) тот компонент, который отображает (renders) ваши компоненты People, с помощью Context Provider:

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 Hook. Результат выглядит следующим образом:

Анимация: Использование данных из Context (Контекста).
Анимация: Использование данных из Context (Контекста).

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

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

.              .             .

useMemo

И последнее, но не менее важное, это беглый взгляд на useMemo. Этот Hook отличается от других методов, которые мы уже рассматривали, в том смысле, что это только форма кэширования на локальном уровне: внутри одного элемента компонента. Вы не можете использовать useMemo для обмена данными между несколькими компонентами — по крайней мере, без таких обходных путей, как пробрасывание (prop-drilling) или внедрение зависимостей ((dependency injection) (например, 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.

И, наконец, мы используем useMemo на 7-й строке. Мы мемоизуем (memoize) результат вызова функции и сохраняем его в виде переменной, называемой pow. Наша функция возвращает число, которое представляет собой сумму age, увеличенную до двух, плюс случайное число. Она имеет зависимость от переменной age, поэтому мы передаем ее в качестве зависимого аргумента вызова useMemo.

Функция выполняется только при изменении значения возраста. Если наш компонент повторно будет вызван (re-rendered) и значение age не изменится, то useMemo просто вернёт мемоизованный (memoized) результат.

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

Последние две анимации покажут, что происходит. Во-первых, мы обновляем компонент randomNumber и не затрагиваем значение age. Таким образом, мы видим useMemo в действии (значение pow не меняется при ререндеринге (re-rendered) компонента. Каждый раз, когда мы нажимаем на кнопку “randomNumer”, наш компонент возвращается (re-rendered) к исходному значению:

Анимация: Много обращений (re-renders), но значение pow мемоизуется (memoized) с useMemo.
Анимация: Много обращений (re-renders), но значение pow мемоизуется (memoized) с useMemo.

Однако, если мы изменяем значение age, pow получает повторный отклик (re-rendered), потому что наш useMemo вызов имеет зависимость от значения age:

Анимация: Наше мемоизованное (memoized) значение обновляется при обновлении зависимости.
Анимация: Наше мемоизованное (memoized) значение обновляется при обновлении зависимости.

.              .             .

Заключение

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

Весь программный код, использованный в этой статье, можно найти у меня в архиве на GitLab.

Спасибо, что уделили время!


Узнать подробнее о курсе "React.js Developer".

Смотреть открытый вебинар «ReactJS: быстрый старт. Сильные и слабые стороны».