用了这么长时间的 React useEffect hook,你用对了吗?

11,643 阅读5分钟

useEffect 是 React Hooks 的核心,要保证理解它的运行机制和正确的使用方法才能避免这样那样的坑。在以前的工作中,因为它我碰到过无数个坑,比如拿到的值是旧的,该执行的时候不执行,不该执行的时候执行了……

所以为了避免如上尴尬、节省绞尽脑汁找 bug 的时间、保护咱们的发际线,一定要认真学会如何正确使用 useEffect hook。

什么是 Effect

俗话说,知已知彼,百战不殆,我们先了解下神马是 Effect。其实大家在开发过程中或多或少的都接触过 side effect 的概念,即

副作用
—— 对于数据抓取,注册监听事件,修改 DOM 元素等马后炮式的操作都属于副作用,因为我们渲染出来的页面是静态的,任何在之后的操作都会对它产生影响,所以才称之为副作用。而 useEffect 则是专门用来编写
副作用
代码的,这也是 React 的核心所在。

与生命周期的关系

目前市面上的文章,包括官方文档都让我们把 useEffect 想象成 componentDidMountcomponentDidUpdatecomponentWillUnmount 三个生命周期的结合体。其实并不然,如果非要把 useEffect 的运行机制往生命周期上靠,会造成一些逻辑上的困惑,进而产生 bug。我们所要做的,就是把 useEffect 当成一个全新的特性,专门为函数式组件服务的,这样用起来才不会迷茫。下面我们通过实例来演示它的各种用法。

运行时机

useEffect 必然会在 render 的时候执行一次,其他的运行时机取决于以下情况:

  • 有没有第二个参数。useEffect hook 接受两个参数,第一个是要执行的代码,第二个是一个数组,指定一组依赖的变量,其中任何一个变量发生变化时,此 effect 都会重新执行一次。
  • 有没有返回值。 useEffect 的执行代码中可以返回一个函数,在每一次新的 render 进行前或者组件 unmount 之时,都会执行此函数,进行清理工作。

我们先看一个简单的例子,想看完整代码和随意把玩的,请点击下边按钮


我们首先看最顶层 <App /> 的代码:

function App() {
  const [showList, setShowList] = useState(false);
  const [postCount, setPostCount] = useState(5);

  return (
    <div className="App">
      <button onClick={() => setShowList(!showList)}>
        {showList ? "隐藏" : "显示"}
      </button>
      <button onClick={() => setPostCount(previousCount => previousCount + 1)}>
        增加数量
      </button>
      {showList && <PostList count={postCount} />}
    </div>
  );
}

此组件用来显示一系列的文章列表,以及控制文章列表是否显示的按钮和控制显示多少条文章的按钮。我们用 showList state 来控制 <PostList /> 的显示与否。这是为了让 <PostList /> 组件 unmount 再 render ,以证明每次它 render 和 unmount 的时候,useEffect hook 都会跑一次。 <PostList /> 组件的代码如下:

function PostList({ count = 5 }) {
  useEffect(() => {
    let p = document.createElement("p");
    p.innerHTML = `当前文章数量:${count}`;
    document.body.append(p);
  });

  return (
    <ul>
      {new Array(count).fill("文章标题").map((value, index) => {
        return (
          <li key={index}>
            {value}
            {index + 1}
          </li>
        );
      })}
    </ul>
  );
}

该组件展示了一个 <ul> 列表,为了简单起见,生成了一些无聊的文章标题。我们重点来看一下 useEffect 所做的操作:

  • 创建一个 p 元素
  • 设置 p 的文本为当前文章的数量
  • 追加 pbody 的最后

在这里,此 effect 并没有返回任何值,也没有给它传递任何一个参数,那会是什么样的效果呢?
没返回值

答案是,此 effect 会在每次 countshowList 改变时每点击一次 显示增加数量 按钮,我们新追加的 p 都会再追加一次。这也是造成内容泄露的坑,如果我们在这里添加了太多耗内存的东西而没有清理,不用多久浏览器就崩溃了~ 解决方法很简单,给 useEffect 添加一个返回值,并在里边删除我们追加的 p 元素即可:

useEffect(() => {
  let p = document.createElement("p");
  p.innerHTML = `当前文章数量:${count}`;
  document.body.append(p);

  return () => {
    p.remove();
  };
});

这样我们在点击按钮的时候,确保只有一个 p 在当前页面上。看,这样写起来是不是比分散在 componentDidMountcomponentWillUnmount 中方便多了?我们可以方便的在同一个作用域中方便的拿到 p 的引用,直接删除它即可。

有返回值

类实际工作的例子 - 抓取数据

为了继续深入 useEffect hook,我仿照实际工作遇到的情况,编写了一个例子,这里我们用 useEffect 进行数据抓取,同样的显示博客文章列表,完整代码请点击下方按钮查看:

Edit async loading

在本例中,<PostList />组件的代码做了一些修改,首先我们定义两个新的 state:

  • posts。保存远程加载的文章列表
  • loading。记录 ajax 请求状态
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);

我们一开始觉得 fetch 是异步操作,那么得给 useEffect hook 传递个 async 的函数吧?错,传递给 useEffect 的函数不能是 async 的, 因为 async 的本质是返回一个 Promise,而 useEffect 唯一接收的返回值是个函数。使用 async 会收到以下异常:

Warning: An effect function must not return anything besides a function, which is used for clean-up.

// 错误
useEffect(async () => {
  // const response = await fetch("https://jsonplaceholder.typicode.com/posts");
});

正确的写法是,把抓取数据的逻辑定义到一个单独的函数中,然后在 useEffect 中调用它:

useEffect(() => {
  // const response = await fetch("https://jsonplaceholder.typicode.com/posts");

  const loadPosts = async () => {
    setLoading(true);
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/posts?_limit=${count}`
    );
    const data = await response.json();
    setPosts(data);
    setLoading(false);
  };

  loadPosts();
}, [count]);

它所做的操作是,在请求数据前,把 loading 状态设置为 true,然后根据 count 的值去取对应数量的文章列表,把返回值更新到 posts state 中,再把 loading 设置为 false。最后根据 loading 的状态,我们显示 加载中文章列表

if (loading) return <div>loading...</div>;

return (
  <ul>
    {posts.slice(0, count).map((post, index) => {
      return <li key={post.id}>{post.title}</li>;
    })}
  </ul>
);

加载数据

第二个参数

上边的例子中我们给 useEffect 传递了第二个参数,并把 count 作为依赖的值,每当 count 变化时,此 effect 都会重新执行一次,去加载新的数据。另外,如果我们隐藏列表,再点击 显示 按钮时,effect 也会再跑一次,因为点击隐藏时,<PostList /> 组件被 unmount ,然后再次显示时会重新 render ,我们可以根据 loading... 这个标志就可以看出来了。

如果我们去掉第二个参数,那么就会陷入死循环的坑,为什么呢?因为 effect 执行时,会更新 postsloading 这两个 state,而 state 变化时,组件又会重新 render 一次,根据 useEffect 在每次 render 必执行一次的定律不难得出结论。

那么如果我们给它一个空数组呢?那就无论怎么点击增加数量,此 effect 都不会重新执行,导致永远只加载默认 5 篇文章。

加载数据-无依赖数组

添加其他属性

我们可以再试试添加一个其他属性来测试 useEffect 依赖数组的特性。在 <App /> 组件中我们添加一个布局状态 vertical 和修改布局的按钮,用来控制 <PostList> 组件的横向、纵向布局:

// APP
function App() {
  // 其它代码省略
  const [vertical, setVertical] = useState(true);

  return (
    <div className="App">
      {/* 其它代码省略 */}
      <button onClick={() => setVertical(prev => !prev)}>更改布局</button>
      {showList && <PostList count={postCount} vertical={vertical} />}
    </div>
  );
}

// PostList
function PostList({ count = 5, vertical = false }) {
  // 其它代码省略
  useEffect(() => {
    // const response = await fetch("https://jsonplaceholder.typicode.com/posts");

    const loadPosts = async () => {
      setLoading(true);
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts?_limit=${count}`
      );
      const data = await response.json();
      setPosts(data);
      setLoading(false);
    };

    loadPosts();
  }, [count, vertical]); // 在这里添加 vertical 作为依赖

  // 其它代码省略
}

在这里我们给第二个参数添加了 vertical 依赖,这样每次点击 更改布局 按钮时,文章列表都会加载一次,这种适合在布局改变时需要重新请求数据的情况:
加载数据-依赖layout

如果不需要重新加载数据,只需要把 vertical 从依赖数组里去掉就可以了。

加载数据-不依赖layout

划重点

看看大家对频繁使用的 useEffect 的用法用对了没有?来标一下重点:

  1. 它可不完全是 componentDidMountcomponentDidUpdatecomponentWillUnmount 三个生命周期的结合体哦(人家有自己的想法)。
  2. 会在每次 render 的时候必定执行一次。
  3. 如果返回了函数,那么在下一次 render 之前或组件 unmount 之前必定会运行一次返回函数的代码。
  4. 如果指定了依赖数组,且不为空,则当数组里的每个元素发生变化时,都会重新运行一次。
  5. 如果数组为空,则只在第一次 render 时执行一次,如果有返回值,则同 3。
  6. 如果在 useEffect 中更新了 state,且没有指定依赖数组,或 state 存在于依赖数组中,就会造成死循环。

大家掌握了吗?有什么问题欢迎评论或私信我!如果觉得文章有帮助请关注博主我哦,感谢,比心。