Компонент React иногда отрисовывается дважды с неизменным состоянием

Я использую Redux для подписки на магазин и обновления компонента.

Это упрощенный пример без Redux. Он использует магазин макетов для подписки и отправки.

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

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

Редактировать 2: добавлена ​​еще более краткая демонстрация в разделе «Обновление 2».

const {useState, useEffect} = React;

let counter = 0;

const createStore = () => {
	const listeners = [];
	
	const subscribe = (fn) => {
		listeners.push(fn);
		return () => {
			listeners.splice(listeners.indexOf(fn), 1);
		};
	}
	
	const dispatch = () => {
		listeners.forEach(fn => fn());
	};
	
	return {dispatch, subscribe};
};

const store = createStore();

function Test() {
	const [yes, setYes] = useState('yes');
	
	useEffect(() => {
		return store.subscribe(() => {
			setYes('yes');
		});
	}, []);
	
	console.log(`Rendered ${++counter}`);
	
	return (
		<div>
			<h1>{yes}</h1>
			<button onClick={() => {
				setYes(yes === 'yes' ? 'no' : 'yes');
			}}>Toggle</button>
			<button onClick={() => {
				store.dispatch();
			}}>Set to Yes</button>
		</div>
	);
}

ReactDOM.render(<Test />, document.getElementById('root'));
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

Что происходит

  1. ✅ Нажмите «Установить на Да». Поскольку значение yes уже равно «да», состояние не изменилось, следовательно, компонент не визуализируется повторно.
  2. ✅ Нажмите «Переключить». yes установлен на «нет». Состояние изменилось, поэтому компонент повторно отрисован.
  3. ✅ Нажмите «Установить на Да». yes установлено на «да». Состояние снова изменилось, поэтому компонент повторно отрисовывается.
  4. ⛔ Снова нажмите «Установить на Да». Состояние не изменилось, но компонент все еще обрабатывается повторно.
  5. ✅ Последующие нажатия на «Установить на Да» не вызывают ожидаемого повторного рендеринга.

Что должно произойти

На шаге 4 компонент не должен быть повторно отрисован, так как состояние не изменилось.

Обновлять

Как указано в React docs, useEffect

подходит для многих распространенных побочных эффектов, таких как настройка подписок и обработчиков событий ...

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

В этом примере мы вызываем функцию внутри useEffect один раз при первой визуализации компонента, передавая ей пустой массив []. Функция настраивает прослушиватели событий для изменения состояния в сети.

Предположим, в интерфейсе приложения у нас также есть кнопка для ручного переключения онлайн-состояния.

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

const {useState, useEffect} = React;

let counter = 0;

function Test() {
	const [online, setOnline] = useState(true);
	
	useEffect(() => {
		const onOnline = () => {
			setOnline(true);
		};
		const onOffline = () => {
			setOnline(false);
		};
		window.addEventListener('online', onOnline);
		window.addEventListener('offline', onOffline);
		
		return () => {
			window.removeEventListener('online', onOnline);
			window.removeEventListener('offline', onOffline);
		}
	}, []);
	
	console.log(`Rendered ${++counter}`);
	
	return (
		<div>
			<h1>{online ? 'Online' : 'Offline'}</h1>
			<button onClick={() => {
				setOnline(!online);
			}}>Toggle</button>
		</div>
	);
}

ReactDOM.render(<Test />, document.getElementById('root'));
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

Что происходит

  1. ✅ Компонент сначала отображается на экране, а сообщение регистрируется в консоли.
  2. ✅ Нажмите «Переключить». online установлен на false. Состояние изменилось, поэтому компонент повторно отрисован.
  3. ⛔ Откройте инструменты разработчика и на панели «Сеть» переключитесь в «офлайн». online уже был false, поэтому состояние не изменилось, но компонент все еще обрабатывается повторно.

Что должно произойти

На шаге 3 компонент не должен быть повторно отрисован, так как состояние не изменилось.

Обновление 2

const {useState, useEffect} = React;

let counterRenderComplete = 0;
let counterRenderStart = 0;


function Test() {
  const [yes, setYes] = useState('yes');

  console.log(`Component function called ${++counterRenderComplete}`);
  
  useEffect(() => console.log(`Render completed ${++counterRenderStart}`));

  return (
    <div>
      <h1>{yes ? 'yes' : 'no'}</h1>
      <button onClick={() => {
        setYes(!yes);
      }}>Toggle</button>
      <button onClick={() => {
        setYes('yes');
      }}>Set to Yes</button>
    </div>
  );
}

ReactDOM.render(<Test />, document.getElementById('root'));

 
<div id="root"></div>
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>

Что происходит

  1. ✅ Нажмите «Установить на Да». Поскольку значение yes уже равно true, состояние не изменилось, поэтому компонент не визуализируется повторно.
  2. ✅ Нажмите «Переключить». yes установлен на false. Состояние изменилось, поэтому компонент повторно отрисован.
  3. ✅ Нажмите «Установить на Да». yes установлен на true. Состояние снова изменилось, поэтому компонент повторно отрисовывается.
  4. ⛔ Снова нажмите «Установить на Да». Состояние не изменилось, несмотря на то, что компонент запускает процесс отрисовки, вызывая функцию. Тем не менее, React отказывается от рендеринга где-то в середине процесса, и эффекты не вызываются.
  5. ✅ Последующие нажатия на «Установить в Да» не вызывают повторного рендеринга (вызовов функций), как ожидалось.

Вопрос

Почему компонент все еще перерисовывается? Я что-то делаю не так или это ошибка React?


person Tigran    schedule 27.03.2020    source источник
comment
Я считаю, что избегание ненужных повторных отрисовок после вызова setState с тем же значением - это просто оптимизация производительности, а не семантическая гарантия ... Я не видел исходный код, но первым делом я предполагаю, что откажусь, если setState вызывается из стека вызовов функций, вложенных в useEffect (но это может быть совершенно другая причина)   -  person Aprillion    schedule 27.03.2020


Ответы (2)


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

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

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

person Dan Abramov    schedule 29.03.2020
comment
Спасибо. Это в значительной степени ответило на мой вопрос. Немного странно, что в этом случае функциональные компоненты ведут себя иначе, чем компоненты класса. Для компонента чистого класса метод рендеринга никогда не вызывается, если состояние не изменилось. Запоминание функционального компонента с помощью memo () будет проверять только изменения свойств, но не состояние. Итак, насколько я понимаю, в настоящее время нет способа сделать функциональный компонент чистым как для свойств, так и для состояния. Это, как вы отметили, не должно вызывать беспокойства, поскольку мы должны избегать побочных эффектов и тяжелых вычислений во время вызова функции. - person Tigran; 30.03.2020

Похоже, это ожидаемое поведение.

Из React docs:

Спасение от обновления состояния

Если вы обновите State Hook до того же значения, что и текущее состояние, React выйдет из строя без рендеринга дочерних элементов или эффектов срабатывания. (React использует Object.is алгоритм сравнения < / а>.)

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

Итак, React выполняет повторный рендеринг компонента на шаге 4 первой демонстрации и шаге 3 второй. Следовательно, он выполняет весь код внутри функции и вызывает React.createElement() для каждого дочернего элемента компонента.

Однако он не отображает потомков компонента и не запускает эффекты.

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

Мы ничего не можем сделать, чтобы избежать повторного запуска. Запоминание функции с помощью memo() тоже не поможет, поскольку проверяет только изменения свойств, а не государство. Так что мы просто должны принять во внимание эту ситуацию.

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

person Tigran    schedule 28.03.2020
comment
@YevgenGorbunkov, это именно то, что происходит. Я добавил в вопрос третий пример, чтобы продемонстрировать это. Щелчок просто вызывает setState(true), и сообщения рендеринга действительно появляются, как и раньше, даже если состояние уже было истинным. - person Tigran; 29.03.2020