Skip to content

Instantly share code, notes, and snippets.

@banyudu
Last active July 14, 2021 02:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save banyudu/ee58333f01c46e181d4de0b90f4ad9ee to your computer and use it in GitHub Desktop.
Save banyudu/ee58333f01c46e181d4de0b90f4ad9ee to your computer and use it in GitHub Desktop.
React自定义hooks useOutdatedEffect 介绍

React自定义hooks useOutdatedEffect 介绍

前言

React中有一个常见的问题,数据获取之后,组件已经销毁,此时会有这样一段警告:

Can't perform a React state update on an unmounted component

相应地,也有一些常见的解决方案,如:

useEffect(() => {
  let isMounted = true;               // note mutable flag
  someAsyncOperation().then(data => {
    if (isMounted) setState(data);    // add conditional check
  })
  return () => { isMounted = false }; // use cleanup to toggle value, if unmounted
}, []);                               // adjust dependencies to your needs

但是却鲜少有人提及另外一种场景,useEffect异步操作之后,虽然组件仍然处于挂载状态,但是当初的条件已经发生了改变,这种情况下容易出现bug。

问题

举例来说,对于如下的代码:

import React, { FC, useState, useEffect } from 'react'
import axios from 'axios'

const App = (props) => {
  const { id } = props
  const [dataSource, setDataSource] = useState(null)
  useEffect(() => {
    const fetchData = async () => {
      if (id) {
        const { data } = await axios.get(`/api/mydata/${id}`)
        setDataSource(data)
      } else {
        setDataSource(null)
      }
    }
  }, [id])
}

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和组件装载状态的当前情况。

对于上面的问题,可以用如下的方式改进:

const App = (props) => {
  const { id } = props
  const [dataSource, setDataSource] = useState(null)
  useOutdatedEffect((outdated, unmounted) => {
    const fetchData = async () => {
      const { data } = await axios.get(`/api/mydata/${id}`)
      if (outdated()) { // check whether dependencies changed. In this example, it's the id variable
        // id changed, stop the current operations
        return
      }

      if (unmounted()) { // check whether component is unmounted
        // component destroied, stop the current operations
        return
      }

      setDataSource(data)
    }

  }, [id])

}

实现原理

在自定义hooks的内部使用useRef维护一个 myRef 的变量,每当dependencies发生变化,自动设置myRef.current为一个新的值。

因为多个useEffect中的代码同时生效时是按顺序执行的,所以可以在自定义hooks中使用两个useEffect,第一个改myRef.current的值,第二个先记录myRef.current的旧值,在异步逻辑完成之后,再重新判断一次获取最新值,只有校验通过之后,才继续进行下一步。

关键代码:

const useAsyncEffect = (
  effect: (outdated: () => boolean, unmounted: () => boolean, ) => ReturnType<EffectCallback>,
  inputs?: DependencyList,
) => {
  const asyncFlag = useRef<number>(0);

  useEffect(() => { asyncFlag.current += 1 }, inputs);

  useEffect(function () {
    let result: ReturnType<EffectCallback> | undefined;
    let unmounted = false;
    const innerAsyncFlag = asyncFlag.current;
    const maybePromise = effect(
      () => asyncFlag.current !== innerAsyncFlag,
      () => unmounted,
    );

    Promise.resolve(maybePromise).then((value) => { result = value });

    return () => { unmounted = true; result && result?.() };
  }, inputs);
};

安装使用

npm i use-outdated-effect
useOutdatedEffect((outdated, unmounted) => {
  const fetchData = async () => {
    const { data } = await axios.get(`/api/mydata/${id}`)
    if (outdated()) { // check whether dependencies changed. In this example, it's the id variable
      // id changed, stop the current operations
      return
    }

    if (unmounted()) { // check whether component is unmounted
      // component destroied, stop the current operations
      return
    }
    setDataSource(data)
  }

  }, [id])

更多

更多细节,参考Github仓库

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment