产生的原因

在React中产生竞态请求的原因,主要来自于Promise的本质和React的生命周期

现象

useEffect中发送fetch请求,当请求未结束时,我们切换了React的组件展示状态,页面会被切换,但是请求并没有结束,此时我们看到的是新的页面,但是但请求结束之后,旧的请求数据会覆盖到新的页面上。
例如一个有两个Tab的列表,两个列表UI不同,但是数据源都依赖一个 list state, 切换Tab时页面会发生改变,接口会根据不同的Tab传递不同的参数,同时返回的数据也是不一样的,当我们处于TabA时,快速的切换到TabB,接口请求TabB对应的数据,再快速切换到TabA,此时TabB的接口还未结束请求,发送TabA的请求,TabA请求返回,页面渲染TabA的数据,TabB请求返回,覆盖TabA的数据,会造成页面是TabA,但是数据确实TabB的数据。
这种情况是超出我们的预期的,且行为是不受控制的。

处理

如果是在 React中,有几种处理这种竞态请求问题的方式

状态设计&强制卸载

在组件设计之处就要考虑到竞态请求的情况,将接口和状态封装在组件内部,这里的组件就类似于我们上面的 TabATabB,当切换Tab时强制卸载掉组件

const TabA = () => {
	const [data, setData] = useState([]);
	useEffect(() => {
		fetch(api).then(() => setData())
	}, []);
}
 
const TabB = () => {
	const [data, setData] = useState([]);
	useEffect(() => {
		fetch(api).then(() => setData())
	}, []);
}
 
const Tabs = () => {
	const [key, setKey] = useState(1);
 
	return (
		<div>
			{key === 1 && <TabA />}
			{key === 2 && <TabB />}
		</div>
	)
 
}

这样可以保证Tab在切换时,会卸载掉无用的组件,避免竞态请求造成的情况发生,但是受限于业务功能,且对性能会有一定的影响。

请求判断拦截

第二种方法是对请求成功后添加判断的方式,来决定是不是当前组件所对应的数据

const Tabs = () => {
	const ref = useRef(id);
	const [key, setKey] = useState(1);
	const [data, setData] = useState([]);
 
	useEffect(() => {
		// 更新ref为最后一次的ID
	    ref.current = id;
 
		fetch(`/some-data-url/${id}`)
	      .then((r) => r.json())
	      .then((r) => {
	        // 比较最新的ID和结果
	        // 仅在结果属于该ID时更新状态
	        if (ref.current === r.id) {
	          setData(r);
	        }
      });
	}, []);
 
	return (
		<Tabs value={key}>
			<TabA data={data} />
			<TabB data={data} />
		</Tabs>
	)
 
}

上面的方案是根据请求参数的Id来判断,同样在没有ID的时候,我们还可以对比请求的Url或者自定义ID来进行判断

const Page = ({ id }) => {
  // create ref
  const ref = useRef(id);
  useEffect(() => {
    // update ref value with the latest url
    ref.current = url;
    fetch(`/some-data-url/${id}`)
      .then((result) => {
        // compare the latest url with the result's url
        // only update state if the result actually belongs to that url
        if (result.url === ref.current) {
          result.json().then((r) => {
            setData(r);
          });
        }
 
  }, [url]);
}

利用 useEffect destroy 函数修改状态

useEffect(() => {
    // active默认为true
    let isActive = true;
    fetch(`/some-data-url/${id}`)
      .then((r) => r.json())
      .then((r) => {
        // 只有active为true时才更新状态
        if (isActive) {
          setData(r);
        }
      });
    return () => {
      // 在下次重新渲染之前,将这个闭包设置为不活动
      isActive = false;
    }
  }, [id]);

使用 AbortController 中断请求

AbortController

AbortController 接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求。
你可以使用 AbortController.AbortController() 构造函数创建一个新的 AbortController。使用 AbortSignal 对象可以完成与 DOM 请求的通信。

fetch中如下使用

useEffect(() => {
	const controller = new AbortController(); 
	fetch("https://mdn.github.io/dom-examples/abort-api/sintel.mp4", { 
		// fetch配置中仅需把signal指向AbortController实例的signal即可 
		signal: controller.signal
	});
	return () => {
		controller.abort();
	} 
}, []);

axios 中使用

useEffect(() => {
	const controller = new AbortController();
	
	axios.get('/api/foo', { signal: controller.signal });
 
	return () => {
		controller.abort();
	}
}, []);

注意

Axios最开始使用 cancalToken 来实现取消请求的功能,但是在 v0.22 版本之后官方就不再建议使用cancelToken了,推荐与fetch一样使用 AbortController

useEffect(() => {
	const source = axios.CancelToken.source();
	axios.get('/api/foo', { cancelToken: source.token })
 
	return () => {
		source.cancel();
	}
}, []);

参考