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

React DEV 的全球展示挑战赛(由 Mux 呈现):处理 API 请求竞态条件:展示你的项目!

React 中 API 请求竞态条件的处理

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

:建议在我的网站上阅读本文。原文包含可运行的 React 示例,但由于 dev.to 不支持 MDX,我不得不将其移除。

这也是我第一次在这里发帖,希望你们喜欢 :)


许多博客文章讨论了如何在 React 应用中加载 API/异步数据,例如使用componentDidMountRedux useEffect、Apollo 等工具。

然而,所有这些文章通常都比较乐观,从未提及一个需要考虑的重要因素:可能会出现竞态条件,导致用户界面最终处于不一致的状态

一张图片胜过千言万语:

你搜索马克龙,然后改变主意搜索特朗普,结果发现你想要的(特朗普)你得到的(马克龙)并不匹配。

如果你的用户界面有一定概率会处于这种状态,那么你的应用就容易出现竞态条件。

为什么会发生这种情况?

有时,多个请求会并行发出(争相渲染同一个视图),我们通常会认为最后一个请求会最后解析。但实际上,最后一个请求可能最先解析,也可能直接失败,导致第一个请求最后解析。

这种情况比你想象的要常见。对于某些应用程序来说,这可能会导致非常严重的问题,例如用户购买了错误的产品,或者医生给病人开了错误的药物

以下列举部分原因:

  • 网络速度慢、质量差、不稳定,请求延迟也不稳定……
  • 后端负载过重,正在遭受拒绝服务攻击,部分请求已被限制……
  • 用户正在快速点击,通勤,旅行,在乡村……
  • 你只是运气不好。

开发人员在开发过程中看不到这些问题,因为开发过程中的网络条件通常很好,有时甚至在自己的计算机上运行后端 API,延迟接近 0 毫秒。

在这篇文章中,我将通过真实的网络仿真和可运行的演示,向您展示这些问题会造成什么影响。我还会解释如何根据您已使用的库来解决这些问题。

免责声明:为了专注于竞态条件,以下代码示例setState在卸载后将无法阻止 React 警告。

被指控的法典:

你可能已经看过包含以下代码的教程:

const StarwarsHero = ({ id }) => {
  const [data, setData] = useState(null);

  useEffect(() => {
    setData(null);

    fetchStarwarsHeroData(id).then(
      result => setData(result),
      e => console.warn('fetch failure', e),
    );
  }, [id]);

  return <div>{data ? data.name : <Spinner />}</div>;
};
Enter fullscreen mode Exit fullscreen mode

或者使用类 API:

class StarwarsHero extends React.Component {
  state = { data: null };

  fetchData = id => {
    fetchStarwarsHeroData(id).then(
      result => setState({ data: result }),
      e => console.warn('fetch failure', e),
    );
  };

  componentDidMount() {
    this.fetchData(this.props.id);
  }

  componentDidUpdate(nextProps) {
    if (nextProps.id !== this.props.id) {
      this.fetchData(this.props.id);
    }
  }

  render() {
    const { data } = this.state;
    return <div>{data ? data.name : <Spinner />}</div>;
  }
}
Enter fullscreen mode Exit fullscreen mode

以上两种方法都会导致相同的结果。即使在您家中网络良好且 API 响应速度极快的情况下,如果 ID 更改过快,也会出现问题,有时甚至会渲染之前请求的数据。请不要认为防抖机制可以完全避免这种情况:它只是降低了出现这种问题的概率。

现在让我们看看当你乘坐一列有几条隧道的火车时会发生什么。

模拟不良网络状况

让我们编写一些工具来模拟糟糕的网络状况:

import { sample } from 'lodash';

// Will return a promise delayed by a random amount, picked in the delay array
const delayRandomly = () => {
  const timeout = sample([0, 200, 500, 700, 1000, 3000]);
  return new Promise(resolve =>
    setTimeout(resolve, timeout),
  );
};

// Will throw randomly with a 1/4 chance ratio
const throwRandomly = () => {
  const shouldThrow = sample([true, false, false, false]);
  if (shouldThrow) {
    throw new Error('simulated async failure');
  }
};
Enter fullscreen mode Exit fullscreen mode

增加网络延迟

您的网络速度可能较慢,或者后端响应可能需要一些时间。

useEffect(() => {
  setData(null);

  fetchStarwarsHeroData(id)
    .then(async data => {
      await delayRandomly();
      return data;
    })
    .then(
      result => setData(result),
      e => console.warn('fetch failure', e),
    );
}, [id]);
Enter fullscreen mode Exit fullscreen mode

增加网络延迟和故障

你乘坐一列行驶在乡村的火车,途中会经过几个隧道:请求会随机延迟,有些请求可能会失败。

useEffect(() => {
  setData(null);

  fetchStarwarsHeroData(id)
    .then(async data => {
      await delayRandomly();
      throwRandomly();
      return data;
    })
    .then(
      result => setData(result),
      e => console.warn('fetch failure', e),
    );
}, [id]);
Enter fullscreen mode Exit fullscreen mode

这段代码很容易导致奇怪且不一致的用户界面状态。

如何避免这个问题

假设按顺序发出三个请求 R1、R2 和 R3,且这些请求仍处于待处理状态。解决方案是仅处理来自 R3(即最后一个发出的请求)的响应。

有几种方法可以做到这一点:

  • 忽略先前 API 调用的响应
  • 取消之前的 API 调用
  • 取消和忽略

忽略先前 API 调用的响应

以下是一种可能的实现方式。

// A ref to store the last issued pending request
const lastPromise = useRef();

useEffect(() => {
  setData(null);

  // fire the api request
  const currentPromise = fetchStarwarsHeroData(id).then(
    async data => {
      await delayRandomly();
      throwRandomly();
      return data;
    },
  );

  // store the promise to the ref
  lastPromise.current = currentPromise;

  // handle the result with filtering
  currentPromise.then(
    result => {
      if (currentPromise === lastPromise.current) {
        setData(result);
      }
    },
    e => {
      if (currentPromise === lastPromise.current) {
        console.warn('fetch failure', e);
      }
    },
  );
}, [id]);
Enter fullscreen mode Exit fullscreen mode

有些人可能会想用这种id方式进行过滤,但这并非明智之举:如果用户点击了某个选项next,然后又previous点击了另一个选项,我们最终可能会收到两个针对同一英雄的不同请求。通常这不会造成问题(因为这两个请求通常会返回完全相同的数据),但使用 Promise Identity 是一种更通用、更易于移植的解决方案。

取消之前的 API 调用

最好取消正在进行的 API 请求:浏览器可以避免解析响应,从而减少不必要的 CPU 和网络资源占用。fetch感谢以下支持取消功能AbortSignal

const abortController = new AbortController();

// fire the request, with an abort signal,
// which will permit premature abortion
fetch(`https://swapi.co/api/people/${id}/`, {
  signal: abortController.signal,
});

// abort the request in-flight
// the request will be marked as "cancelled" in devtools
abortController.abort();
Enter fullscreen mode Exit fullscreen mode

中止信号就像一个小事件发射器,你可以触发它(通过AbortController),并且每个使用此信号启动的请求都会收到通知并被取消。

我们来看看如何使用这个特性来解决竞态条件:

// Store abort controller which will permit to abort
// the last issued request
const lastAbortController = useRef();

useEffect(() => {
  setData(null);

  // When a new request is going to be issued,
  // the first thing to do is cancel the previous request
  if (lastAbortController.current) {
    lastAbortController.current.abort();
  }

  // Create new AbortController for the new request and store it in the ref
  const currentAbortController = new AbortController();
  lastAbortController.current = currentAbortController;

  // Issue the new request, that may eventually be aborted
  // by a subsequent request
  const currentPromise = fetchStarwarsHeroData(id, {
    signal: currentAbortController.signal,
  }).then(async data => {
    await delayRandomly();
    throwRandomly();
    return data;
  });

  currentPromise.then(
    result => setData(result),
    e => console.warn('fetch failure', e),
  );
}, [id]);
Enter fullscreen mode Exit fullscreen mode

这段代码乍一看没问题,但实际上我们仍然不安全。

让我们来看以下代码:

const abortController = new AbortController();

fetch('/', { signal: abortController.signal }).then(
  async response => {
    await delayRandomly();
    throwRandomly();
    return response.json();
  },
);
Enter fullscreen mode Exit fullscreen mode

如果在获取数据期间中止请求,浏览器会收到通知并采取相应措施。但如果中止发生在浏览器执行then()回调函数期间,它就无法处理这部分代码的中止,您需要自行编写相应的逻辑。如果中止发生在我们添加的伪延迟期间,则不会取消该延迟,也不会停止整个流程。

fetch('/', { signal: abortController.signal }).then(
  async response => {
    await delayRandomly();
    throwRandomly();
    const data = await response.json();

    // Here you can decide to handle the abortion the way you want.
    // Throwing or never resolving are valid options
    if (abortController.signal.aborted) {
      return new Promise();
    }

    return data;
  },
);
Enter fullscreen mode Exit fullscreen mode

让我们回到问题本身。这是最终的、安全的版本,它会在请求进行中时中止它,并且最终会利用中止操作来过滤结果。此外,我们还会使用钩子函数的清理功能,正如我在 Twitter 上被建议的那样,这会让代码更简洁一些。

useEffect(() => {
  setData(null);

  // Create the current request's abort controller
  const abortController = new AbortController();

  // Issue the request
  fetchStarwarsHeroData(id, {
    signal: abortController.signal,
  })
    // Simulate some delay/errors
    .then(async data => {
      await delayRandomly();
      throwRandomly();
      return data;
    })
    // Set the result, if not aborted
    .then(
      result => {
        // IMPORTANT: we still need to filter the results here,
        // in case abortion happens during the delay.
        // In real apps, abortion could happen when you are parsing the json,
        // with code like "fetch().then(res => res.json())"
        // but also any other async then() you execute after the fetch
        if (abortController.signal.aborted) {
          return;
        }
        setData(result);
      },
      e => console.warn('fetch failure', e),
    );

  // Trigger the abortion in useEffect's cleanup function
  return () => {
    abortController.abort();
  };
}, [id]);
Enter fullscreen mode Exit fullscreen mode

现在只有我们安全了。

使用库

手动完成所有这些操作既复杂又容易出错。希望一些库能够帮您解决这个问题。让我们一起来了解一下 React 中常用的数据加载库(以下并非完整列表)。

重制版

将数据加载到 Redux store 的方法有很多种。通常情况下,如果您使用 Redux-saga 或 Redux-observable,就不会有问题。对于 Redux-thunk、Redux-promise 和其他中间件,您可以参考后续章节中的“原生 React/Promise”解决方案。

Redux-saga

take您可能会注意到Redux-saga API 中有多种方法,但通常您会发现很多示例都使用takeLatest`.`。这是因为 `.`takeLatest可以保护您免受竞态条件的影响。

Forks a saga on each action dispatched to the Store
that matches pattern. And automatically cancels any previous saga
task started previously if it's still running.
Enter fullscreen mode Exit fullscreen mode
function* loadStarwarsHeroSaga() {
  yield* takeLatest(
    'LOAD_STARWARS_HERO',
    function* loadStarwarsHero({ payload }) {
      try {
        const hero = yield call(fetchStarwarsHero, [
          payload.id,
        ]);
        yield put({
          type: 'LOAD_STARWARS_HERO_SUCCESS',
          hero,
        });
      } catch (err) {
        yield put({
          type: 'LOAD_STARWARS_HERO_FAILURE',
          err,
        });
      }
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

之前的loadStarwarsHero生成器执行将被“取消”。遗憾的是,底层 API 请求实际上并不会被取消(你需要一个 `response`AbortSignal来实现这一点),但 Redux-saga 会确保成功/错误操作只会分发给 Redux,且仅针对最后请求的星战英雄。关于取消正在进行的请求,请参阅此问题。

您也可以选择退出此保护并使用taketakeEvery

Redux-observable

类似地,Redux-observable(实际上是 RxJS)也提供了一种解决方案switchMap

The main difference between switchMap and other flattening operators
is the cancelling effect. On each emission the previous inner observable
(the result of the function you supplied) is cancelled and
the new observable is subscribed. You can remember this
by the phrase switch to a new observable.
Enter fullscreen mode Exit fullscreen mode
const loadStarwarsHeroEpic = action$ =>
  action$.ofType('LOAD_STARWARS_HERO').switchMap(action =>
    Observable.ajax(`http://data.com/${action.payload.id}`)
      .map(hero => ({
        type: 'LOAD_STARWARS_HERO_SUCCESS',
        hero,
      }))
      .catch(err =>
        Observable.of({
          type: 'LOAD_STARWARS_HERO_FAILURE',
          err,
        }),
      ),
  );
Enter fullscreen mode Exit fullscreen mode

mergeMap如果你清楚自己在做什么,也可以使用其他 RxJS 操作符,但许多教程都会使用switchMap`remove`,因为它是一个更安全的默认选项。与 Redux-saga 类似,`remove` 不会取消正在进行的底层请求,但有一些方法可以实现这个功能。

阿波罗

Apollo 允许你传递 GraphQL 查询变量。每当星战英雄 ID 发生变化时,都会触发一个新的请求来加载相应的数据。你可以使用高阶组件 (HOC)、渲染属性或钩子函数,Apollo 始终保证,即使你请求了id: 2某个星战英雄的数据,你的 UI 也永远不会返回其他星战英雄的数据。

const data = useQuery(GET_STARWARS_HERO, {
  variables: { id },
});

if (data) {
  // This is always true, hopefully!
  assert(data.id === id);
}
Enter fullscreen mode Exit fullscreen mode

原生 React

有很多库可以将数据加载到 React 组件中,而无需全局状态管理解决方案。

我创建了react-async-hook:一个非常简单轻巧的 hooks 库,用于将异步数据加载到 React 组件中。它对原生 TypeScript 的支持非常好,并且通过使用上面讨论的技术来防止竞态条件。

import { useAsync } from 'react-async-hook';

const fetchStarwarsHero = async id =>
  (await fetch(
    `https://swapi.co/api/people/${id}/`,
  )).json();

const StarwarsHero = ({ id }) => {
  const asyncHero = useAsync(fetchStarwarsHero, [id]);
  return (
    <div>
      {asyncHero.loading && <div>Loading</div>}
      {asyncHero.error && (
        <div>Error: {asyncHero.error.message}</div>
      )}
      {asyncHero.result && (
        <div>
          <div>Success!</div>
          <div>Name: {asyncHero.result.name}</div>
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

其他保护措施:

  • react-async:非常相似,也支持渲染属性 API。
  • react-refetch:较早的项目,基于高阶组件(HOC)。

还有许多其他库可供选择,但我无法告诉你它们是否能保护你:请查看它们的实现方式。

注意:这是有可能的react-async-hookreact-async并将在未来几个月内合并。

注意:可以使用StarwarsHero key={id} id={id}/>一个简单的 React 变通方案,确保组件在每次 id 更改时重新挂载。这可以保护你(有时也很有用),但会增加 React 的工作量。

Vanilla Promise 和 Javascript

如果你正在处理原生 Promise 和 Javascript,这里有一些简单的工具可以帮助你避免这些问题。

如果您在 Redux 中使用 thunk 或 promises,这些工具也可用于处理竞态条件。

注意:其中一些工具实际上是react-async-hook的底层实现细节

可撤销的承诺

React 有一篇旧博文,名为《`isMounted()` 是一种反模式》,文中讲解了如何让 Promise 可取消,从而避免卸载后 `setState` 的警告。实际上,Promise 本身并不会被cancellable取消(底层 API 调用不会被取消),但你可以选择忽略或拒绝 Promise 的响应。

我创建了一个库,它非常棒,而且具有强制性,旨在简化这个过程:

import { createImperativePromise } from 'awesome-imperative-promise';

const id = 1;

const { promise, resolve, reject, cancel } = createImperativePromise(fetchStarwarsHero(id);

// will make the returned promise resolved manually
resolve({
  id,
  name: "R2D2"
});

// will make the returned promise rejected manually
reject(new Error("can't load Starwars hero"));

// will ensure the returned promise never resolves or reject
cancel();
Enter fullscreen mode Exit fullscreen mode

注意:所有这些方法都必须在底层 API 请求被解析或拒绝之前调用。如果 Promise 已被解析,则无法“取消解析”。

自动忽略最后点单

awesome-only-resolves-last-promise是一个库,用于确保我们只处理最后一次异步调用的结果:

import { onlyResolvesLast } from 'awesome-only-resolves-last-promise';

const fetchStarwarsHeroLast = onlyResolvesLast(
  fetchStarwarsHero,
);

const promise1 = fetchStarwarsHeroLast(1);
const promise2 = fetchStarwarsHeroLast(2);
const promise3 = fetchStarwarsHeroLast(3);

// promise1: won't resolve
// promise2: won't resolve
// promise3: WILL resolve
Enter fullscreen mode Exit fullscreen mode

悬念呢?

它应该可以避免这些问题,但我们还是等等正式版吧 :)

结论

在您下次使用 React 加载数据时,我希望您能考虑正确处理竞态条件。

我还建议在开发环境中为 API 请求硬编码一些延迟。这样更容易发现潜在的竞态条件和糟糕的加载体验。我认为强制执行这种延迟比要求每个开发者在开发者工具中启用慢网络选项更安全。

希望您觉得这篇文章有趣并且有所收获,这是我的第一篇技术博客文章 :)


最初发布在我的网站上

如果你喜欢,请转发分享

浏览器演示代码或帮我纠正博客仓库中的帖子拼写错误

想要获取更多类似内容,请订阅我的邮件列表并在推特上关注我

感谢我的审稿人:Shawn WangMateusz BurzyńskiAndrei CalazansAdrian CarolliClément OriolThibaud DuthoitBernard Pratz

文章来源:https://dev.to/sebastienlorber/handling-api-request-race-conditions-in-react-4j5b