React useEffect Cleanup: таймеры, прослушиватели и гонки выборки

React useEffect Cleanup: таймеры, прослушиватели и гонки выборки

Для примеров кода требуется React 18+. Поведение двойного вызова StrictMode было представлено в React 18.

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

Оглавление

Как на самом деле работает очистка useEffect

Жизненный цикл установки-очистки в одном предложении

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

Почему StrictMode дважды вызывает ваши эффекты

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

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

import { useEffect } from 'react';

function ExampleComponent({ dependency }) {
  useEffect(() => {
    
    
    
    console.log('Effect setup with dependency:', dependency);

    return () => {
      
      
      
      console.log('Effect cleanup for dependency:', dependency);
    };
  }, [dependency]); 
  
  

  return <div>Anatomy of useEffectdiv>;
}

Шаблон 1. Очистка таймеров (setInterval/setTimeout)

Ошибка: суммирование интервалов при повторном рендеринге

Когда setInterval начинается внутри useEffect, который зависит от значения свойства или состояния, каждый раз, когда эта зависимость изменяется, React повторно запускает эффект. Без очистки предыдущий интервал продолжает работать. Начинается второй интервал. Потом третий. Наблюдаемый симптом зависит от того, что делает интервал: счетчик увеличивается N раз за такт (где N — количество потерянных интервалов), обратный вызов опроса срабатывает несколько раз за такт или анимация пользовательского интерфейса хаотично ускоряется. Каждый потерянный интервал содержит замыкание устаревшего состояния, что усугубляет проблему. При двойном монтировании StrictMode, находящемся в стадии разработки, эта ошибка проявляется сразу же, как два интервала, начиная с самого первого рендеринга.

Исправление:clearInterval в возврате очистки.

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

import { useEffect, useState } from 'react';

function PollingComponent({ delay }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    
    if (!delay || delay <= 0 || !Number.isFinite(delay)) return;

    
    
    
    

    
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, delay);

    return () => {
      clearInterval(id);
    };
  }, [delay]); 

  return <div>Count: {count}div>;
}

Тот же принцип применим и к setTimeout. Заменять setInterval с setTimeout и clearInterval с clearTimeout. Сохраните идентификатор таймера, возвращенный функцией планирования, а затем очистите его в результате очистки.

Шаблон 2. Очистка прослушивателей событий

Ошибка: скопление прослушивателей в окне/документе

Добавление прослушивателя событий в window, documentили любой узел DOM за пределами собственного дерева рендеринга компонента требует очистки вручную. Без соответствия removeEventListener в возврате очистки каждый повторный рендеринг (или двойное монтирование StrictMode) присоединяет дополнительный обработчик. Обработчики накапливаются. Прослушиватель изменения размера, который обновляет состояние, будет запускать setState несколько раз для каждого события изменения размера, по одному разу для каждого потерянного прослушивателя. Это снижает производительность и может привести к незаметному повреждению состояния, когда несколько обработчиков соревнуются за обновление одной и той же переменной состояния.

Исправление: сопоставление добавления и удаления с одной и той же ссылкой.

Критическая деталь заключается в том, что removeEventListener должен получить точно такую ​​же ссылку на функцию, переданную в addEventListener. Встроенная стрелочная функция, созданная внутри тела эффекта, создает новую ссылку при каждом вызове, поэтому вызов removeEventListener с другой встроенной стрелкой ничего не происходит. Слушатель никогда не удаляется. Объявите обработчик как именованную функцию (или переменную, содержащую функцию) внутри эффекта, затем передайте эту же переменную обоим addEventListener и removeEventListener.

Критическая деталь заключается в том, что removeEventListener должен получить точно такую ​​же ссылку на функцию, переданную в addEventListener.

import { useEffect, useState } from 'react';

function WindowSizeTracker() {
  
  
  
  const [width, setWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 0); 

  useEffect(() => {
    if (typeof window === 'undefined') return;

    
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener('resize', handleResize);

    return () => {
      
      window.removeEventListener('resize', handleResize);
    };
  }, []); 

  return <div>Window width: {width}pxdiv>;
}

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

Шаблон 3: Отмена условий гонки выборки с помощью AbortController

Ошибка: устаревшие ответы перезаписывают свежее состояние

Рассмотрим компонент поиска, где пользователь быстро печатает. Каждое нажатие клавиши запускает новую выборку. Запрос «rea» может занять 300 мс, а запрос «реагировать» — 50 мс. Ответ «реакция» приходит первым, и пользовательский интерфейс показывает правильные результаты. Затем приходит ответ «rea», и поскольку ничто не мешает ему вызвать setState, он перезаписывает пользовательский интерфейс устаревшими, неверными результатами. Это классическое состояние гонки. Более медленный ответ выигрывает не потому, что он более поздний, а потому, что он разрешается позже. Без отмены не существует механизма, позволяющего браузеру отклонить текущий запрос, результаты которого больше не актуальны.

Исправление: сигнал AbortController при очистке

Современное решение – это AbortController. Каждый вызов эффекта создает новый экземпляр контроллера. Контроллера signal имущество передается fetch как вариант. Когда запускается функция очистки (из-за изменения зависимости или размонтирования компонента), она вызывает controller.abort()который отменяет запрос в полете. Обещание выборки отклоняется с AbortError. Браузеры обычно также пытаются отменить базовый сетевой запрос, хотя поведение зависит от механизма (см. спецификацию Fetch в AbortSignal и таблицы совместимости браузеров MDN). Это гарантирует, что только самый последний запрос сможет успешно установить состояние.

Грамотная обработка AbortError

Вызов abort() отвергает обещание. Это нормальное явление, а не ошибка. Блок catch должен различать AbortError от реальных сетевых сбоев. Без этой защиты пользовательский интерфейс будет выдавать ошибку при каждой преднамеренной отмене, или отклонение приведет к срабатыванию инфраструктуры регистрации ошибок. Стандартная проверка проверяет наличие ошибок name свойство: если оно равно 'AbortError'блок catch возвращается раньше, не обновляя состояние и не выдавая ошибку.

import { useEffect, useState } from 'react';

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setResults([]);
      setError(null);
      setLoading(false);
      return;
    }

    
    const controller = new AbortController();

    async function fetchResults() {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const data = await response.json();
        const items = Array.isArray(data?.items) ? data.items : [];
        setResults(items);
      } catch (err) {
        if (err.name === 'AbortError') {
          
          return;
        }
        
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchResults();

    return () => {
      controller.abort();
    };
  }, [query]); 

  if (error) return <div>Error: {error}div>;
  if (loading) return <div>Loading...div>;
  return (
    <ul>
      {results.map((item, index) => (
        <li key={item.id ?? `item-${index}`}>{item.title ?? '(Untitled)'}li>
      ))}
    ul>
  );
}

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

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

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

СценарийДействие настройкиДействие очисткиРаспространенная ошибка
setInterval / setTimeoutЗапустить таймерclearInterval / clearTimeoutИнтервалы суммирования при изменении зависимости
addEventListenerПрисоединить обработчикremoveEventListener с той же ссылкойВстроенная стрелка ≠ та же ссылка
fetchЗапрос на пожар с signalcontroller.abort()Устаревший ответ перезаписывает состояние
Вебсокетnew WebSocket(url) + прикрепить обработчикиНулевые обработчики, затем ws.close()Утечка соединения при размонтировании
Сторонняя подписка (например, Firebase onSnapshot)ПодписатьсяВызов функции отмены подпискиЗабыть возвращаемое значение — это субъект

Шаблоны WebSocket и Firebase приведены здесь для справки; полную информацию о реализации см. в документации MDN WebSocket API и документации Firebase.

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

Контрольный список PR-анализа для использованияEffect Cleanup

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

  1. Каждый ли useEffect это создает побочный эффект, возвращающий функцию очистки?
  2. Осуществляет ли очистка полностью обратную настройку (очистка таймера, удаление прослушивателя, прерывание выборки)?
  3. Стабильны ли ссылки на обработчики событий (именованная функция или useCallback) так removeEventListener на самом деле совпадает?
  4. Является AbortError ловится и обрабатывается отдельно от реальных сетевых ошибок (например, досрочное возвращение без установки состояния ошибки или регистрации)?
  5. Включает ли массив зависимостей все значения, на которые ссылается эффект?
  6. Был ли компонент протестирован при двойном монтировании React StrictMode?
  7. Есть ли какие-нибудь eslint-disable react-hooks/exhaustive-deps комментарии? Если да, то обосновано ли каждое из них сопровождающим комментарием к коду, объясняющим, почему подавление безопасно?

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

Подавление правила исчерпывающего Deps Lint

react-hooks/exhaustive-deps Правило ESLint существует для обнаружения отсутствующих зависимостей, которые приводят к устаревшим закрытиям. Отключение правила скрывает ошибку, а не исправляет ее. Эффект закрывается по устаревшему значению и никогда не запускается повторно в ответ на изменение этого значения, создавая устаревшее замыкание, которое трудно воспроизвести и отладить. Если правило отмечает зависимость, добавьте ее. Если это вызывает нежелательные повторы, реструктурируйте эффект: извлеките логику, разделите его на несколько эффектов или стабилизируйте ссылки с помощью useCallback или useMemo. Подавление должно быть крайней мерой, всегда сопровождаемой комментарием, объясняющим, почему упущение является преднамеренным и безопасным.

Возврат обещания вместо функции

Маркировка обратного вызова эффекта как async это обычная ловушка. Ан async функция неявно возвращает обещание. React ожидает, что возвращаемое значение будет либо undefined или функцию синхронной очистки. React игнорирует возвращаемое обещание и никогда не регистрирует функцию очистки, поэтому при размонтировании или изменении зависимостей ничего не запускается. Исправление всегда одно и то же: определите внутреннюю асинхронную функцию внутри эффекта и немедленно вызовите ее, сохраняя синхронность внешней функции эффекта, чтобы оператор return обеспечивал правильную функцию очистки.

React игнорирует возвращаемое обещание и никогда не регистрирует функцию очистки, поэтому при размонтировании или изменении зависимостей ничего не запускается.

Ключевые выводы

  • Каждый useEffect который получает ресурс, должен вернуть функцию очистки, которая его освобождает. Единственное исключение: эффект, который не вызывает постоянного побочного эффекта (например, однократный вызов аналитики по принципу «выпустил и забыл»).
  • AbortController — это современный браузерный инструмент для отмены запросов на выборку. Он устраняет условия гонки без логических флагов или счетчиков последовательности.
  • Приобретайте ресурс, освобождайте ресурс, честно объявляйте зависимости. Этот принцип применим как к таймерам, прослушивателям, выборкам, WebSockets, так и к сторонним подпискам.
  • Добавьте контрольный список из семи пунктов для проверки PR в свои рекомендации по участию или в шаблон запроса на включение. Он выявляет наиболее часто встречающиеся ошибки очистки до того, как они попадут в рабочую среду.


Source link

Leave a Reply

Your email address will not be published. Required fields are marked *