React自定义hooks useOutdatedEffect 介绍
前言
React中有一个常见的问题,数据获取之后,组件已经销毁,此时会有这样一段警告:
Can't perform a React state update on an unmounted component
相应地,也有一些常见的解决方案,如:
1useEffect(() => { 2 let isMounted = true; // note mutable flag 3 someAsyncOperation().then(data => { 4 if (isMounted) setState(data); // add conditional check 5 }) 6 return () => { isMounted = false }; // use cleanup to toggle value, if unmounted 7}, []); // adjust dependencies to your needs
但是却鲜少有人提及另外一种场景,useEffect异步操作之后,虽然组件仍然处于挂载状态,但是当初的条件已经发生了改变,这种情况下容易出现bug。
问题
举例来说,对于如下的代码:
1import React, { FC, useState, useEffect } from 'react' 2import axios from 'axios' 3 4const App = (props) => { 5 const { id } = props 6 const [dataSource, setDataSource] = useState(null) 7 useEffect(() => { 8 const fetchData = async () => { 9 if (id) { 10 const { data } = await axios.get(`/api/mydata/${id}`) 11 setDataSource(data) 12 } else { 13 setDataSource(null) 14 } 15 } 16 }, [id]) 17}
当id
有值的时候,这个组件的useEffect
会先请求数据,再设置状态(setDataSource),在id
为空的情况下,则会直接清空dataSource
。
但是因为id
不为空时,effect代码是异步代码(axios.get
),而在id
为空时,则是一个简单的同步代码。
那么就有可能出现这样的问题:假如id先有值,后清空,那么第二次useEffect
(无数据获取)会更快执行完,而第一次useEffect
则执行较慢一些,导致最后dataSource
是有值的,反映的是第一次useEffect
对应的老数据。
解决方案
为了解决这个问题,我提取了一个公共的npm包:use-outdated-effect
,这里简单介绍下它的用法:
- 将
useEffect
换成useOutdatedEffect
- 为
useEffect
中的callback函数增加两个参数:outdated
和unmounted
,均为函数,分别返回effect dependencies和组件装载状态的当前情况。
对于上面的问题,可以用如下的方式改进:
1const App = (props) => { 2 const { id } = props 3 const [dataSource, setDataSource] = useState(null) 4 useOutdatedEffect((outdated, unmounted) => { 5 const fetchData = async () => { 6 const { data } = await axios.get(`/api/mydata/${id}`) 7 if (outdated()) { // check whether dependencies changed. In this example, it's the id variable 8 // id changed, stop the current operations 9 return 10 } 11 12 if (unmounted()) { // check whether component is unmounted 13 // component destroied, stop the current operations 14 return 15 } 16 17 setDataSource(data) 18 } 19 20 }, [id]) 21 22}
实现原理
在自定义hooks的内部使用useRef
维护一个 myRef 的变量,每当dependencies发生变化,自动设置myRef.current
为一个新的值。
因为多个useEffect
中的代码同时生效时是按顺序执行的,所以可以在自定义hooks中使用两个useEffect
,第一个改myRef.current
的值,第二个先记录myRef.current
的旧值,在异步逻辑完成之后,再重新判断一次获取最新值,只有校验通过之后,才继续进行下一步。
关键代码:
1const useAsyncEffect = ( 2 effect: (outdated: () => boolean, unmounted: () => boolean, ) => ReturnType<EffectCallback>, 3 inputs?: DependencyList, 4) => { 5 const asyncFlag = useRef<number>(0); 6 7 useEffect(() => { asyncFlag.current += 1 }, inputs); 8 9 useEffect(function () { 10 let result: ReturnType<EffectCallback> | undefined; 11 let unmounted = false; 12 const innerAsyncFlag = asyncFlag.current; 13 const maybePromise = effect( 14 () => asyncFlag.current !== innerAsyncFlag, 15 () => unmounted, 16 ); 17 18 Promise.resolve(maybePromise).then((value) => { result = value }); 19 20 return () => { unmounted = true; result && result?.() }; 21 }, inputs); 22};
安装使用
1npm i use-outdated-effect
1useOutdatedEffect((outdated, unmounted) => { 2 const fetchData = async () => { 3 const { data } = await axios.get(`/api/mydata/${id}`) 4 if (outdated()) { // check whether dependencies changed. In this example, it's the id variable 5 // id changed, stop the current operations 6 return 7 } 8 9 if (unmounted()) { // check whether component is unmounted 10 // component destroied, stop the current operations 11 return 12 } 13 setDataSource(data) 14 } 15 16 }, [id])
更多
更多细节,参考Github仓库。