logo

鱼肚的博客

Don't Repeat Yourself

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,这里简单介绍下它的用法:

  1. useEffect换成useOutdatedEffect
  2. useEffect中的callback函数增加两个参数:outdatedunmounted,均为函数,分别返回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仓库