Главная / Статьи / Сначала сократите объем работы: чек-лист производительности React перед использованием мемоизации

Сначала сократите объем работы: чек-лист производительности React перед использованием мемоизации

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

2118 слов

Когда приложение на React работает медленно, первым побуждением является использование useMemo, useCallback или React.memo. Эти инструменты снижают нагрузку на уже выполненные операции, но во многих приложениях настоящая нагрузка возникает из-за операций, которые вообще не должны были выполняться: избыточные запросы, слишком большие объемы данных, обновления состояния, влияющие на половину структуры приложения, а также код, который пользователи еще не запрашивали. В этом руководстве рассматриваются семь способов устранения ненужных операций перед оптимизацией оставшегося кода, а также список проверок, который можно применять во время ревью кода.

Основной вопрос на протяжении всего руководства прост: можно ли полностью избежать выполнения этих операций?

Сократите количество запросов: используйте дебаунс для поля поиска

Поля поиска — классический источник лишних запросов. Наивный обработчик вызывает API при каждой смене содержимого поля:

const handleSearch = (value) => {
  fetchUsers(value);
};

При вводе слова «React» в это поле формируется по одному запросу на каждую нажатую клавишу:

R
Re
Rea
Reac
React

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

const handleSearch = debounce((value) => {
  fetchUsers(value);
}, 300);

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

Одно ограничение при использовании подобного помощника внутри компонента: если функция debounce(...) вызывается напрямую в теле компонента, при каждой перерисовке создается новая функция дебаунсинга (и новый таймер), что нарушает работу механизма дебаунсинга. Её следует создавать один раз, например с помощью useMemo или useRef, либо использовать описанный ниже подход с эффектами.

Самодостаточный компонент поиска с дебаунсингом

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

import { useEffect, useState } from "react";
function UserSearch() {
  const [search, setSearch] = useState("");
  const [users, setUsers] = useState([]);

Эффект запускается каждый раз, когда меняется значение search. Вместо немедленного запроса данные загружаются с задержкой с помощью setTimeout. Внутри обратной связи запрос с пустым или содержащим только пробелы текстом очищает результаты и прерывает работу без отправки запроса.

useEffect(() => {
    const timer = setTimeout(async () => {
      if (!search.trim()) {
        setUsers([]);
        return;
      }

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

const response = await fetch(
        `/api/users?search=${encodeURIComponent(search)}`
      );

Затем выполняется парсинг JSON и сохраняются результаты, причем всё это происходит в рамках таймера на 300 мс:

const data = await response.json();
      setUsers(data);
    }, 300);

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

return () => clearTimeout(timer);
  }, [search]);

В конце компонент отображает управляемое поле ввода, связанное с переменной search:

return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />

Кроме того, он выводит список пользователей, отсортированных по их ID:

{users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

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

Более общий урок: предотвращение выполнения работы обычно эффективнее, чем ускорение уже выполняющейся работы.

Загружайте только те данные, которые нужны экрану

Ещё одной распространённой причиной потери ресурсов является загрузка гораздо большего объёма данных, чем отображается. Предположим, конечная точка возвращает информацию о 10 000 пользователях, в то время как интерфейс показывает по 20 пользователей за раз. Браузер всё равно должен загрузить, обработать и сохранить все эти данные в памяти, а библиотека React вынуждена работать с гораздо более большими массивами, чем это необходимо.

Там, где это возможно, используйте пагинацию, чтобы каждый запрос содержал только одну страницу:

API
 ↓
20 users
 ↓
Browser
 ↓
Display

Каждый раз, когда пользователь прокручивает страницу, запрашивайте следующий фрагмент данных. Это снижает нагрузку на сеть, потребление памяти, объём обработки на стороне клиента и количество данных, проходящих через компоненты. Принцип тот же применяется и к функциям бесконечной прокрутки, а также API, основанным на курсоре. Изменение способа получения данных часто даёт гораздо более значительный эффект, чем настройка отдельных компонентов.

Храните данные, которые быстро меняются, рядом с теми компонентами, которые их используют

Местоположение состояния определяет, какая часть интерфейса будет перерисована при его изменении. Рассмотрим панель управления, которая хранит текст поиска:

function Dashboard() {
  const [search, setSearch] = useState("");
return (
    <>
      <SearchBox value={search} onChange={setSearch} />
      <Analytics />
      <UserTable />
    </>
  );
}

Каждое нажатие клавиши обновляет Dashboard, в результате чего перерисовываются также Analytics и UserTable, хотя ни один из них не использует значение поиска. На крупной панели управления это приводит к значительным затратам ресурсов. Если состояние важно только для небольшой части интерфейса, его следует переместить именно туда (в сам SearchBox), чтобы обновления происходили только там, где это необходимо, и структура компонентов становилась более понятной.

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

Присваивайте элементам динамического списка стабильные ключи

В списках незначительные детали могут оказывать огромное влияние. Распространённым подходом является использование индекса массива в качестве ключа:

{users.map((user, index) => (
  <UserCard key={index} user={user} />
))}

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

{users.map((user) => (
  <UserCard key={user.id} user={user} />
))}

Разница заключается в том, что представляет собой ключ:

index → position
id    → identity

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

Сомневайтесь не только в скорости обработки, но и в самой процедуре

Тяжелые преобразования на стороне клиента являются ещё одним источником ненужных затрат. Здесь пользователи фильтруются, сортируются и преобразуются в новые объекты:

const filteredUsers = users
  .filter((user) => user.isActive)
  .sort((a, b) => a.name.localeCompare(b.name))
  .map((user) => ({
    ...user,
    displayName: user.name.toUpperCase()
  }));

При нескольких тысячах записей выполнение этих операций при каждой отрисовке становится дорогостоящим. Одним из решений может быть использование мемоизации, но сначала стоит проверить, действительно ли клиенту необходимо выполнять эту работу. Часто сам конечный пункт может сделать фильтрацию активных пользователей, база данных может обрабатывать сортировку за счет индексов, что делает её дешевой, а пагинация может уменьшить объем данных, делая оставшуюся обработку тривиальной. Сокращение объема входных данных часто эффективнее, чем ускорение вычислений.

Загружайте функции по мере необходимости

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

const Reports = lazy(() => import("./Reports"));

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

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

Использование мемоизации с основанием

Распространенной ошибкой является автоматическое применение инструментов оптимизации во всем коде, например, оборачивание каждого обработчика:

const handleClick = useCallback(() => {
  setSelectedUser(id);
}, [id]);

Или каждого вычисляемого значения:

const data = useMemo(() => {
  return processData(users);
}, [users]);

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

  • Действительно ли вычисления требуют много ресурсов?
  • Выполняются ли они часто?
  • Используется ли результат при нескольких отрисовках?
  • Зависит ли мемоизированный потомок или эффект от стабильного указателя?
  • Приведёт ли изменение к заметной разнице?

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

Чек-лист для аудита

При проверке функционала React рассмотрите следующие вопросы:

  1. Ненужные запросы? Обратите внимание на вызовы при каждом нажатии клавиши, дублирующиеся запросы и получение данных, которые в настоящее время не отображаются.
  2. Слишком много данных? Используйте пагинацию, фильтрацию на сервере и откладывайте получение данных до момента их необходимости.
  • Значение слишком высокое? Проверьте, не приводит ли одно обновление к перерисовке большей части дерева, чем это необходимо.
  • Нестабильные ключи списка? Используйте стабильные идентификаторы там, где элементы могут перемещаться или менять своё состояние.
  • Чрезмерная обработка? Перед оптимизацией выясните, можно ли перенести работу на сервер или пропустить её.
  • Ненужные функции? Загружайте в режиме отложенной загрузки крупные или редко используемые модули.
  • Ненужная мемоизация? Не добавляйте useMemo, useCallback или React.memo только потому, что они существуют.
  • Производительность — это цепочка операций, а не просто процесс рендеринга

    Производительность React зависит не только от самого React. Затраты накапливаются на всём протяжении цепочки обработки:

    API calls
       ↓
    Amount of data
       ↓
    State updates
       ↓
    Component structure
       ↓
    Data processing
       ↓
    Rendering
       ↓
    Bundle size
    

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

    Основные выводы

    • Устранение операций (запросов, данных в байтах, обновлений, кода) обычно приносит больший эффект, чем ускорение самих операций.
    • Необходимо использовать технику дебаунсинга для запросов, инициируемых вводом данных, а также сочетать ее с возможностью отмены запросов, когда это важно.
    • Пусть сервер выполняет фильтрацию, сортировку и пагинацию; на клиент отправляются только те данные, которые он должен отобразить.
    • Храните состояние на самом низком уровне, доступном для всех его читателей, а динамические списки следует сортировать по идентификатору.
    • Используйте стратегию отложенной загрузки для уменьшения объема первоначальной загрузки, а мемоизацию применяйте только тогда, когда это действительно необходимо из-за конкретных затрат.
  • Прежде чем спрашивать, как что-то оптимизировать, сначала узнайте, действительно ли это необходимо.
  • Связанные статьи