React.useEffect Hook – 常见问题和解决方案

726 阅读4分钟

React hooks 已经发布了有一段时间。大多数开发者也能够熟练的在工作中使用它们。但是有一个关于 useEffect 的问题让我们很多人一直为之着迷。

用例

让我们来举一个简单的例子。假设我们要创建一个 React 应用并且在其中一个组件中展示当前用户的用户名。首先,我们需要从 API 中获取用户名。

因为我们知道将来有可能在应用的其他地方也会用到用户相关的数据,因此想要将获取用户数据的逻辑抽象到自定义的 React hook 中。

一般情况下,我们会这么写:

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

看起来很简单吧!

React hook - useUser

第二步是创建我们的自定义 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;
};

我们来分析一下以上代码。首先检查 hook 是否接收到了一个 user 对象。然后从 user.json 文件中获取用户数据列表,并且根据 id 筛选出要查找的用户。

一旦查找到了需要的用户数据,就将它存在 hook 中的 userData 这个 state 里。最后返回 userData 以供其他组件使用。

注意:这只是一个简单的数据查询的例子!真实情况往往比这复杂得多。如果你对这个话题感兴趣,可以 查看我的文章 来了解如何使用 ReactQuery, Typescript 和 GraphQL 创建一个出色的数据查询机制。

接下来我们把这个 hook 添加到 React 组件中,看看发生了什么。

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

这段代码运行的和期望的一样。

ESLint exhaustive-deps 规则

但是我们发现 hook 中出现了一个 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;
};

糟糕!组件看起来在不断的重渲染。这是怎么回事?!

原因是这样的。

无穷尽的重渲染问题

组件不断的重渲染是因为 useEffect 的依赖在不断的发生变化。但是,我们不是每次都向 hook 中传入了相同的对象吗?

其实我们只是传入了和之前的对象拥有相同键值对的另外一个对象,这两个对象完全不同。组件每次在重渲染的时候,都创建了一个新的对象。然后把这个对象作为参数传递给了 useUser hook。

useEffect 的内部,它会比较这两个对象。当它们的引用地址不一样时,就会重新查询用户数据,并将新的用户对象设置到 state 里。state 更新又会触发组件的重渲染。如此这样不断的重复这一过程,就造成了死循环...

那我们该怎么做呢?

如何修复

我们了解了这个问题产生的原因之后,就可以来寻找一个解决方案了。

首先,最明显的方式就是从 useEffect 的依赖项数组中移除刚刚添加的 user 对象,忽略 ESLint 规则的检测,这样组件就可以正常运行了。

但这是错误的做法。它会(而且极有可能会)导致一些其他的 bug 和一些非预期的行为。如果你想了解更多关于 useEffect 的原理,我强烈推荐你去读一下 Dan Abramov 的完全指南

那接下来该怎么做呢?

在我们的例子里,最简单的方式就是将 { id: 1 } 这个对象移到组件之外。这样 user 对象的引用会保持不变,我们的问题就迎刃而解了。

const userObject = { id: 1 };

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

export default Component;

但是当你的用户 id 依赖于组件的 props 和 state,这种方案就不可行了。

举个例子,我们有可能是从 URL 的参数中来获取用户 id 的。这种情况下,就可以使用 useMemo hook 来记忆对象以确保这个对象的引用是稳定的。

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;

最后,我们其实可以不传递对象,而是传递一个原始值 - 用户 id 作为 useUser hook 的参数。这样就能避免 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 的校验规则。

注意: 如果想要向一个自定义 hook 中传入方法作为参数,而不是对象,也可以使用相似的技术来避免无穷尽的重渲染问题。唯一不同的就是用 useCallback 来代替上面例子中的 useMemo

感谢您的阅读!