React.useEffect Hook 常见问题及解决方法

大多数开发人员都非常熟悉 React hooks 的工作方式和常见用例,但是有一个 useEffect 问题很多人可能不太清楚。

用例

让我们从一个简单的场景开始。我们正在构建一个 React 应用程序,希望在一个组件中显示当前用户的用户名。但首先,我们需要从 API 中获取用户名。

因为我们知道我们也需要在应用程序的其他地方使用用户数据,所以我们还想在自定义 React hook 中抽象数据获取逻辑。

我们希望 React 组件看起来像这样:

const Component = () => {
  // useUser custom hook
  
  return <div>{user.name}</div>;
};

看起来很简单!

useUser React hook

第二步是创建我们的 useUser 自定义钩子。

const useUser = (user) => {
  const [userData, setUserData] = useState();
  useEffect(() => {
    if (user) {
      fetch("users.json").then((response) =>
        response.json().then((users) => {
          return setUserData(users.find((item) => item.id === user.id));
        })
      );
    }
  }, []);

  return userData;
};

让我们分解一下。我们正在检查钩子是否正在接收用户对象。之后,我们从名为 users.json 的文件中获取用户列表,并对其进行过滤以找到具有我们需要的 id 的用户。

然后,一旦我们获得了必要的数据,我们就将其保存为钩子的 userData 状态。最后返回 userData

注意: 这是一个仅用于说明目的的示例!现实世界中的数据获取要复杂得多。如果你对该主题感兴趣,请查看我关于如何使用 ReactQuery、Typescript 和 GraphQL 创建出色的数据获取设置的文章。

让我们在 React 组件中插入钩子,看看会发生什么。

const Component = () => {
  const user = useUser({ id: 1 });
  return <div>{user?.name}</div>;
};

好的,一切都按预期进行。但是等等……这是什么?

ESLint exhaustive-deps 规则

我们的钩子中有一个 ESLint 警告:

React Hook useEffect has a missing dependency: 'user'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)

嗯,我们的 useEffect 似乎缺少依赖项。那好吧! 让我们添加它。可能发生的最坏情况是什么? 😂

const useUser = (user) => {
  const [userData, setUserData] = useState();
  useEffect(() => {
    if (user) {
      fetch("users.json").then((response) =>
        response.json().then((users) => {
          return setUserData(users.find((item) => item.id === user.id));
        })
      );
    }
  }, [user]);

  return userData;
};

看起来我们的组件 Component 现在不会停止重新渲染。这里发生了什么?!

让我们解释一下。

无限重新渲染问题

我们的组件重新渲染的原因是因为 useEffect 依赖项在不断变化。但为什么?我们总是将相同的对象传递给钩子!

虽然我们确实传递了一个具有相同键和值的对象,但它并不是完全相同的对象。每次重新渲染组件时,我们实际上都是在创建一个新对象,然后我们将新对象作为参数传递给 useUser 钩子。

在内部,useEffect 比较两个对象,由于它们有不同的引用,它再次获取用户并将新的用户对象设置为状态。状态更新然后触发组件中的重新渲染,不断重复……

所以,我们能做些什么?

如何修复

现在我们了解了问题,可以开始寻找解决方案。

第一个也是最明显的选择是从 useEffect 依赖数组中移除依赖,忽略 ESLint 规则,继续我们的生活。

但这是错误的做法。它可以(并且可能会)导致我们的应用程序中出现错误和意外行为。如果你想了解更多有关 useEffect 如何工作的信息,我强烈推荐 Dan Abramov 的完整指南。

下一个是什么?

在我们的例子中,最简单的解决方案是从组件中取出 { id: 1 } 对象。这将为对象提供稳定的引用并解决我们的问题。

const userObject = { id: 1 };

const Component = () => {
  const user = useUser(userObject);
  return <div>{user?.name}</div>;
};

export default Component;

但这并不总是可能的。想象一下,用户 id 以某种方式依赖于组件 props 或状态。

例如,可能是我们使用 URL 参数来访问它。如果是这种情况,我们可以使用一个方便的 useMemo 钩子来记忆对象并再次确保稳定的引用。

const Component = () => {
  const { userId } = useParams();
  
  const userObject = useMemo(() => {
    return { id: userId };
  }, [userId]); // Don't forget the dependencies here either!

  const user = useUser(userObject);
  return <div>{user?.name}</div>;
};

export default Component;

最后,不是将对象变量传递给我们的 useUser 钩子,而是可以只传递用户 ID 本身,这是一个原始值。这将防止 useEffect 钩子中的引用相等问题。

const useUser = (userId) => {
  const [userData, setUserData] = useState();

  useEffect(() => {
    fetch("users.json").then((response) =>
      response.json().then((users) => {
        return setUserData(users.find((item) => item.id === userId));
      })
    );
  }, [userId]);

  return userData;
};

const Component = () => {
  const user = useUser(1);

  return <div>{user?.name}</div>;
};

问题解决啦!

在此过程中,我们甚至不必违反任何 ESLint 规则……

注意: 如果我们传递给自定义钩子的参数是一个函数,而不是一个对象,我们将使用非常相似的技术来避免无限重新渲染。一个显着的区别是我们必须在上面的例子中用 useCallback 替换 useMemo