React Effect:竞态条件的修复

524 阅读2分钟

原文链接:Fixing Race Conditions in React with useEffect,by Max Rozen (@RozenMD)

你有一个在 React 组件中获取数据的需求。这个组件接受 id 作为 prop,使用 id 通过 useEffect 获取并展示数据。

你注意到了一些奇怪的事情:这个组件有时候会正确地展示数据,而有时候展示的却是无效数据或旧数据。

很有可能,你是遇到了 竞态条件 问题。

通常,在两个差别不大的数据请求发出后,如果应用程序是根据哪个请求先完成就显示哪个数据的话,就会遇到竞态条件问题。

问题复现

使用 [useEffect](https://maxrozen.com/fetching-data-react-with-useeffect/) 获取数据 时,如果 id 变化得足够快,那么我们编写的组件就可能会存在竞态条件问题:

import React, { useEffect, useState } from 'react';

export default function DataDisplayer(props) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
      const newData = await response.json();
      setData(newData);
    };

    fetchData();
  }, [props.id]);

  if (data) {
    return <div>{data.name}</div>;
  } else {
    return null;
  }
}

上面的代码片段就很容易受到竞态条件的影响。因为看起来可能不是很明显,所以我创建了一个 CodeSandbox 示例方便大家来观察(我为了每个请求添加了随机响应时间(最多 12 秒))。

如果只是简单点击了一次“Fetch data!”按钮,我们能看到预期行为——一个简单的组件,在单击后顺利展示了响应回来的数据。

但是如果你快速点击“Fetch data!”按钮几次,情况会变得复杂。这个组件会同时发出多个请求,并且这些请求都是以随机顺序响应的,组件最终展示的是最后结束的那个请求的响应数据。

示例中的 DataDisplayer 组件代码如下:

export default function DataDisplayer(props) {
  const [data, setData] = useState(null);
  const [fetchedId, setFetchedId] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
+      setTimeout(async () => {
        const response = await fetch(
          `https://swapi.dev/api/people/${props.id}/`
        );
        const newData = await response.json();

        setFetchedId(props.id);
        setData(newData);
+      }, Math.round(Math.random() * 12000));
    };

    fetchData();
  }, [props.id]);

  if (data) {
    return (
      <div>
        <p style={{ color: fetchedId === props.id ? 'green' : 'red' }}>
          Displaying Data for: {fetchedId}
        </p>
        <p>{data.name}</p>
      </div>
    );
  } else {
    return null;
  }
}

修复 Effect 中的竞态条件问题

我们可以采用两种方法来解决竞态条件问题,都用到了 useEffect 的清理函数:

  • 如果我们能够接受发出多个请求,只呈现最后一个结果,则可以借助一个布尔标志
  • 或者,如果无需支持 Internet Explorer 的用户,就可以使用 AbortController 取消旧请求

使用带有布尔标志的 useEffect 清理函数

首先,我们在代码中修复的要点是:

import React, { useEffect, useState } from 'react';

export default function DataDisplayer(props) {
  const [data, setData] = useState(null);

  useEffect(() => {
+    let ignore = false;

    const fetchData = async () => {
      const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
      const newData = await response.json();
+      if (!ignore) {
        setData(newData);
      }
    };

    fetchData();

+    return () => {
+        ignore = true;
+    }
  }, [props.id]);

  if (data) {
    return <div>{data.name}</div>;
  } else {
    return null;
  }
}

这个修复依赖于 React Hooks API 参考文档 中经常被忽视的一句话:

此外,如果一个组件被渲染多次(通常是这样的),在执行下一个 effect 之前会清除上一个 effect。 (译注:即对于非首次渲染,React 会先执行清理函数(cleanup function),再去执行启动函数(setup function))

在上面的示例中:

  • 更改 props.id 将导致组件重新渲染
  • 每次重新渲染都会触发清理函数执行,将 ignore 设置为 true
  • 由于 ignore 被设置为 true,旧请求响应数据会被忽略设置,无法更新状态了

当然竞争条件问题仍然存在,因为多个请求仍在进行中,只不过只有最后一个请求的结果会被使用。

清理函数的效果看起来可能不是很明显,所以我创建了一个 CodeSandbox 示例方便大家来观察(我还添加了计数器来跟踪活动的请求数量和一些辅助函数)。

使用 AbortController 的 useEffect 清理函数

再次,让我们从代码开始:

import React, { useEffect, useState } from 'react';

export default function DataDisplayer(props) {
  const [data, setData] = useState(null);

  useEffect(() => {
+    const abortController = new AbortController();

    const fetchData = async () => {
      try {
        const response = await fetch(`https://swapi.dev/api/people/${props.id}/`, {
+          signal: abortController.signal,
        });
        const newData = await response.json();
        setData(newData);
      } catch (error) {
+        if (error.name === 'AbortController') {
+            // 中止获取操作会抛出一个错误
+            // 因此我们不会更新旧响应数据
+        }
        // 在此处理其他请求错误
      }
    };

    fetchData();

+    return () => {
+        abortController.abort();
+    }
  }, [props.id]);

  if (data) {
    return <div>{data.name}</div>;
  } else {
    return null;
  }
}

和前面的例子一样,我们利用了 React 在执行下一个 effect 之前运行清理函数的特性。你也可以查看 CodeSandbox(这次我们不计算请求数量,因为同一时间只会有一个请求)。

然而,这一次我们:

  • 在 effect 开始时初始化了一个 AbortController 实例
  • 通过 options 参数将 AbortController.signal 传递给 fetch
  • 在清理函数中调用 abort() 函数
  • 捕获任何抛出的 AbortErrors(当调用 abort() 时,fetch() 将返回一个携带 AbortError 的 rejected 状态的 Promise,具体 请参阅 MDN 文档

使用这种方案我们就要放弃对 Internet Explorer 的支持;或者使用 polyfill,获得取消正在进行中的 HTTP 请求的能力。

就我个人而言,我很幸运地为一家不再支持 Internet Explorer 的公司工作,因此我更喜欢避免浪费用户带宽,并使用 AbortController。