发布于 2026-01-06 3 阅读
0

React 状态更新在未挂载组件上 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

已卸载组件的 React 状态更新

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

最初发布在我的个人博客debugger.io上。

如果你是一名 React 开发者,那么你很可能至少遇到过一次这样的警告:

警告:无法对已卸载的组件执行 React 状态更新。此操作不会产生任何实际效果,但表明您的应用程序存在内存泄漏。要解决此问题,请在 useEffect 的清理函数中取消所有订阅和异步任务。

为了了解如何修复此警告,我们需要了解其发生的原因。我们需要以一致的方式重现此问题。

⚠️ 请注意,本文中使用了 React Hooks,如果您使用的是 React 类组件,您可能会在警告中看到对 componentWillUnmount 的引用,而不是 useEffect 清理函数。

重现该警告

👀 我已经在 GitHub 上上传了一个入门仓库,这样你就不用复制粘贴代码了。
你可以克隆并在本地运行,或者使用codesandbox.io导入功能。

如果我们再仔细看一下这个警告,就会发现其中主要有两个部分在起作用:

  1. React 状态更新
  2. 未安装的组件

为了实现这些功能,我们将构建这个简单的下拉菜单,并采用异步数据获取方式。

显示宠物下拉菜单,选择狗或猫

州政府最新动态

function Pets() {
  const [pets, dispatch] = useReducer(petsReducer, initialState);

  const onChange = ({ target }) => {
    dispatch({ type: "PET_SELECTED", payload: target.value });
  };

  useEffect(() => {
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);

  return (
    <div>
      <select value={pets.selectedPet} onChange={onChange}>
        <option value="">Select a pet</option>
        <option value="cats">Cats</option>
        <option value="dogs">Dogs</option>
      </select>
      {pets.loading && <div>Loading...</div>}
      {pets.petData && <Pet {...pets.petData} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

这里是Pets组件,它使用useReducerhook 来存储一些状态。
让我们看看petsReducer初始状态:

const initialState = { loading: false, selectedPet: "", petData: null }

function petsReducer(state, action) {
  switch (action.type) {
    case "PET_SELECTED": {
      return {
        ...state,
        selectedPet: action.payload
      };
    }
    case "FETCH_PET": {
      return {
        ...state,
        loading: true,
        petData: null
      };
    }
    case "FETCH_PET_SUCCESS": {
      return {
        ...state,
        loading: false,
        petData: action.payload
      };
    }

    case "RESET": {
      return initialState;
    }

    default:
      throw new Error( `Not supported action ${action.type}` );
  }
}
Enter fullscreen mode Exit fullscreen mode

如你所见,这里没有什么特别之处,只是一个简单的 reducer 来管理我们的状态。

Pets组件还使用useEffecthook 来实现一些副作用,例如获取我们所选宠物的数据。我们调用getPet返回 a 的函数Promise,并将FETCH_PET_SUCCESS返回的数据作为有效负载分发 action 来更新我们的状态。

请注意,这getPet实际上并没有访问服务器端点,它只是一个模拟服务器调用的函数。它的代码如下所示:

const petsDB = {
  dogs: { name: "Dogs", voice: "Woof!", avatar: "🐶" },
  cats: { name: "Cats", voice: "Miauuu", avatar: "🐱" }
};

export function getPet(type) {
  return new Promise(resolve => {
    // simulate a fetch call
    setTimeout(() => {
      resolve(petsDB[type]);
    }, 1000);
  });
}
Enter fullscreen mode Exit fullscreen mode

如你所见,它只不过是一个setTimeout内部Promise

我们的App代码基本上就是渲染这个Pets组件:

function App() {
  return (
    <div>
      <Pets />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

好的,我们问题的第一部分已经完成,React state update现在我们需要创建第二部分An unmounted component

卸载组件

使用状态和条件渲染可以相对容易地实现这一点,我们将在级别存储一个布尔标志,并使用切换按钮根据该标志App渲染组件。<Pets />

function App() {
  const [showPets, setShowPets] = useState(true);

  const toggle = () => {
    setShowPets(state => !state);
  };

  return (
    <div>
      <button onClick={toggle}>{showPets ? "hide" : "show"}</button>
      {showPets && <Pets />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

这就是我们的应用程序应该呈现的样子。
应用程序带有一个切换按钮,用于显示或隐藏下拉菜单。

生殖

好的,现在我们已经满足了出现警告的两个条件,让我们来试一试。我们再看一下这个警告:

警告:无法对已卸载的组件执行 React 状态更新。此操作不会产生任何实际效果,但表明您的应用程序存在内存泄漏。要解决此问题,请在 useEffect 的清理函数中取消所有订阅和异步任务。

我们重点来看这一行:

已卸载组件的 React 状态更新

如果我们选择一只宠物,我们知道至少需要1 秒钟才能收到数据。数据返回后,我们会更新状态。如果在 1 秒钟之前(数据到达之前)getPet卸载组件,就会触发已卸载组件的更新。Pet

方法如下:
*如果 1 秒的延迟无法实现,请尝试增加函数timeOut中的延迟getPet

显示错误

好的,这是我们任务的第一部分,现在我们需要修复它。

修复

你可能会感到惊讶,但解决这个问题其实很简单。React 会提供清晰且非常有用的信息,并指导你找到解决方案:

要解决此问题,请在 useEffect 清理函数中取消所有订阅和异步任务。

嗯,我们可能并没有真正订阅任何东西,但我们确实有一个asynchronous tasks,还记得getPet异步函数吗?

function Pets() {
  const [pets, dispatch] = useReducer(petsReducer, initialState);

  const onChange = ({ target }) => {
    dispatch({ type: "PET_SELECTED", payload: target.value });
  };

  useEffect(() => {
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);

  return (
    <div>
      <select value={pets.selectedPet} onChange={onChange}>
        <option value="">Select a pet</option>
        <option value="cats">Cats</option>
        <option value="dogs">Dogs</option>
      </select>
      {pets.loading && <div>Loading...</div>}
      {pets.petData && <Pet {...pets.petData} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

所以基本上,如果组件尚未挂载,我们就需要在回调函数中不要更新状态。

function Pets() {
  const [pets, dispatch] = useReducer(petsReducer, initialState);

  const onChange = ({ target }) => {
    dispatch({ type: "PET_SELECTED", payload: target.value });
  };

  useEffect(() => {
    let mounted = true;
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        if(mounted){
          dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
        }
      });
    } else {
      dispatch({ type: "RESET" });
    }

    return () => mounted = false;

  }, [pets.selectedPet]);

  return (
    <div>
      <select value={pets.selectedPet} onChange={onChange}>
        <option value="">Select a pet</option>
        <option value="cats">Cats</option>
        <option value="dogs">Dogs</option>
      </select>
      {pets.loading && <div>Loading...</div>}
      {pets.petData && <Pet {...pets.petData} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

每次执行 effect 时,我们都会设置一个局部变量,mountedtrue在 effect 的清理函数中将其设置为 false(正如 React 建议的那样)。最重要的是,只有当该变量的值为 false 时true,我们才会更新 state。也就是说,如果组件已卸载(即变量已设置为 false),则不会进入该if代码块。

好了,现在我们不再收到警告了:
修复后的应用程序没有发出任何警告

额外提示

我们在作用域内设置了一个局部变量useEffect,如果想在另一个作用域内重用此变量,useEffect可以使用 ` useRefis_preRendering_state`,这是一种组件的非渲染状态。

例如:

function Pets() {
  const [pets, dispatch] = useReducer(petsReducer, initialState);
  const isMountedRef = useRef(null);

  const onChange = ({ target }) => {
    dispatch({ type: "PET_SELECTED", payload: target.value });
  };

  useEffect(() => {
    isMountedRef.current = true;
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        if(isMountedRef.current){
          dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
        }
      });
    } else {
      dispatch({ type: "RESET" });
    }

    return () => isMountedRef.current = false;

  }, [pets.selectedPet]);

  useEffect(() => {
      // we can access isMountedRef.current here as well
  })

  return (
    <div>
      <select value={pets.selectedPet} onChange={onChange}>
        <option value="">Select a pet</option>
        <option value="cats">Cats</option>
        <option value="dogs">Dogs</option>
      </select>
      {pets.loading && <div>Loading...</div>}
      {pets.petData && <Pet {...pets.petData} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Hooks 的妙处在于,我们可以将这部分逻辑提取到一个自定义 Hook 中,并在组件间复用。一种可能的实现方式如下:

function useIsMountedRef(){
  const isMountedRef = useRef(null);

  useEffect(() => {
    isMountedRef.current = true;
    return () => isMountedRef.current = false;
  });

  return isMountedRef;
}

function Pets() {
  const [pets, dispatch] = useReducer(petsReducer, initialState);
  const isMountedRef = useIsMountedRef();

  const onChange = ({ target }) => {
    dispatch({ type: "PET_SELECTED", payload: target.value });
  };

  useEffect(() => {
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        if(isMountedRef.current){
          dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
        }
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet, isMountedRef]);

  return (
    <div>
      <select value={pets.selectedPet} onChange={onChange}>
        <option value="">Select a pet</option>
        <option value="cats">Cats</option>
        <option value="dogs">Dogs</option>
      </select>
      {pets.loading && <div>Loading...</div>}
      {pets.petData && <Pet {...pets.petData} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

自定义使用效果

如果我们想让我们的钩子更加疯狂,我们可以创建自己的自定义钩子useEffect(或钩子useLayoutEffect),它将为我们提供效果的“当前状态”:

function useAbortableEffect(effect, dependencies) {
  const status = {}; // mutable status object
  useEffect(() => {
    status.aborted = false;
    // pass the mutable object to the effect callback
    // store the returned value for cleanup
    const cleanUpFn = effect(status);
    return () => {
      // mutate the object to signal the consumer
      // this effect is cleaning up
      status.aborted = true;
      if (typeof cleanUpFn === "function") {
        // run the cleanup function
        cleanUpFn();
      }
    };
  }, [...dependencies]);
}
Enter fullscreen mode Exit fullscreen mode

我们将在Pet组件中这样使用它:

  useAbortableEffect((status) => {
    if (pets.selectedPet) {
      dispatch({ type: "FETCH_PET" });
      getPet(pets.selectedPet).then(data => {
        if(!status.aborted){
          dispatch({ type: "FETCH_PET_SUCCESS", payload: data });
        }
      });
    } else {
      dispatch({ type: "RESET" });
    }
  }, [pets.selectedPet]);
Enter fullscreen mode Exit fullscreen mode

请注意,我们的自定义 effect 回调现在接受一个status参数,该参数是一个包含aborted布尔属性的对象。如果该属性设置为 true true,则表示我们的 effect 已被清理并重新运行(这意味着我们的依赖项已更改或组件已卸载)。

我挺喜欢这种模式的,希望 ReactuseEffect能默认支持这种行为。我已经为此在 React 代码库里创建了一个 RFC,欢迎大家评论或改进。

总结

我们已经看到,一个简单的组件如果执行异步状态更新,就可能导致这种常见的警告。想想你所有类似的组件。务必在执行状态更新之前检查组件是否已成功挂载。

希望这篇文章对你有帮助。如果你有不同的方法或建议,欢迎告诉我,你可以在推特上或私信我@ sag1v。🤓

更多文章请访问debuggr.io

文章来源:https://dev.to/sag1v/react-state-update-on-an-unmounted-component-1bi1