React useEffect引起的问题:无法在未挂载的组件上执行React状态更新。

185
当获取数据时,我收到以下警告:无法在未挂载的组件上执行React状态更新。 应用程序仍然可以工作,但是React建议我可能会导致内存泄漏。

这是一个空操作,但它指示您的应用程序存在内存泄漏。 要修复,请在useEffect cleanup函数中取消所有订阅和异步任务。

为什么我一直收到这个警告?

我尝试研究了以下解决方案:

https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal

https://developer.mozilla.org/en-US/docs/Web/API/AbortController

但是这仍然给我警告。

const  ArtistProfile = props => {
  const [artistData, setArtistData] = useState(null)
  const token = props.spotifyAPI.user_token

  const fetchData = () => {
    const id = window.location.pathname.split("/").pop()
    console.log(id)
    props.spotifyAPI.getArtistProfile(id, ["album"], "US", 10)
    .then(data => {setArtistData(data)})
  }
  useEffect(() => {
    fetchData()
    return () => { props.spotifyAPI.cancelRequest() }
  }, [])
  
  return (
    <ArtistProfileContainer>
      <AlbumContainer>
        {artistData ? artistData.artistAlbums.items.map(album => {
          return (
            <AlbumTag
              image={album.images[0].url}
              name={album.name}
              artists={album.artists}
              key={album.id}
            />
          )
        })
        : null}
      </AlbumContainer>
    </ArtistProfileContainer>
  )
}

编辑:

在我的api文件中,我添加了一个AbortController()并使用了一个signal,这样我就可以取消请求。

export function spotifyAPI() {
  const controller = new AbortController()
  const signal = controller.signal

// code ...

  this.getArtist = (id) => {
    return (
      fetch(
        `https://api.spotify.com/v1/artists/${id}`, {
        headers: {"Authorization": "Bearer " + this.user_token}
      }, {signal})
      .then(response => {
        return checkServerStat(response.status, response.json())
      })
    )
  }

  // code ...

  // this is my cancel method
  this.cancelRequest = () => controller.abort()
}

我的spotify.getArtistProfile()看起来像这样

this.getArtistProfile = (id,includeGroups,market,limit,offset) => {
  return Promise.all([
    this.getArtist(id),
    this.getArtistAlbums(id,includeGroups,market,limit,offset),
    this.getArtistTopTracks(id,market)
  ])
  .then(response => {
    return ({
      artist: response[0],
      artistAlbums: response[1],
      artistTopTracks: response[2]
    })
  })
}

但是因为我的信号用于解决在Promise.all中的单个api调用,所以我无法abort()该promise,因此我将始终设置状态。


2
警告的原因是 Promise getArtistProfile()在组件卸载后解决。取消该请求,或者如果不可能,则在.then()处理程序中添加检查,以便在组件已卸载时不调用setArtistData() - ՕլՁՅԿ
如果不了解组件之外的应用程序的更多信息,就无法解释为什么会发生这种情况。 我们需要知道是什么导致该组件装载/卸载。 当您遇到错误时,应用程序中正在发生什么? - Ryan Cogswell
@ııı 我该如何检查组件是否已卸载? - Ryan Sam
这不是真正的内存泄漏,但很可能是一个虚假警告 - 这就是为什么React团队将在下一个版本中删除该警告的原因。请参见[PR](https://github.com/facebook/react/pull/22114) - Katie
19个回答

137

对我来说,在组件卸载时清除状态有所帮助。

 const [state, setState] = useState({});

useEffect(() => {
    myFunction();
    return () => {
      setState({}); // This worked for me
    };
}, []);

const myFunction = () => {
    setState({
        name: 'Jhon',
        surname: 'Doe',
    })
}


44
我不理解其背后的逻辑,但它有效。 - Getzel
7
哦,我明白了。在 useEffect 中的回调函数仅在组件卸载时执行。这就是为什么我们可以在组件卸载之前访问状态的 namesurname 属性的原因。 - entithat
17
当你从 useEffect 中返回一个函数时,这个函数将在组件卸载时执行。因此,利用这一点,你可以将状态设置为空。通过这样做,无论何时你离开该屏幕或组件卸载,状态都将为空,因此屏幕的组件不会再次尝试重新渲染。希望这有所帮助。 - Joseph Ajibodu
1
谢谢,不知道为什么但是这个有效了。你说在卸载时清理,这给了我一些提示。我会尝试学习 useEffect。 - CrackerKSR
2
在许多情况下,只需在useEffect()中返回一个空函数,就可以让React认为您正在清除任何副作用,从而修复卸载组件错误。 - John Zenith
显示剩余4条评论

56
分享AbortControllerfetch()请求之间是正确的方法。
任何一个Promise被中止时,Promise.all()将会用AbortError拒绝:

function Component(props) {
  const [fetched, setFetched] = React.useState(false);
  React.useEffect(() => {
    const ac = new AbortController();
    Promise.all([
      fetch('http://placekitten.com/1000/1000', {signal: ac.signal}),
      fetch('http://placekitten.com/2000/2000', {signal: ac.signal})
    ]).then(() => setFetched(true))
      .catch(ex => console.error(ex));
    return () => ac.abort(); // Abort both fetches on unmount
  }, []);
  return fetched;
}
const main = document.querySelector('main');
ReactDOM.render(React.createElement(Component), main);
setTimeout(() => ReactDOM.unmountComponentAtNode(main), 1); // Unmount after 1ms
<script src="//cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.development.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.development.js"></script>
<main></main>


53
例如,您有一些组件执行一些异步操作,然后将结果写入状态并在页面上显示状态内容:
export default function MyComponent() {
    const [loading, setLoading] = useState(false);
    const [someData, setSomeData] = useState({});
    // ...
    useEffect( () => {
        (async () => {
            setLoading(true);
            someResponse = await doVeryLongRequest(); // it takes some time
            // When request is finished:
            setSomeData(someResponse.data); // (1) write data to state
            setLoading(false); // (2) write some value to state
        })();
    }, []);

    return (
        <div className={loading ? "loading" : ""}>
            {someData}
            <Link to="SOME_LOCAL_LINK">Go away from here!</Link>
        </div>
    );
}

假设用户在doVeryLongRequest()仍在执行时点击某个链接。 MyComponent被卸载,但请求仍在执行,当它收到响应时,会尝试在(1)(2)行设置状态,并尝试更改HTML中的相应节点。我们将从主题中获得错误。

我们可以通过检查组件是否已卸载来解决此问题。让我们创建一个componentMounted引用(下面的(3)行),并将其设置为true。当组件卸载时,我们将其设置为false(下面的(4)行)。并且每次尝试设置状态时,让我们检查componentMounted变量(下面的(5)行)。

修复后的代码:

export default function MyComponent() {
    const [loading, setLoading] = useState(false);
    const [someData, setSomeData] = useState({});
    const componentMounted = useRef(true); // (3) component is mounted
    // ...
    useEffect( () => {
        (async () => {
            setLoading(true);
            someResponse = await doVeryLongRequest(); // it takes some time
            // When request is finished:
            if (componentMounted.current){ // (5) is component still mounted?
                setSomeData(someResponse.data); // (1) write data to state
                setLoading(false); // (2) write some value to state
            }
            return () => { // This code runs when component is unmounted
                componentMounted.current = false; // (4) set it to false when we leave the page
            }
        })();
    }, []);

    return (
        <div className={loading ? "loading" : ""}>
            {someData}
            <Link to="SOME_LOCAL_LINK">Go away from here!</Link>
        </div>
    );
}

2
我对这个信息不是非常有信心,但是按照那种方式设置componentMounted变量可能会触发以下警告:“在React Hook useEffect中从内部分配给'componentMounted'变量的分配将在每次渲染后丢失。为了保留值,存储在useRef Hook中,并将可变值保存在'.current'属性中。” 在这种情况下,根据这里的建议将其设置为状态可能是必要的:https://dev59.com/bFMI5IYBdhLWcg3weLRS - Bonellia
2
这是有效的,但你应该使用 useRef 钩子来存储 componentMounted 的值(可变值),或将 componentMounted 变量的声明移动到 useEffect 内部。 - Diego Lope Loyola
同意,伙计们。已修复。 - Dzmitry Kulahin
1
useEffect 需要一个异步回调函数才能在 someResponse 处使用 await 吗?useEffect(async () => {...},[]) - Luigi Minardi
1
还有一些细节,useEffect不应该带async关键字而是作为一个常规函数。异步调用应该通过在useEffect主体内部/外部定义的函数来完成,例如: useEffect(() => { const doStuff = async () => { ... }; doStuff(); ... }, []); - vicvans20
显示剩余2条评论

14

我为什么一直收到这个警告?

这个警告的目的是帮助你防止应用程序中的内存泄漏。如果组件在从DOM卸载后更新其状态,那么这是一个迹象,表明可能存在内存泄漏,但它是一个误报的迹象。

如何知道是否存在内存泄漏?

如果比组件寿命更长的对象直接或间接地引用了它,则会出现内存泄漏。当你在组件从DOM卸载时没有取消订阅事件或某些内容的更改时,通常会发生这种情况。

通常看起来像这样:

useEffect(() => {
  function handleChange() {
     setState(store.getState())
  }
  // "store" lives longer than the component, 
  // and will hold a reference to the handleChange function.
  // Preventing the component to be garbage collected after 
  // unmount.
  store.subscribe(handleChange)

  // Uncomment the line below to avoid memory leak in your component
  // return () => store.unsubscribe(handleChange)
}, [])

其中store是一个对象,它位于React树的更高层(可能在上下文提供程序中)或全局/模块范围内。另一个例子是订阅事件:

useEffect(() => {
  function handleScroll() {
     setState(window.scrollY)
  }
  // document is an object in global scope, and will hold a reference
  // to the handleScroll function, preventing garbage collection
  document.addEventListener('scroll', handleScroll)
  // Uncomment the line below to avoid memory leak in your component
  // return () => document.removeEventListener(handleScroll)
}, [])

需要记住的另一个例子是Web API setInterval,如果在卸载组件时忘记调用clearInterval,它也会导致内存泄漏。

但我并没有这样做,为什么我要关心这个警告?

React 的策略是在组件卸载后每当状态更新发生时发出警告,这会产生很多误报。最常见的情况是在异步网络请求后设置状态:

async function handleSubmit() {
  setPending(true)
  await post('/someapi') // component might unmount while we're waiting
  setPending(false)
}

从技术上讲,你可以认为这也是一种内存泄漏,因为组件在不再需要后没有立即释放。如果你的“post”需要很长时间才能完成,那么释放内存所需的时间也会很长。然而,这不是你应该担心的事情,因为它最终会被垃圾回收。在这些情况下,你可以简单地忽略这个警告

但是看到警告真的很烦人,我该如何去除它呢?

有很多博客和stackoverflow上的答案建议跟踪组件的挂载状态并将状态更新包装在if语句中:

let isMountedRef = useRef(false)
useEffect(() => {
  isMountedRef.current = true
  return () => {
    isMountedRef.current = false
  }
}, [])

async function handleSubmit() {
  setPending(true)
  await post('/someapi')
  if (!isMountedRef.current) {
    setPending(false)
  }
}
这不是一个推荐的方法! 这不仅使代码 less readable 并增加了运行时开销,而且它可能也不能很好地与 React 的未来特性配合使用它对于“内存泄漏”也没有任何作用,组件仍将像没有额外代码一样存在。

处理这个问题的推荐方式是取消异步函数(例如使用 AbortController API ),或者忽略它。

事实上,React 开发团队认识到避免 false positives 太难了,在 React v18 中已经删除了警告


只要那些带有钩子的代码块在调用它们时不是在函数组件或自定义钩子中,这种方法就是有效的。https://reactjs.org/warnings/invalid-hook-call-warning.html - Carmine Tambascia

8
你可以尝试像这样设置一个状态,并检查你的组件是否已经挂载。这样,你可以确保如果你的组件未被卸载,你不会尝试获取任何内容。
const [didMount, setDidMount] = useState(false); 

useEffect(() => {
   setDidMount(true);
   return () => setDidMount(false);
}, [])

if(!didMount) {
  return null;
}

return (
    <ArtistProfileContainer>
      <AlbumContainer>
        {artistData ? artistData.artistAlbums.items.map(album => {
          return (
            <AlbumTag
              image={album.images[0].url}
              name={album.name}
              artists={album.artists}
              key={album.id}
            />
          )
        })
        : null}
      </AlbumContainer>
    </ArtistProfileContainer>
  )

希望这能对您有所帮助。

didMount 在未挂载状态下将为 true - ՕլՁՅԿ
1
你能进一步解释一下为什么吗? - Mertcan Diken
组件挂载,然后效果运行并将 didMount 设置为 true,然后组件卸载但 didMount 从未重置。 - ՕլՁՅԿ
我认为你的意思是 setDidMount(false)。但问题在于,.then() 处理程序的闭包将引用先前的 didMount - ՕլՁՅԿ
1
这是我在我的应用程序中解决SSR问题的方法,我认为这种情况也适用。如果不行的话,我想承诺应该被取消。 - Mertcan Diken
2
错误:渲染时使用的钩子比上一次渲染时多。 - cmcodes

5

我遇到了一个与滚动到顶部相关的问题,@CalosVallejo 的回答解决了它 :) 非常感谢!!

const ScrollToTop = () => { 

  const [showScroll, setShowScroll] = useState();

//------------------ solution
  useEffect(() => {
    checkScrollTop();
    return () => {
      setShowScroll({}); // This worked for me
    };
  }, []);
//-----------------  solution

  const checkScrollTop = () => {
    setShowScroll(true);
 
  };

  const scrollTop = () => {
    window.scrollTo({ top: 0, behavior: "smooth" });
 
  };

  window.addEventListener("scroll", checkScrollTop);

  return (
    <React.Fragment>
      <div className="back-to-top">
        <h1
          className="scrollTop"
          onClick={scrollTop}
          style={{ display: showScroll }}
        >
          {" "}
          Back to top <span>&#10230; </span>
        </h1>
      </div>
    </React.Fragment>
  );
};


1
你有一个 window.addEventListener("scroll", checkScrollTop); 的渲染。 - Filip Kováč

4

我也曾经遇到过相同的警告,这个解决方案对我有用 ->

useEffect(() => {
    const unsubscribe = fetchData(); //subscribe
    return unsubscribe; //unsubscribe
}, []);

如果你有多个 fetch 函数,则:
const getData = () => {
    fetch1();
    fetch2();
    fetch3();
}

useEffect(() => {
    const unsubscribe = getData(); //subscribe
    return unsubscribe; //unsubscribe
}, []);

3

有很多答案,但我认为我可以更简单地演示abort的工作方式(至少对于我而言如何解决问题):

useEffect(() => {
  // get abortion variables
  let abortController = new AbortController();
  let aborted = abortController.signal.aborted; // true || false
  async function fetchResults() {
    let response = await fetch(`[WEBSITE LINK]`);
    let data = await response.json();
    aborted = abortController.signal.aborted; // before 'if' statement check again if aborted
    if (aborted === false) {
      // All your 'set states' inside this kind of 'if' statement
      setState(data);
    }
  }
  fetchResults();
  return () => {
    abortController.abort();
  };
}, [])

其他方法: 如何使用Hook修复React.js中的内存泄漏问题


1
这是一个正确的验证中止信号。 - sandeep.gosavi

3

在导航到其他组件后,在当前组件上执行状态更新时,会出现此错误:

例如:

  axios
      .post(API.BASE_URI + API.LOGIN, { email: username, password: password })
      .then((res) => {
        if (res.status === 200) {
          dispatch(login(res.data.data)); // line#5 logging user in
          setSigningIn(false); // line#6 updating some state
        } else {
          setSigningIn(false);
          ToastAndroid.show(
            "Email or Password is not correct!",
            ToastAndroid.LONG
          );
        }
      })

在上述情况中,在第5行,我正在调度“login”操作,该操作会将用户导航到仪表板,因此登录屏幕现在已被卸载。
现在,当React Native到达第6行并看到有状态正在更新时,它会大声呼喊:我该怎么做,因为“login组件”不再存在。
解决方案:
  axios
      .post(API.BASE_URI + API.LOGIN, { email: username, password: password })
      .then((res) => {
        if (res.status === 200) {
          setSigningIn(false); // line#6 updating some state -- moved this line up
          dispatch(login(res.data.data)); // line#5 logging user in
        } else {
          setSigningIn(false);
          ToastAndroid.show(
            "Email or Password is not correct!",
            ToastAndroid.LONG
          );
        }
      })

只需将React状态更新移到上方,将第6行移至第5行之前。
现在在用户导航之前更新状态。双赢。

实际上,在RN中这是最常见的情况,问题在于如果redux存储与api获取分离。 - Carmine Tambascia

0

我在React Native iOS中遇到了这个问题,并通过将我的setState调用移动到catch中来解决它。请参见下面的代码:

错误的代码(导致错误):

  const signupHandler = async (email, password) => {
    setLoading(true)
    try {
      const token = await createUser(email, password)
      authContext.authenticate(token) 
    } catch (error) {
      Alert.alert('Error', 'Could not create user.')
    }
    setLoading(false) // this line was OUTSIDE the catch call and triggered an error!
  }

好的代码(无错误):

  const signupHandler = async (email, password) => {
    setLoading(true)
    try {
      const token = await createUser(email, password)
      authContext.authenticate(token) 
    } catch (error) {
      Alert.alert('Error', 'Could not create user.')
      setLoading(false) // moving this line INTO the catch call resolved the error!
    }
  }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接