如何解决前端常见的竞态问题?

发表于 2年以前  | 总阅读数:476 次

本文将深入研究 Promise 是如何导致竞态条件的,以及防止竞态条件发生的几种方法!

1 . Promise和竞态条件

(1)Promise

我们知道,JavaScript 是单线程的,代码会同步执行,即按顺序从上到下执行。Promise 是可供我们异步执行的方法之一。使用 Promise,可以触发一个任务并立即进入下一步,而无需等待任务完成,该任务承诺它会在完成时通知我们。

最重要和最广泛使用 Promise 的情况之一就是数据获取。不管是 fetch 还是 axios,Promise 的行为都是一样的。

从代码的角度来看,就是这样的:

console.log('first step');

fetch('/some-url') // 创建 Promise
  .then(() => { // 等待 Promise 完成
      console.log('second step'); // 成功
    }
  )
  .catch(() => {
    console.log('something bad happened'); // 发生错误
  })

console.log('third step');

这里会创建 Promise fetch('/some-url'),并在 .then 中获得结果时执行某些操作,或者在 .catch 中处理错误。

(2)实际应用

Promise 中最有趣的部分之一是它可能会导致竞态条件。下面是一个非常简单的应用:


import "./styles.scss";
import { useState, useEffect } from "react";

type Issue = {
  id: string;
  title: string;
  description: string;
  author: string;
};

const url1 =
  "https://run.mocky.io/v3/ebf1b8f3-0368-4e3b-a965-1c5fdcc5d490?mocky-delay=2000ms";
const url2 =
  "https://run.mocky.io/v3/27398801-05e2-4a62-8719-2a2d40974e52?mocky-delay=2000ms";

const Page = ({ id }: { id: string }) => {
  const [data, setData] = useState<Issue>({} as Issue);
  const [loading, setLoading] = useState(false);
  const url = id === "1" ? url1 : url2;

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then((r) => r.json())
      .then((r) => {
        setData(r);
        console.log(r);
        setLoading(false);
      });
  }, [url]);

  if (!data.id || loading) return <>loading issue {id}</>;

  return (
    <div>
      <h1>My issue number {data.id}</h1>
      <h2>{data.title}</h2>
      <p>{data.description}</p>
    </div>
  );
};

const App = () => {
  const [page, setPage] = useState("1");

  return (
    <div className="App">
      <div className="container">
        <ul className="column">
          <li>
            <button onClick={() => setPage("1")}>Issue 1</button>
          </li>
          <li>
            <button onClick={() => setPage("2")}>Issue 2</button>
          </li>
        </ul>

        <Page id={page} />
      </div>
    </div>
  );
};

export default App;

在线实例:https://codesandbox.io/s/app-with-race-condition-fzyrj5?from-embed

页面效果如下:

可以看到,在左侧有两个选项卡,切换选项卡就会发送一个数据请求,请求的数据会在右侧展示。当我们在选项卡之间进行快速切换时,内容会发生闪烁,数据也是随机出现。如下:

为什么会这样呢?我们来看一下这个应用是怎么实现的。这里有两个组件,一个是根组件 APP,它会管理 activepage 状态,并渲染导航按钮和实际的 Page 组件。

const App = () => {
  const [page, setPage] = useState("1");

  return (
    <>
      <!-- 左侧按钮 -->
      <button onClick={() => setPage("1")}>Issue 1</button>
      <button onClick={() => setPage("2")}>Issue 2</button>

      <!-- 实际内容 -->
      <Page id={page} />
    </div>
  );
};

另一个就是 Page 组件,它接受活动页面 的 id 作为 props,发送一个 fetch 请求来获取数据,然后渲染它。简化的实现(没有加载状态)如下所示:

const Page = ({ id }: { id: string }) => {
  const [data, setData] = useState({});

  // 通过 id 获取相关数据
  const url = `/some-url/${id}`;

  useEffect(() => {
    fetch(url)
      .then((r) => r.json())
      .then((r) => {
        setData(r);
      });
  }, [url]);

  return (
    <>
      <h2>{data.title}</h2>
      <p>{data.description}</p>
    </>
  );
};

这里通过 id 来确定获取数据的 url。然在 useEffect 中发送 fetch 请求,并将获取到的数据存储在 state 中。那么竞态条件和奇怪的行为是从哪里来的呢?

(3)竞态条件

这可以归结于两个方面:Promises 的本质React 生命周期

从生命周期的角度来看,执行如下:

  1. App 组件挂载;
  2. Page 组件使用默认的 prop 值 1 挂载;
  3. Page 组件中的 useEffect 首次执行

那么 Promises 的本质就生效了:useEffect 中的 fetch 是一个 Promise,它是异步操作。它发送实际的请求,然后 React 继续它的生命周期而不会等待结果。大约 2 秒后,请求完成,.then 开始执行,在其中我们调用 setData 来将获取到的数据保存状态中,Page 组件使用新数据更新,我们在屏幕上看到它。

如果在所有内容渲染完成后再点击导航按钮,事件流如下:

  1. App 组件将其状态更改为另一个页面;
  2. 状态改变触发 App 组件的重新渲染;
  3. Page 组件也会重新渲染;
  4. Page 组件中的 useEffect 依赖于 idid变了就会再次触发 useEffect
  5. useEffect 中的 fetch 将使用新 id 触发,大约 2 秒后 setData 将再次调用,Page 组件更新,我们将在屏幕上看到新数据。

但是,如果在第一次 fetch 正在进行但尚未完成时单击导航按钮,这时 id 发生了变化,会发生什么呢?

  1. App 组件将再次触发 Page 的重新渲染;
  2. useEffect 将再次被触发(因为依赖的 id 更改);
  3. fetch 将再次被触发;
  4. 第一次 fetch 完成,setData 被触发,Page 组件使用第一次 fecth 的数据进行更新;
  5. 第二次 fetch 完成,setData 被触发,Page 组件使用第二次 fetch 的数据进行更新。

这样,竞态条件就产生了。在导航到新页面后,我们会看到内容的闪烁:第一次 fetch 的内容先被渲染,然后被第二次 fetch 的内容替换。

如果第二次 fetch 在第一次 fetch 之前完成,这种效果会更加有趣。我们会先看到下一页的正确内容,然后将其替换为上一页的错误内容。

来看下面的例子,等到第一次加载完所有内容,然后导航到第二页,然后快速导航回第一页。页面效果如下:

在线实例:https://codesandbox.io/s/app-without-race-condition-reversed-yuoqkh?from-embed

可以看到,我们先点击 Issues 2,再点击的 Issue 1。而最终先显示了 Issue 1 的结果,后显示了 Issue 2 的结果。那该如何解决这个问题呢?

2 . 修复竞态条件

(1)强制重新挂载

其实这一个并不是解决方案,它更多地解释了为什么这些竞态条件实际上并不会经常发生,以及为什么我们通常在常规页面导航期间看不到它们。

想象一下如下组件:

const App = () => {
  const [page, setPage] = useState('issue');

  return (
    <>
      {page === 'issue' && <Issue />}
      {page === 'about' && <About />}
    </>
  )
}

这里我们并没有传递 props,IssueAbout 组件都有各自的 url,它们可以从中获取数据。并且数据获取发生在 useEffect Hook 中:

const About = () => {
  const [about, setAbout] = useState();

  useEffect(() => {
    fetch("/some-url-for-about-page")
      .then((r) => r.json())
      .then((r) => setAbout(r));
  }, []);
  ...
}

这次导航时没有发生竞态条件。尽可能多地和尽可能快地进行导航:应用运行正常。

在线实例:https://codesandbox.io/s/issue-and-about-no-bug-5udo04?from-embed

这是为什么呢?答案就在这里:{page === ‘issue’ && <Issue />}。当 page 值发生更改时,IssueAbout 页面都不会重新渲染,而是会重新挂载。当值从 issue 更改为 about 时,Issue 组件会自行卸载,而 About 组件会进行挂载。

从 fetch 的角度来看:

  1. App 组件首先渲染,挂载 Issue 组件,并获取相关数据;
  2. 当 fetch 仍在进行时导航到下一页时,App 组件会卸载 Issue 页面并挂载 About 组件,它会执行自己的数据获取。

当 React 卸载一个组件时,就意味着它已经完全消失了,从屏幕上消失,其中发生的一切,包括它的状态都丢失了。将其与前面的代码进行比较,我们在其中编写了 <Page id={page} />,这个 Page 组件从未被卸载,我们只是在导航时重新使用它和它的状态。

回到卸载的情况,当我们跳转到在 About 页面时,Issue 的 fetch 请求完成时,Issue 组件的 .then 回调将尝试调用 setIssue,但是组件已经消失了,从 React 的角度来看,它已经不存在了。所以 Promise 会消失,它获取的数据也会消失。

顺便说一句,React 中经常会提示:Can't perform a React state update on an unmounted component当组件已经消失后完成数据获取等异步操作时就会出现这个警告。

理论上,这种行为可以用来解决应用中的竞态条件:只需要强制页面组件重新挂载。可以使用 key 属性:

<Page id={page} key={page} />

在线实例:https://codesandbox.io/s/app-without-race-condition-twv1sm?file=/src/App.tsx

⚠️ 这并不是推荐使用的竞态条件问题的解决方案,其影响较大:性能可能会受到影响,状态的意外错误,渲染树下的 useEffect 意外触发。有更好的方法来处理竞争条件(见下文)。

(2)丢弃错误的结果

解决竞争条件的另外一种方法就是确保传入 .then 回调的结果与当前“active”的 id 匹配。

如果结果可以返回用于生成 url 的id,就可以比较它们,如果不匹配就忽略它。这里的技巧就是在函数中避免 React 生命周期和本地数据,并在 useEffect 中访问最新的 id。React ref 就非常适合:

const Page = ({ id }) => {
  // 创建 ref
  const ref = useRef(id);

  useEffect(() => {
    // 用最新的 id 更新 ref 值
    ref.current = id;

    fetch(`/some-data-url/${id}`)
      .then((r) => r.json())
      .then((r) => {
        // 将最新的 id 与结果进行比较,只有两个 id 相等时才更新状态
        if (ref.current === r.id) {
          setData(r);
        }
      });
  }, [id]);
}

在线示例:https://codesandbox.io/s/app-with-race-condition-fixed-with-id-and-ref-jug1jk?file=/src/App.tsx

我们也可以直接比较 url

const Page = ({ id }) => {
  // 创建 ref
  const ref = useRef(id);

  useEffect(() => {
    // 用最新的 url 更新 ref 值
    ref.current = url;

    fetch(`/some-data-url/${id}`)
      .then((result) => {
        // 将最新的 url 与结果进行比较,仅当结果实际上属于该 url 时才更新状态
        if (result.url === ref.current) {
          result.json().then((r) => {
            setData(r);
          });
        }
      });
  }, [url]);
}

在线示例:https://codesandbox.io/s/app-with-race-condition-fixed-with-url-and-ref-whczob?file=/src/App.tsx

(3)丢弃以前的结果

useEffect 有一个清理函数,可以在其中清理订阅等内容。它的语法如下所示:

useEffect(() => {
  return () => {
    // 清理的内容
  }
}, [url]);

清理函数会在组件卸载后执行,或者在每次更改依赖项导致的重新渲染之前执行。因此重新渲染期间的操作顺序将如下所示:

  • url 更改;
  • 清理函数被触发;
  • useEffect 的实际内容被触发。

JavaScript 中函数和闭包的性质允许我们这样做:

useEffect(() => {
  // useEffect中的局部变量
  let isActive = true;

  // 执行 fetch 请求

  return () => {
    // 上面的局部变量
    isActive = false;
  }
}, [url]);

我们引入了一个局部布尔变量 isActive,并在 useEffect 运行时将其设置为 true,在清理时将其设置为 false。每次重新渲染时都会重新创建 useEffect 中的变量,因此最新的 useEffect 会将 isActive 始终重置为 true。但是,在它之前运行的清理函数仍然可以访问前一个变量的作用域,并将其重置为 false。这就是 JavaScript 闭包的工作方式。

虽然 fetch 是异步的,但仍然只存在于该闭包中,并且只能访问启动它的 useEffect 中的局部变量。因此,当检查 .then 回调中的 isActive 时,只有最近的运行(即尚未清理的运行)才会将变量设置为 true。所以,现在只需要检查是否处于活动闭包中,如果是,则将获取的数据设置状态。如果不是,什么都不做,数据将再次消失。

useEffect(() => {
  // 将 isActive 设置为 true
  let isActive = true;

  fetch(`/some-data-url/${id}`)
    .then((r) => r.json())
    .then((r) => {
      // 如果闭包处于活动状态,更新状态
      if (isActive) {
        setData(r);
      }
    });

  return () => {
    // 在下一次重新渲染之前将 isActive 设置为 false
    isActive = false;
  }
}, [id]);

在线示例:https://codesandbox.io/s/app-with-race-condition-fixed-with-cleanup-4du0wf?file=/src/App.tsx

(4)取消之前的请求

对于竞态条件问题,我们可以取消之前的请求,而不是清理或比较结果。如果之前的请求不能完成(取消),那么使用过时数据的状态更新将永远不会发生,问题也就不会存在。可以为此使用 AbortController 来取消请求。

我们可以在 useEffect 中创建 AbortController 并在清理函数中调用 .abort()

useEffect(() => {
  // 创建 controller
  const controller = new AbortController();

  // 将 controller 作为signal传递给 fetch
  fetch(url, { signal: controller.signal })
    .then((r) => r.json())
    .then((r) => {
      setData(r);
    });

  return () => {
    // 中止请求
    controller.abort();
  };
}, [url]);

这样,在每次重新渲染时,正在进行的请求将被取消,新的请求将是唯一允许解析和设置状态的请求。

中止一个正在进行的请求会导致 Promise 被拒绝,所以需要在 Promise 中捕捉错误。因为 AbortController 而拒绝会给出特定类型的错误:

fetch(url, { signal: controller.signal })
  .then((r) => r.json())
  .then((r) => {
    setData(r);
  })
  .catch((error) => {
    // 由于 AbortController 导致的错误
    if (error.name === 'AbortError') {
      // ...
    } else {
      // ...
    }
  });

在线示例:https://codesandbox.io/s/app-with-race-condition-fixed-with-abort-controller-6u0ckk?file=/src/App.tsx

3 . Async/await

上面我们说了 Promise 的竞态条件的解决方案,那 Async/await 会有所不同吗?其实,Async/await 只是编写 Promise 的一种更好的方式。它只是将 Promise 变成“同步”函数,但不会改变它们的异步的性质。

对于 Promise:

fetch('/some-url')
  .then(r => r.json())
  .then(r => setData(r));

使用 Async/await 这样写:

const response = await fetch('/some-url');
const result = await response.json();
setData(result);

使用 async/await 而不是“传统”promise 实现的完全相同的应用,将具有完全相同的竞态条件。以上所有解决方案和原因都适用,只是语法会略有不同。可以在在线示例中查看:https://codesandbox.io/s/app-with-race-condition-async-away-q39lgi?file=/src/App.tsx

参考文章:https://www.developerway.com/posts/fetching-in-react-lost-promises

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/KHxkXW7vUKZKeG9EBe5KcQ

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录