Когда я использую компонент класса, у меня есть код:
setTimeout(() => console.info(this.state.count), 5000);
Когда я использую хук:
const [count, setCount] = useState(0);
setTimeout(() => console.info(count), 5000);
Если я активирую setTimeout
, то изменю count
на 1 до истечения времени ожидания (5000ms
), компонент класса будет console.info(1)
(самое новое значение), а для useState
это console.info(0)
(значение при тайм-ауте регистрации).
Почему это происходит?
Для useState
он создает тайм-аут, используя count
в первый раз. Он получает доступ к значению count
через closure
. Когда мы устанавливаем новое значение с помощью setCount
, компонент перерисовывается, но не изменяет значение, переданное таймауту.
Мы можем использовать const count = useRef(0)
и перейти к таймауту count.current
. При этом всегда будет использоваться новейшее значение count.
Перейдите по этой ссылке для получения дополнительной информации.
Тайм-ауты не очень хорошо сочетаются с реакцией модели декларативного программирования. В функциональных компонентах каждый рендер представляет собой один кадр во времени. Они никогда не меняются. При обновлении состояния все переменные состояния создаются локально заново и не перезаписывают старые закрытые переменные.
Вы также можете думать об эффектах таким же образом, когда эффект будет работать в своей локальной области со всеми его локальными переменными состояния при каждом рендеринге, а новые рендеры не влияют на их вывод.
Единственный способ вырваться из этой модели — реф. Или компоненты класса, где состояние эффективно похоже на ссылки, где экземпляр (this
) является контейнером ссылки. Рефы позволяют осуществлять кросс-рендеринг и блокировку замыканий. Используйте экономно и с осторожностью.
У Дэна Абрамова есть фантастическая статья, объясняющая все это и хук, который решает эту проблему. Как вы правильно ответили, проблема вызвана устаревшим закрытием. Решение действительно включает использование refs.
Обновленная версия:
Вопрос: Разница в поведении переменной React State внутри setTimeout
/setInterval
для функций и компонентов класса?
Случай 1: переменная состояния в функциональном компоненте (устаревшее закрытие):
const [value, setValue] = useState(0)
useEffect(() => {
const id = setInterval(() => {
// It will always print 0 even after we have changed the state (value)
// Reason: setInterval will create a closure with initial value i.e. 0
console.info(value)
}, 1000)
return () => {
clearInterval(id)
}
}, [])
Случай 2: переменная состояния в компоненте класса (без устаревшего закрытия):
constructor(props) {
super(props)
this.state = {
value: 0,
}
}
componentDidMount() {
this.id = setInterval(() => {
// It will always print current value from state
// Reason: setInterval will not create closure around "this"
// as "this" is a special object (refernce to instance)
console.info(this.state.value)
}, 1000)
}
Случай 3: Давайте попробуем создать устаревшее замыкание вокруг this
// Attempt 1
componentDidMount() {
const that = this // create a local variable so that setInterval can create closure
this.id = setInterval(() => {
console.info(that.state.value)
// This, too, always print current value from state
// Reason: setInterval could not create closure around "that"
// Conclusion: Oh! that is just a reference to this (attempt failed)
}, 1000)
}
Случай 4: Давайте снова попробуем создать устаревшее замыкание в компоненте класса
// Attempt 2
componentDidMount() {
const that = { ...this } // create a local variable so that setInterval can create closure
this.id = setInterval(() => {
console.info(that.state.value)
// Great! This always prints 0 i.e. the initial value from state
// Reason: setInterval could create closure around "that"
// Conclusion: It did it because that no longer is a reference to this,
// it is just a new local variable which setInterval can close around
// (attempt successful)
}, 1000)
}
Случай 5: Давайте снова попробуем создать устаревшее замыкание в компоненте класса
// Attempt 3
componentDidMount() {
const { value } = this.state // create a local variable so that setInterval can create closure
this.id = setInterval(() => {
console.info(value)
// Great! This always prints 0 i.e. the initial value from state
// Reason: setInterval created closure around value
// Conclusion: It is easy! value is just a local variable so it will be closed
// (attempt successful)
}, 1000)
}
Случай 6: класс выиграл (без дополнительных усилий, чтобы избежать устаревшего закрытия). Но как избежать этого в функциональном компоненте?
// Let's find solution
const value = useRef(0)
useEffect(() => {
const id = setInterval(() => {
// It will always print the latest ref value
// Reason: We used ref which gives us something like an instance field.
// Conclusion: So, using ref is a solution
console.info(value.current)
}, 1000)
return () => {
clearInterval(id)
}
}, [])
Случай 6: Давайте найдем другое решение для функциональных компонентов
useEffect(() => {
const id = setInterval(() => {
// It will always print the latest state value
// Reason: We used updater form of setState (which provides us latest state value)
// Conclusion: So, using updater form of setState is a solution
setValue((prevValue) => {
console.info(prevValue)
return prevValue
})
}, 1000)
return () => {
clearInterval(id)
}
}, [])
Оригинальная версия:
Проблема вызвана замыканиями и может быть устранена с помощью ref
. Но вот обходной путь, чтобы исправить это, то есть получить доступ к последнему значению state
, используя форму «обновления» setState
:
function App() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
setTimeout(() => console.info('count after 5 secs: ', count, 'Wrong'), 5000)
}, [])
React.useEffect(() => {
setTimeout(() => {
let count
setCount(p => {
console.info('p: ', p)
count = p
return p
})
console.info('count after 5 secs: ', count, 'Correct')
}, 5000);
}, [])
return (<div>
<button onClick = {() => setCount(p => p+1)}>Click me before 5 secs</button>
<div>Latest count: {count}</div>
</div>)
}
ReactDOM.render(<App />, document.getElementById('mydiv'))
<script crossorigin src = "https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src = "https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<body>
<div id = "mydiv"></div>
</body>
@deckele setState(prevValue => prevValue)
не вызовет повторного рендеринга, потому что возвращается то же значение. React сделает: должен отобразить: Object.is(oldValue, newValue)
=> false
. Так что ререндера не будет, если мы это сделаем setCount(p => { // do_something; return p})
В документах говорится, что дополнительных рендеров не будет, если результат setState такой же, как и предыдущее значение. Однако это не совсем правильно, вся функция перерисовывается во второй раз, и только потом выручает от отрисовки своих дочерних элементов: github.com/facebook/react/issues/14994 Тем не менее, вы правы в своем примере что использование setState безопасно внутри useEffect. Но использование этого метода снаружи в рендере вызовет бесконечный цикл. Даже несмотря на то, что текущее значение идентично предыдущему.
@deckele хорошо. Может быть. Я не читал документы, когда сказал это - there will no re-render if we do setCount(p => { // do_something; return p})
. Я протестировал его локально, а затем подумал о его написании. Я не знаю, что такое правда. В любом случае, примеры, предоставленные мной, предназначены для понимания цели (редко они будут реальным случаем). Вдобавок к этому, вероятно, нам меньше нужно беспокоиться о бесконечном повторном рендеринге с использованием setTimeout
.
@deckele Я только что увидел ваше редактирование: But using this method outside in render will cause an infinite loop. Even though current value is identical to the previous one
: И я проверил это, да, вы правы. Я никогда не знал этого, потому что я никогда не писал такой код, но вы правы :) Спасибо!
С функциональными компонентами каждый рендеринг является вызовом функции, создавая новое закрытие функции для этого конкретного вызова. Функциональный компонент закрывается функцией обратного вызова setTimeout, так что все в обратном вызове setTimeout обращается только к конкретному рендерингу, где он был вызван.
Использование Ref и доступ к нему только в рамках обратного вызова setTimeout даст вам значение, которое сохраняется между рендерингами.
Однако не так удобно использовать React Ref со значением, которое всегда обновляется, например счетчик. Вы отвечаете как за обновление значения, так и за повторный рендеринг. Обновление Ref не влечет за собой визуализацию компонента.
Мое решение для простоты использования состоит в том, чтобы объединить хуки useState и useRef в один хук «useStateAndRef». Таким образом, вы получаете сеттер, который получает как значение, так и ссылку для использования в асинхронных ситуациях, таких как setTimeout и setInterval:
import { useState, useRef } from "react";
function useStateAndRef(initial) {
const [value, setValue] = useState(initial);
const valueRef = useRef(value);
valueRef.current = value;
return [value, setValue, valueRef];
}
export default function App() {
const [count, setCount, countRef] = useStateAndRef(0);
function logCountAsync() {
setTimeout(() => {
const currentCount = countRef.current;
console.info(`count: ${count}, currentCount: ${currentCount}`);
}, 2000);
}
return (
<div className = "App">
<h1>useState with updated value</h1>
<h2>count: {count}</h2>
<button onClick = {() => setCount(prev => prev + 1)}>+</button>
<button onClick = {logCountAsync}>log count async</button>
</div>
);
}
Ссылка на рабочую среду CodeSandbox: https://codesandbox.io/s/set-timeout-with-hooks-fdngm?file=/src/App.tsx
Это интересное решение. Недостатком является то, что использование сеттера для получения последнего значения также вызывает повторную визуализацию, поэтому весь побочный эффект setTimeout вызывает ненужную визуализацию. Это может привести к бесконечному циклу в некоторых обстоятельствах, например. если useEffect зависит от измененного счетчика.