如何解决前端常见的race问题?
时间:2023-03-17 20:08:54
科技观察
这篇文章将深入探讨Promise是如何导致竞争条件的,以及一些防止竞争条件发生的方法!一、Promise和raceconditions(1)Promise我们知道JavaScript是单线程的,代码会同步执行,即从上到下顺序执行。Promises是我们可以异步执行的方法之一。使用Promise,可以触发一个任务并立即进入下一步,而无需等待任务完成,承诺它会在完成时通知我们。Promises最重要和广泛使用的案例之一是数据获取。不管是fetch还是axios,Promise的行为都是一样的。从代码的角度来看,它看起来像这样:console.log('firststep');fetch('/some-url')//创建一个Promise.then((){//等待Promise完成console.log('secondstep');//success}).catch((){console.log('somethingbadhappened');//anerroroccurred})console.log('thirdstep');这里会创建Promisefetch('/some-url'),然后在.then中得到结果时做一些事情,或者在.catch中处理错误。(2)在实践中使用Promises最有趣的部分之一是它可能导致竞争条件。下面是一个非常简单的应用:import"./styles.scss";import{useState,useEffect}from"react";typeIssue={id:string;标题:字符串;描述:字符串;作者:字符串;};consturl1="https://run.mocky.io/v3/ebf1b8f3-0368-4e3b-a965-1c5fdcc5d490?mocky-delay=2000ms";consturl2="https://run.mocky.io/v3/27398801-05e2-4a62-8719-2a2d40974e52?mocky-delay=2000ms";constPage=({id}:{id:string})=>{const[data,setData]=useState({}作为问题);const[loading,setLoading]=useState(false);consturl=id==="1"?网址1:网址2;useEffect((){setLoading(true);fetch(url).then((r)=>r.json()).then((r)=>{setData(r);console.log(r);setLoading(假);});},[url]);if(!data.id||loading)return<>loadingissue{id}>;return(我的问题编号{data.id}
{data.title}
{data.description}
);};constApp=(){const[page,setPage]=useState("1");返回(setPage("1")}>问题1setPage("2")}>问题2
);};exportdefaultApp;online示例:https://codesandbox.io/s/app-with-race-condition-fzyrj5?from-embed页面效果如下:可以看到左边有两个标签,切换标签会发送一个数据请求,请求的数据会显示在右边。当我们在标签页之间快速切换时,内容会闪烁,数据会随机出现。如下:为什么会这样?让我们来看看这个应用程序是如何实现的。这里有两个组件,一个是根组件APP,它管理活动页面状态,并渲染导航按钮和实际的Page组件。constApp=(){const[page,setPage]=useState("1");return(<>
setPage("1")}>Issue1setPage("2")}>第2期 );};另一个是Page组件,它接受活动页面的id作为props,发送一个fetch请求来获取数据,并渲染它。一个简化的实现(没有加载状态)看起来像这样:constPage=({id}:{id:string})=>{const[data,setData]=useState({});//通过id获取相关数据consturl=`/some-url/${id}`;useEffect((){fetch(url).then((r)=>r.json()).then((r)=>{setData(r);});},[url]);返回(<>
{data.title}
{data.description}
>);};确定用于获取数据的url。然后在useEffect中发送fetch请求,将获取到的数据存储到state中。那么竞争条件和奇怪的行为从何而来?(3)Raceconditions这可以归结为两个方面:Promises的性质和React生命周期。从生命周期来看,执行过程如下:App组件挂载;Page组件以默认prop值为1挂载;Page组件中的useEffect是第一次执行,然后Promises的本质生效:useEffect中的fetch是一个Promise,是一个异步操作。它发送实际请求,然后React继续其生命周期而不等待结果。大约2秒后,请求完成,然后.then开始执行,我们调用setData以使用获取的数据保存状态,Page组件使用新数据更新,我们在屏幕上看到它。如果在所有内容渲染完成后点击导航按钮,事件流程如下:App组件改变状态到另一个页面;状态变化触发App组件的重新渲染;页面组件也重新呈现;Page组件中的useEffect依赖于id,如果id发生变化,useEffect会再次触发;useEffect中的fetch将被新id触发,setData将在大约2秒后再次调用,Page组件将被更新,我们将在屏幕上看到新数据。但是,如果在第一次获取正在进行但尚未完成时单击导航按钮时id发生变化,会发生什么情况?App组件会再次触发Page的重新渲染;useEffect会再次触发(因为依赖的id改变了);fetch将再次被触发;第一次fetch完成,触发setData,Page组件更新为第一次fetch的数据;第二次fetch完成,触发setData,Page组件更新为第二次fetch的数据。因此,出现竞争条件。导航到一个新页面后,我们看到内容闪烁:第一次获取的内容被呈现,然后被第二次获取的内容替换。如果第二次提取在第一次提取之前完成,则此效果会更加有趣。我们首先在下一页看到正确的内容,然后用上一页的错误内容替换它。看下面的例子,等到第一次加载完所有内容,然后导航到第二页,然后快速导航回第一页。页面效果如下:在线示例:https://codesandbox.io/s/app-without-race-condition-reversed-yuoqkh?from-embed可以看到我们先点击Issues2,再点击Issue1.最后先显示Issue1的结果,再显示Issue2的结果。那么如何解决这个问题呢?2.Fixraceconditions(1)Forceremount这个实际上不是一个解决方案,它更多地解释了为什么这些竞争条件实际上并不经常发生,以及为什么我们通常看不到它们。想象这样一个组件:constApp=(){const[page,setPage]=useState('issue');return(<>{page==='issue'&&
}{page==='about'&&
}>)}这里不传props,Issue和About组件有自己的自己的网址,他们可以从中获取数据。数据获取发生在useEffectHook中:constAbout=(){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'&&
}。当pagevalue改变时,Issue和About页面都不会重新渲染,而是重新挂载。当值从issue更改为about时,Issue组件自行卸载并安装About组件。从fetch的角度看:App组件先渲染,挂载Issue组件,获取相关数据;在fetch还在进行的情况下导航到下一个页面时,App组件卸载Issue页面并挂载About组件,执行自己的数据获取。当React卸载一个组件时,这意味着它完全消失了,离开了屏幕,其中发生的一切,包括它的状态,都丢失了。将此与我们之前编写的代码进行比较
,此Page组件永远不会卸载,我们只是在导航时重用它及其状态。回到卸载的情况,当我们跳转到About页面,当Issue的fetch请求完成后,Issue组件的.then回调会尝试调用setIssue,但是组件已经消失了。从React的角度来看,它已经不存在了。所以Promise消失了,它检索到的数据也消失了。对了,React经常会提示:Can'tperformaReactstateupdateonanunmountedcomponent。当组件已经消失并完成数据获取等异步操作时,就会出现这个警告。理论上,此行为可用于解决应用程序中的竞争条件:只需强制重新挂载页面组件即可。可以使用key属性:
在线示例:https://codesandbox.io/s/app-without-race-condition-twv1sm?file=/src/App.tsx这个不是racecondition问题的推荐方案,影响比较大:可能会影响性能,state出现意外错误,rendertree下useEffect的意外触发。有更好的方法来处理竞争条件(见下文)。(2)丢弃错误的结果解决竞争条件的另一种方法是确保传递给.then回调的结果与当前“活动”id相匹配。如果结果可以返回生成url的id,可以比较,不匹配则忽略。这里的技巧是避免函数中的React生命周期和本地数据,并在useEffect中访问最新的id。Reactref非常适合这个:constPage=({id})=>{//createrefconstref=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我们也可以直接比较urls:constPage=({id})=>{//createrefconstref=useRef(id);useEffect((){//使用最新的url更新ref值ref.current=url;fetch(`/some-data-url/${id}`).then((result)=>{//比较带有结果的最新url并且仅当结果实际上属于该url时才更新状态});}});},[url]);}在线示例:https://codesandbox.io/s/app-with-race-condition-fixed-with-url-and-ref-whczob?file=/src/App.tsx(3)删除之前的resultuseEffect有一个cleanup函数,可以在这里清理订阅和其他内容。它的语法如下:useEffect((){return(){//清理内容}},[url]);cleanup函数会在组件卸载后执行,或者在每次依赖变化引起的重新渲染前执行。因此,重新渲染期间的操作顺序将如下所示:url已更改;清理功能已启动;useEffect的实际内容被触发。JavaScript中函数和闭包的性质允许我们这样做:useEffect((){//useEffect中的局部变量letisActive=true;//执行获取请求return(){//上面的局部变量isActive=false;}},[网址]);我们引入了一个局部布尔变量isActive并在useEffect运行时将其设置为true,在清理时将其设置为false。每次重新渲染时都会重新创建useEffect中的变量,因此最新的useEffect总是将isActive重置为true。但是,在它之前运行的清理函数仍然可以访问前一个变量的范围并将其重置为false。这就是JavaScript闭包的工作方式。虽然fetch是异步的,但它仍然仅存在于该闭包中,并且只能访问启动它的useEffect中的局部变量。因此,在.then回调中检查isActive时,只有最近一次运行(即尚未清理的运行)会将变量设置为true。所以,现在只需要检查我们是否处于活动闭包中,如果是,则使用获取的数据设置状态。如果没有,则什么也不做,数据将再次消失。useEffect((){//将isActive设置为trueletisActive=true;fetch(`/some-data-url/${id}`).then((r)=>r.json()).then((r)=>{//如果闭包处于活动状态,则更新状态if(isActive){setData(r);}});return(){//在下次重新渲染之前将isActive设置为falseisActive=false;}},[ID]);在线示例:https://codesandbox.io/s/app-with-race-condition-fixed-with-cleanup-4du0wf?file=/src/App.tsx(4)取消PreviousRequests对于racecondition问题,我们可以取消以前的请求而不是清理或比较结果。如果之前的请求无法完成(取消),那么使用陈旧数据的状态更新将永远不会发生,问题也不存在。AbortController可用于取消请求。我们可以在useEffect中创建AbortController并在清理函数中调用.abort():useEffect((){//创建控制器constcontroller=newAbortController();//将控制器作为信号传递给获取fetch(url,{signal:controller.signal}).then((r)=>r.json()).then((r)=>{setData(r);});return(){//中止请求controller.abort();};},[网址]);这样,在每次重新渲染时,正在进行的请求将被取消,新请求将是唯一允许解析和设置状态的请求。中止正在进行的请求将导致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.tsx3.Async/await上面我们提到了Promise的竞争动态条件的解决方案,异步/等待会有所不同吗?实际上,Async/await只是一种更好的Promises编写方式。它只是将Promises变成“同步”函数,而不改变它们的异步性质。对于Promise:fetch('/some-url').then(rr.json()).then(rsetData(r));使用异步/等待写入:constresponse=awaitfetch('/some-url');constresult=awaitresponse.json();设置数据(结果);使用async/await而不是“传统”承诺实现的完全相同的应用程序将具有完全相同的竞争条件。以上所有解决方案和原因都适用,只是语法会略有不同。这可以在在线示例中看到:https://codesandbox.io/s/app-with-race-condition-async-away-q39lgi?file=/src/App.tsx