Spec-Zone.ru › React

Синхронизация с эффектами

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

Вы узнаете

  • Что такое эффекты
  • Чем эффекты отличаются от событий
  • Как объявить эффект в вашем компоненте
  • Как пропустить ненужное повторное выполнение эффекта
  • Почему эффекты выполняются дважды в режиме разработки и как это исправить

Что такое эффекты и чем они отличаются от событий?

Прежде чем перейти к эффектам, вам необходимо ознакомиться с двумя типами логики внутри React-компонентов:

  • Код отрисовки (представлен в Описании пользовательского интерфейса) находится на верхнем уровне вашего компонента. Здесь вы принимаете props и состояние, преобразуете их и возвращаете JSX, который вы хотите видеть на экране. Код отрисовки должен быть чистым. Как математическая формула, он должен только вычислять результат, но не делать ничего больше.

  • Обработчики событий (представлены в Добавлении интерактивности) — это вложенные функции внутри ваших компонентов, которые делают что-то, а не просто вычисляют это. Обработчик события может обновить поле ввода, отправить HTTP POST-запрос для покупки товара или перенаправить пользователя на другой экран. Обработчики событий содержат “побочные эффекты” (они изменяют состояние программы), вызванные определенным действием пользователя (например, нажатием кнопки или набором текста).

Иногда этого недостаточно. Рассмотрим компонент ChatRoom, который должен подключиться к серверу чата всякий раз, когда он виден на экране. Подключение к серверу — это не чистое вычисление (это побочный эффект), поэтому оно не может происходить во время отрисовки. Однако нет одного конкретного события, такого как щелчок, которое вызывает ChatRoom.

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

Примечание

Здесь и далее в этом тексте заглавная буква «Эффект» относится к определению React, т.е. побочному эффекту, вызванному отрисовкой. Для обозначения более широкого понятия программирования мы будем использовать термин «побочный эффект».

Вы, возможно, не нуждаетесь в эффекте

Не спешите добавлять эффекты в ваши компоненты. Помните, что эффекты обычно используются для «выхода» за пределы вашего кода React и синхронизации с какой-либо внешней системой. Это включает в себя API браузера, сторонние виджеты, сеть и так далее. Если ваш эффект только корректирует состояние на основе другого состояния, возможно, вам не нужен эффект.

Как написать эффект

Чтобы написать эффект, следуйте этим трем шагам:

  1. Объявите эффект. По умолчанию ваш эффект будет выполняться после каждого этапа подтверждения.
  2. Укажите зависимости эффекта. Большинство эффектов должны выполняться только при необходимости, а не после каждой отрисовки. Например, анимация плавного входа должна срабатывать только при появлении компонента. Подключение и отключение от чата должно происходить только при появлении и исчезновении компонента или при изменении чата. Вы узнаете, как контролировать это, указав зависимости.
  3. Добавьте очистку, если это необходимо. Некоторые эффекты должны указать, как остановить, отменить или очистить то, что они делали. Например, «подключить» требует «отключить», «подписаться» — «отписаться», а «получить» — либо «отменить», либо «проигнорировать». Вы узнаете, как сделать это, вернув функцию очистки.

Давайте рассмотрим каждый из этих шагов подробнее.

Шаг 1: Объявление эффекта

Чтобы объявить эффект в вашем компоненте, импортируйте useEffect хук из React:

import { useEffect } from 'react';

Затем вызовите его на верхнем уровне вашего компонента и поместите некоторый код внутрь вашего эффекта:

function MyComponent() {
  useEffect(() => {
    // Code here will run after *every* render
  });
  return <div />;
}

Каждый раз, когда ваш компонент отрисовывается, React обновит экран и затем выполнит код внутри useEffect. Другими словами, useEffect «откладывает» выполнение части кода до тех пор, пока эта отрисовка не отобразится на экране.

Давайте посмотрим, как можно использовать эффект для синхронизации с внешней системой. Рассмотрим <VideoPlayer> React-компонент. Было бы неплохо контролировать, воспроизводится он или приостановлен, передавая isPlaying prop.

<VideoPlayer isPlaying={isPlaying} />;

Ваш пользовательский VideoPlayer компонент отображает встроенный браузер <video> тег:

function VideoPlayer({ src, isPlaying }) {
  // TODO: do something with isPlaying
  return <video src={src} />;
}

Однако тег браузера <video> не имеет isPlaying prop. Единственный способ контролировать его — вручную вызывать методы play() и pause() на элементе DOM. Вам необходимо синхронизировать значение isPlaying prop, указывающее, должен ли видеоплеер в данный момент воспроизводиться, с вызовами, подобными play() и pause().

Сначала нам понадобится получить ссылку на <video> узел DOM.

Вы можете попытаться вызвать play() или pause() во время отрисовки, но это неверно:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play();  // Calling these while rendering isn't allowed.
  } else {
    ref.current.pause(); // Also, this crashes.
  }

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

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

Более того, когда VideoPlayer вызывается в первый раз, его DOM еще не существует! Еще нет узла DOM, на котором можно вызвать play() или pause(), потому что React не знает, какой DOM создать, пока вы не вернете JSX.

Решение здесь заключается в оборачивании побочного эффекта с useEffect , чтобы перенести его за пределы вычисления отрисовки:

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

Оборачивая обновление DOM в эффект, вы позволяете React сначала обновить экран. Затем выполняется ваш эффект.

Когда ваш VideoPlayer компонент отрисовывается (в первый раз или если он перерисовывается), произойдет несколько вещей. Во-первых, React обновит экран, гарантируя, что тег <video> находится в DOM с правильными атрибутами. Затем React выполнит ваш эффект. Наконец, ваш эффект вызовет play() или pause() в зависимости от значения isPlaying.

Нажмите Play/Pause несколько раз и посмотрите, как видеоплеер остается синхронизированным со значением isPlaying:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

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

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

Опасность

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

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

Эффекты выполняются как результат отрисовки. Установка состояния вызывает отрисовку. Установка состояния непосредственно в эффекте — это как подключение розетки к самой себе. Эффект выполняется, он устанавливает состояние, что вызывает перерисовку, что вызывает выполнение эффекта, он снова устанавливает состояние, что вызывает еще одну перерисовку и так далее.

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

Шаг 2: Указание зависимостей эффекта

По умолчанию эффекты выполняются после каждой отрисовки. Часто этого не нужно:

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

Чтобы продемонстрировать проблему, вот предыдущий пример с несколькими вызовами console.log и полем ввода текста, которое обновляет состояние родительского компонента. Обратите внимание, как ввод текста вызывает повторное выполнение эффекта:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Вы можете сказать React пропустить ненужное повторное выполнение эффекта, указав массив зависимостей в качестве второго аргумента для вызова useEffect. Начните с добавления пустого [] массива в вышеприведенный пример в строке 14:

  useEffect(() => {
    // ...
  }, []);

Вы должны увидеть ошибку, которая гласит React Hook useEffect has a missing dependency: 'isPlaying':

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, []); // This causes an error

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Проблема в том, что код внутри вашего эффекта зависит от значения isPlaying prop, чтобы решить, что делать, но эта зависимость не была явно объявлена. Чтобы исправить эту проблему, добавьте isPlaying в массив зависимостей:

  useEffect(() => {
    if (isPlaying) { // It's used here...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...so it must be declared here!

Теперь все зависимости объявлены, поэтому ошибка отсутствует. Указание [isPlaying] в качестве массива зависимостей говорит React, что он должен пропустить повторное выполнение вашего эффекта, если isPlaying такое же, как и во время предыдущей отрисовки. С этим изменением написание текста в поле ввода не вызывает повторного выполнения эффекта, но нажатие Play/Pause — да:

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Массив зависимостей может содержать несколько зависимостей. React пропустит повторное выполнение эффекта только в том случае, если все указанные вами зависимости имеют ровно те же значения, что и во время предыдущего рендеринга. React сравнивает значения зависимостей с помощью сравнения Object.is. Подробности см. в useEffect справке.

Обратите внимание, что вы не можете «выбирать» свои зависимости. Вы получите ошибку lint, если указанные вами зависимости не соответствуют ожиданиям React на основе кода внутри вашего эффекта. Это помогает обнаружить многие ошибки в вашем коде. Если вы не хотите, чтобы какой-то код выполнялся повторно, измените сам код эффекта, чтобы он «не нуждался» в этой зависимости.

Опасность

Поведение без массива зависимостей и с пустым [] массивом зависимостей отличается:

useEffect(() => {
  // This runs after every render
});

useEffect(() => {
  // This runs only on mount (when the component appears)
}, []);

useEffect(() => {
  // This runs on mount *and also* if either a or b have changed since the last render
}, [a, b]);

В следующем шаге мы подробно рассмотрим, что означает «монтирование».

Подробный анализ

Почему ref был опущен из массива зависимостей?

Этот эффект использует оба ref и isPlaying, но только isPlaying объявлен как зависимость:

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying]);

Это потому, что у объекта ref стабильная идентичность: React гарантирует, что вы всегда получите один и тот же объект от одного и того же вызова useRef при каждом рендеринге. Он никогда не меняется, поэтому он никогда сам не приведет к повторному выполнению эффекта. Поэтому не имеет значения, включаете вы его или нет. Включение также допустимо:

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying, ref]);

Функции set функций, возвращаемые useState, также имеют стабильную идентичность, поэтому их часто пропускают из зависимостей. Если linter позволяет пропустить зависимость без ошибок, это безопасно.

Пропуск всегда стабильных зависимостей работает только тогда, когда linter может «увидеть», что объект стабилен. Например, если ref передавался из родительского компонента, вам нужно будет указать его в массиве зависимостей. Однако это хорошо, потому что вы не можете знать, всегда ли родительский компонент передает тот же ref или передает один из нескольких ref условно. Таким образом, ваш эффект будет зависеть от того, какой ref передан.

Шаг 3: Добавить очистку, если необходимо

Рассмотрим другой пример. Вы пишете компонент ChatRoom, который должен подключиться к серверу чата при своем появлении. Вам предоставлен API createConnection(), который возвращает объект с методами connect() и disconnect(). Как вы сохраните подключение компонента, пока он отображается пользователю?

Начните с написания логики эффекта:

useEffect(() => {
  const connection = createConnection();
  connection.connect();
});

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

useEffect(() => {
  const connection = createConnection();
  connection.connect();
}, []);

Код внутри эффекта не использует какие-либо свойства или состояния, поэтому ваш массив зависимостей [] (пустой). Это говорит React, что этот код должен выполняться только при «монтировании» компонента, то есть когда он впервые появляется на экране.

Попробуем запустить этот код:

import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

Этот эффект запускается только при монтировании, поэтому вы можете ожидать, что "✅ Connecting..." будет выведено в консоли один раз. Однако, если вы проверите консоль, "✅ Connecting..." будет выведено дважды. Почему это происходит?

Представьте, что компонент ChatRoom является частью более крупного приложения с множеством разных экранов. Пользователь начинает свое путешествие на странице ChatRoom. Компонент монтируется и вызывает connection.connect(). Затем представьте, что пользователь переходит на другой экран — например, на страницу «Настройки». Компонент ChatRoom размонтируется. Наконец, пользователь нажимает «Назад», и ChatRoom монтируется снова. Это привело бы к настройке второго подключения — но первое подключение так и не было удалено! По мере навигации пользователя по приложению подключения будут накапливаться.

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

Вывод "✅ Connecting..." дважды помогает обнаружить истинную проблему: ваш код не закрывает подключение при размонтировании компонента.

Для исправления проблемы верните функцию очистки из своего эффекта:

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []);

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

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

Теперь в режиме разработки вы получаете три записи в консоли:

  1. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

Это правильное поведение в режиме разработки. Перемонтировав свой компонент, React проверяет, что навигация в сторону и обратно не нарушит ваш код. Отключение и повторное подключение — именно то, что должно произойти! Когда вы правильно реализуете функцию очистки, не должно быть заметных для пользователя различий между запуском эффекта один раз (как в производстве) и последовательностью «настройка → очистка → настройка» (как вы увидите в режиме разработки). Дополнительная пара вызовов подключение/отключение связана с тем, что React исследует ваш код на наличие ошибок в режиме разработки. Это нормально — не пытайтесь сделать это незаметным!

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

Как обработать повторное срабатывание эффекта в режиме разработки?

React намеренно перемонтирует ваши компоненты в режиме разработки, чтобы найти ошибки, как в последнем примере. Правильный вопрос не «как запустить эффект один раз», а «как исправить мой эффект, чтобы он работал после перемонтирования».

Обычно ответ заключается в реализации функции очистки. Функция очистки должна остановить или отменить любые действия, которые выполнял эффект. Правило состоит в том, что пользователь не должен иметь возможности отличить запуск эффекта один раз (как в производстве) от последовательности «настройка → очистка → настройка» (как вы увидите в режиме разработки).

Большинство эффектов, которые вы будете писать, будут соответствовать одному из общих шаблонов ниже.

Опасность

Не используйте ref для предотвращения срабатывания эффектов

Распространённой ошибкой для предотвращения повторного срабатывания эффектов в режиме разработки является использование ref для предотвращения выполнения эффекта более одного раза. Например, вы могли бы «исправить» вышеупомянутую ошибку с помощью useRef.

  const connectionRef = useRef(null);
  useEffect(() => {
    // 🚩 This wont fix the bug!!!
    if (!connectionRef.current) {
      connectionRef.current = createConnection();
      connectionRef.current.connect();
    }
  }, []);

Это приведет к тому, что вы увидите "✅ Connecting..." только один раз в режиме разработки, но это не исправит ошибку.

Когда пользователь переходит на другую страницу, соединение все еще не закрывается, а при возвращении на эту страницу создается новое соединение. По мере навигации пользователя по приложению соединения будут накапливаться так же, как и до «исправления».

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

См. примеры ниже, чтобы узнать, как обрабатывать распространённые шаблоны.

Управление виджетами, не написанными на React

Иногда вам нужно добавить UI-виджеты, которые не написаны на React. Например, предположим, что вы добавляете компонент карты на свою страницу. Он имеет метод setZoomLevel(), и вы хотите синхронизировать уровень масштабирования с переменной состояния zoomLevel в вашем коде React. Ваш эффект будет выглядеть примерно так:

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

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

Некоторые API могут не позволять вызывать их дважды подряд. Например, метод showModal встроенного элемента <dialog> генерирует исключение при повторном вызове. Реализуйте функцию очистки и закройте диалоговое окно:

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

В режиме разработки ваш эффект вызовет showModal(), затем сразу close(), а затем showModal() снова. Это имеет то же поведение, которое вы видите для пользователя, что и вызов showModal() один раз, как это происходит в производстве.

Подписка на события

Если ваш эффект подписывается на что-то, функция очистки должна отписаться:

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

В режиме разработки ваш эффект вызовет addEventListener(), затем сразу removeEventListener(), а затем addEventListener() снова с тем же обработчиком. Таким образом, активной подписки будет только одна. Это имеет то же поведение для пользователя, что и вызов addEventListener() один раз, как в производстве.

Вызов анимаций

Если ваш эффект анимирует что-то, функция очистки должна сбросить анимацию до начальных значений:

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);

В режиме разработки значение непрозрачности будет установлено на 1, затем на 0, а затем снова на 1. Это должно иметь то же поведение для пользователя, что и прямое задание значения 1, что происходит в производстве. Если вы используете стороннюю библиотеку анимации с поддержкой tweening, ваша функция очистки должна сбросить таймлайн до его начального состояния.

Получение данных

Если ваш эффект получает данные, функция очистки должна либо отменить запрос, либо игнорировать его результат:

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

Вы не можете «отменить» уже выполненный сетевой запрос, но ваша функция очистки должна гарантировать, что запрос, который больше не имеет отношения, больше не влияет на ваше приложение. Если userId изменяется со значения 'Alice' на 'Bob', функция очистки гарантирует, что ответ 'Alice' игнорируется даже если он придёт после 'Bob'.

При разработке вы увидите два запроса в вкладке «Сеть». В этом нет ничего страшного. С приведенным выше подходом первый эффект немедленно будет очищен, поэтому его копия переменной ignore будет установлена в значение true. Таким образом, даже если есть дополнительный запрос, он не повлияет на состояние благодаря проверке if (!ignore).

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

function TodoList() {
  const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
  // ...

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

Подробный анализ

Какие хорошие альтернативы получению данных в эффектах?

Написание вызовов fetch внутри эффектов — популярный способ получения данных, особенно в приложениях, работающих полностью на стороне клиента. Однако это очень ручное подходу, и у него есть существенные недостатки:

  • Эффекты не выполняются на сервере. Это означает, что начатый HTML с рендерингом на сервере будет содержать только состояние загрузки без данных. Компьютер клиента должен загрузить весь JavaScript и рендерить ваше приложение, только чтобы обнаружить, что теперь ему нужно загрузить данные. Это не очень эффективно.
  • Прямое получение данных в эффектах упрощает создание «водопадов» запросов к сети. Вы рендерите родительский компонент, он получает некоторые данные, рендерит дочерние компоненты, а затем они начинают получать свои данные. Если сеть не очень быстрая, это значительно медленнее, чем получение всех данных параллельно.
  • Прямое получение данных в эффектах обычно означает, что вы не предварительно загружаете или не кэшируете данные. Например, если компонент размонтируется, а затем снова смонтируется, ему придётся снова получать данные.
  • Это не очень удобно. При написании вызовов fetch таким образом, чтобы избежать ошибок, таких как проблемы гонки, требуется довольно много повторяющегося кода.

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

  • Если вы используете фреймворк, используйте его встроенный механизм получения данных. Современные фреймворки React имеют встроенные механизмы получения данных, которые эффективны и не страдают от перечисленных выше проблем.
  • В противном случае, рассмотрите использование или создание кэша на стороне клиента. Популярные решения с открытым исходным кодом включают React Query, useSWR и React Router 6.4+. Вы также можете создать собственное решение, в котором вы будете использовать эффекты, но добавите логику для дедупликации запросов, кэширования ответов и предотвращения «водопадов» запросов к сети (путем предварительной загрузки данных или возложения требований к данным на маршруты).

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

Отправка аналитики

Рассмотрим этот код, который отправляет событие аналитики при посещении страницы:

useEffect(() => {
  logVisit(url); // Sends a POST request
}, [url]);

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

В производственной среде дубликатов журналов посещений не будет.

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

Не эффект: Инициализация приложения

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

if (typeof window !== 'undefined') { // Check if we're running in the browser.
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

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

Не эффект: Покупка товара

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

useEffect(() => {
  // 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
  fetch('/api/buy', { method: 'POST' });
}, []);

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

Покупка не вызвана рендерингом; она вызвана конкретным взаимодействием. Она должна выполняться только при нажатии пользователем кнопки. Удалите эффект и переместите свой /api/buy запрос в обработчик события нажатия кнопки «Купить»:

  function handleClick() {
    // ✅ Buying is an event because it is caused by a particular interaction.
    fetch('/api/buy', { method: 'POST' });
  }

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

Объединение всего

Эта игровая площадка может помочь вам «ощутить» практическое применение эффектов.

В этом примере используется setTimeout, чтобы запланировать запись в консоль с текстом ввода, который появится через три секунды после выполнения эффекта. Функция очистки отменяет ожидающий таймаут. Начните с нажатия кнопки «Смонтировать компонент»:

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Unmount' : 'Mount'} the component
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

Сначала вы увидите три записи: Schedule "a" log, Cancel "a" log, и Schedule "a" log снова. Через три секунды также появится запись, гласящая a. Как вы узнали ранее, дополнительная пара «запланировать/отменить» появляется, потому что React перерендерит компонент один раз в режиме разработки, чтобы убедиться, что вы правильно реализовали очистку.

Теперь измените ввод, чтобы он содержал abc. Если вы сделаете это достаточно быстро, вы увидите Schedule "ab" log сразу после Cancel "ab" log и Schedule "abc" log. React всегда очищает эффект предыдущего рендеринга перед эффектом следующего рендеринга. Вот почему, даже если вы быстро набираете текст ввод, запланирован максимум один таймаут одновременно. Измените ввод несколько раз и следите за консолью, чтобы понять, как эффекты очищаются.

Введите текст в поле ввода, а затем сразу нажмите «Разобрать компонент». Обратите внимание, как разборка очищает эффект последнего рендеринга. Здесь он отменяет последний таймаут, прежде чем он успевает сработать.

Наконец, измените компонент выше и закомментируйте функцию очистки, чтобы таймауты не отменялись. Попробуйте быстро набрать abcde. Что вы ожидаете увидеть через три секунды? Будет ли console.log(text) внутри таймаута выводить последнее значение text и создавать пять записей abcde? Попробуйте это, чтобы проверить свои предположения!

Через три секунды вы должны увидеть последовательность записей (a, ab, abc, abcd, и abcde ), а не пять записей abcde . Каждый эффект «захватывает» значение text из соответствующего рендеринга. Неважно, что состояние text изменилось: эффект от рендеринга со значением text = 'ab' всегда увидит 'ab'. Другими словами, эффекты каждого рендеринга изолированы друг от друга. Если вам интересно, как это работает, вы можете прочитать о замыканиях.

Подробное изучение

Каждый рендер имеет свои собственные эффекты

Вы можете представить useEffect как «прикрепление» части поведения к результату рендера. Рассмотрим такой эффект:

export default function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to {roomId}!</h1>;
}

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

Первоначальный рендер

Пользователь посещает <ChatRoom roomId="general" />. Давайте мысленно заменим roomId на 'general':

  // JSX for the first render (roomId = "general")
  return <h1>Welcome to general!</h1>;

Эффект также является частью результата рендера. Эффект первого рендера становится:

  // Effect for the first render (roomId = "general")
  () => {
    const connection = createConnection('general');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the first render (roomId = "general")
  ['general']

React выполняет этот эффект, который подключается к чату 'general'.

Повторный рендер с теми же зависимостями

Предположим, что <ChatRoom roomId="general" /> перерисовывается. Результат JSX остается неизменным:

  // JSX for the second render (roomId = "general")
  return <h1>Welcome to general!</h1>;

React видит, что результат рендера не изменился, поэтому не обновляет DOM.

Эффект второго рендера выглядит так:

  // Effect for the second render (roomId = "general")
  () => {
    const connection = createConnection('general');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the second render (roomId = "general")
  ['general']

React сравнивает ['general'] второго рендера с ['general'] первого рендера. Поскольку все зависимости одинаковы, React пропускает эффект второго рендера. Он никогда не вызывается.

Повторный рендер с другими зависимостями

Затем пользователь посещает <ChatRoom roomId="travel" />. На этот раз компонент возвращает другой JSX:

  // JSX for the third render (roomId = "travel")
  return <h1>Welcome to travel!</h1>;

React обновляет DOM, чтобы изменить "Welcome to general" на "Welcome to travel".

Эффект третьего рендера выглядит так:

  // Effect for the third render (roomId = "travel")
  () => {
    const connection = createConnection('travel');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the third render (roomId = "travel")
  ['travel']

React сравнивает ['travel'] третьего рендера с ['general'] второго рендера. Одна зависимость отличается: Object.is('travel', 'general') — это false. Эффект нельзя пропустить.

Перед тем, как React сможет применить эффект третьего рендера, ему нужно очистить последний эффект, который был выполнен. Эффект второго рендера был пропущен, поэтому React нужно очистить эффект первого рендера. Если вы прокрутите вверх до первого рендера, вы увидите, что его функция очистки вызывает disconnect() по подключению, которое было создано с createConnection('general'). Это отключает приложение от чата 'general'.

После этого React выполняет эффект третьего рендера. Он подключается к чату 'travel'.

Удаление

Наконец, предположим, что пользователь переходит на другую страницу, и компонент ChatRoom удаляется. React выполняет функцию очистки последнего эффекта. Последний эффект был от третьего рендера. Функция очистки третьего рендера уничтожает подключение createConnection('travel'). Таким образом, приложение отключается от комнаты 'travel'.

Поведение, используемое только в разработке

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

Резюме

  • В отличие от событий, эффекты вызываются самим рендерингом, а не каким-либо конкретным взаимодействием.
  • Эффекты позволяют синхронизировать компонент с внешней системой (API сторонних разработчиков, сетью и т. д.).
  • По умолчанию эффекты выполняются после каждого рендера (включая начальный).
  • React пропустит эффект, если все его зависимости имеют те же значения, что и во время последнего рендера.
  • Вы не можете «выбрать» свои зависимости. Они определяются кодом внутри эффекта.
  • Пустой массив зависимостей ([]) соответствует «монтированию» компонента, то есть его добавлению на экран.
  • В режиме строгости React монтирует компоненты дважды (только в режиме разработки!), чтобы подвергнуть эффекты стрессовому тестированию.
  • Если ваш эффект выходит из строя из-за повторного монтирования, вам необходимо реализовать функцию очистки.
  • React вызовет вашу функцию очистки перед тем, как эффект выполнится в следующий раз, и во время удаления.

Попробуйте выполнить некоторые задания

Задание 1 из 4:
Фокусировка поля при монтировании

В этом примере форма рендерит компонент <MyInput />.

Используйте метод focus() элемента ввода, чтобы поле MyInput автоматически фокусировалось при появлении на экране. Уже существует реализация в комментариях, но она не работает должным образом. Выясните, почему она не работает, и исправьте ее. (Если вы знакомы с атрибутом autoFocus, забудьте о нем: мы реализуем ту же функциональность с нуля.)

import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  // TODO: This doesn't quite work. Fix it.
  // ref.current.focus()    

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}

Чтобы проверить, что ваше решение работает, нажмите «Показать форму» и убедитесь, что ввод получает фокус (выделяется и курсор находится внутри). Нажмите «Скрыть форму» и снова «Показать форму». Убедитесь, что ввод снова получает фокус.

MyInput должен фокусироваться только при монтировании, а не после каждого рендера. Чтобы убедиться, что поведение правильное, нажмите «Показать форму», а затем неоднократно нажмите на флажок «Сделать заглавными». Нажатие на флажок не должно фокусировать поле ввода выше него.

© 2013–present Facebook Inc.
Licensed under the Creative Commons Attribution 4.0 International Public License.
https://react.dev/learn/synchronizing-with-effects

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API