React:类组件转Hook后状态异常,test为何始终为false?
类组件代码及运行表现
export default class myComponent extends Component { constructor(props) { super(props); this.state = { test: false }; } componentDidMount() { this.fetchApi(); } fetchApi = () => { console.log("fetchApi: " + this.state.test); this.setState({ test: !this.state.test }, () => console.log("useEffect: " + this.state.test)); } render() { return <div> <label>{"test: " + this.state.test}</label> <Refresher refresh={this.fetchApi.bind(this)} /> </div> } }
运行时页面上的test值每次刷新都会在true和false间切换,控制台输出:
fetchApi: false useEffect: true fetchApi: true useEffect: false fetchApi: false useEffect: true
Hook代码及运行表现
export default function myHook(props) { const [test, setTest] = useState(false); let TEST = false; useEffect(() => fetchApi(), []); useEffect(() => console.log("useEffect: " + test), [test]); const fetchApi = () => { console.log("fetchApi: test = " + test); console.log("fetchApi: TEST = " + TEST); setTest(!test); TEST = !TEST; } return <div> <label>{"test: " + test}</label> <Refresher refresh={fetchApi} /> </div> }
运行时页面上的test值始终停留在true,控制台输出:
fetchApi: test = false fetchApi: TEST = false useEffect: false useEffect: true fetchApi: test = false fetchApi: TEST = true fetchApi: test = false fetchApi: TEST = false
问题
为何Hook中的test变量在fetchApi里始终为false?
补充说明
Refresher组件代码
export default function Refresher(props) { const [value, setValue] = useState(0); let time = 0; let timer = 1; useEffect( () => { timer = props.refreshTimer ?? 5; let currentTime = timer > 0 && setInterval(secondEllapsed, 1000); return () => clearInterval(currentTime) }, []); const secondEllapsed = () => { setValue(++time / timer); if (time % timer) return; props.refresh(); } return <>value</>; }
讨论记录
- @Andrey Smolko:控制台输出正确,但按钮测试时情况一致,现附上Refresher组件代码
- @Victor L.:给出的解决方案有效,这和@Slbox提到的依赖项问题、@Konrad提到的陈旧引用是同一回事吗?
- @Slbox:@Victor L.给出的代码也存在同样的问题吗?
- @Konrad:好思路,这意味着要结合@Victor L.的方案?
问题原因及解决方案
核心原因:闭包导致的陈旧引用
Hook组件里,fetchApi是初始渲染时创建的函数,它捕获了当时的test状态(false)。而Refresher组件的useEffect依赖是空数组,只会在挂载时执行一次,保存的是初始版本的fetchApi引用。后续每次调用这个fetchApi,它访问的始终是初始的test值,所以setTest(!test)每次都是设置为true,页面状态就停在了true,控制台打印的test自然一直是false。
另外你定义的let TEST = false是组件函数内的普通变量,每次组件重新渲染都会被重新初始化,根本没法保存状态变化,不能替代React的状态管理。
解决方案
方案1:用函数式更新获取最新状态
当状态更新依赖当前状态时,使用setTest的函数式写法,直接拿到最新的状态值,不受闭包影响:
const fetchApi = () => { console.log("fetchApi: test = " + test); // 函数式更新,prevTest是当前最新状态 setTest(prevTest => { const newTest = !prevTest; console.log("useEffect: " + newTest); return newTest; }); }
方案2:让Refresher获取最新的fetchApi引用
用useCallback包裹fetchApi并添加依赖,同时修改Refresher的useEffect依赖,确保定时器能拿到最新的refresh函数:
修改Hook组件:
export default function myHook(props) { const [test, setTest] = useState(false); // useCallback包裹,依赖test确保函数更新 const fetchApi = useCallback(() => { console.log("fetchApi: test = " + test); setTest(!test); }, [test]); useEffect(() => fetchApi(), []); useEffect(() => console.log("useEffect: " + test), [test]); return <div> <label>{"test: " + test}</label> <Refresher refresh={fetchApi} /> </div> }
修改Refresher组件:
export default function Refresher(props) { const [value, setValue] = useState(0); let time = 0; useEffect( () => { const timer = props.refreshTimer ?? 5; time = 0; // 每次重新创建定时器时重置时间 const currentTime = timer > 0 && setInterval(secondEllapsed, 1000); return () => clearInterval(currentTime) }, [props.refresh, props.refreshTimer] // 添加依赖,函数或定时器参数变化时重建定时器 ); const secondEllapsed = () => { setValue(++time / timer); if (time % timer !== 0) return; props.refresh(); } return <>value</>; }
方案3:用useRef保存最新函数引用(避免频繁重建定时器)
如果不想每次test变化都重建定时器,可以用useRef保存fetchApi的最新引用,让定时器始终调用最新的函数:
修改Hook组件:
export default function myHook(props) { const [test, setTest] = useState(false); // 用ref保存最新的fetchApi const fetchApiRef = useRef(); const fetchApi = () => { console.log("fetchApi: test = " + test); setTest(!test); }; // 每次渲染更新ref的current为最新的fetchApi useEffect(() => { fetchApiRef.current = fetchApi; }, [test]); // 封装一个固定引用的函数,内部调用最新的fetchApi const wrappedFetchApi = useCallback(() => { fetchApiRef.current(); }, []); useEffect(() => wrappedFetchApi(), []); useEffect(() => console.log("useEffect: " + test), [test]); return <div> <label>{"test: " + test}</label> <Refresher refresh={wrappedFetchApi} /> </div> }
这种方式下Refresher组件不需要修改,因为wrappedFetchApi的引用不会变,它总能通过fetchApiRef.current拿到最新的fetchApi。
内容的提问来源于stack exchange,提问作者ypelissier

