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

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

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

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

大家好,我是 CUGGZ。

本文将深入研究 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');

这里会创建 Promisefetch('/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,它会管理active的page状态,并渲染导航按钮和实际的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依赖于id,id变了就会再次触发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,Issue和About组件都有各自的url,它们可以从中获取数据。并且数据获取发生在useEffectHook 中:

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值发生更改时,Issue和About页面都不会重新渲染,而是会重新挂载。当值从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。Reactref就非常适合:

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。

©本文为清一色官方代发,观点仅代表作者本人,与清一色无关。清一色对文中陈述、观点判断保持中立,不对所包含内容的准确性、可靠性或完整性提供任何明示或暗示的保证。本文不作为投资理财建议,请读者仅作参考,并请自行承担全部责任。文中部分文字/图片/视频/音频等来源于网络,如侵犯到著作权人的权利,请与我们联系(微信/QQ:1074760229)。转载请注明出处:清一色财经

(0)
打赏 微信扫码打赏 微信扫码打赏 支付宝扫码打赏 支付宝扫码打赏
清一色的头像清一色管理团队
上一篇 2023年5月6日 16:52
下一篇 2023年5月6日 16:52

相关推荐

发表评论

登录后才能评论

联系我们

在线咨询:1643011589-QQbutton

手机:13798586780

QQ/微信:1074760229

QQ群:551893940

工作时间:工作日9:00-18:00,节假日休息

关注微信