在获取数据时,值得大书特书的,就是 竞态条件。也许你即使开发过大型的复杂应用,但依然没有遇到过这个问题。但要真遇到了,定位它 和 处理它 会非常有挑战。因为 fetch
和 任何 async
函数的本质都是 Promise,所以这一章的主要 内容是关于 Promise的。
我们会在处理一个有 竞态条件 问题的应用 的过程中,学习到:
- 什么是Promise,以及平平无奇的Promise如何不经意间产生 竞态条件 问题。
- 为什么 竞态条件 问题 会产生。
- 四种处理该问题的方法。
什么是Promise?
在讨论 竞态条件 之前,我们需要搞明白什么是 Promise,以及 为什么需要它。
本质上来说, Promise就是一个 期约。JS在执行代码时,通常是同步执行的:一行一行地往下走。而 Promise是其中几个异步执行代码的方式。我们可以通过Promise,来先执行下一行代码,而无需等待当前任务出结果。而Promise又会在当前任务出结果时,通知我们。
Promise使用最多的场景,就是数据获取:
js
console.log('first step');
fetch('/some-url')
.then(() => {
// wait for Promise to be done
// log stuff after the promise is dome
console.log('second step'); // will log THIRD (if successfull)
})
.catch(() => {
console.log('something bad happened'); // will log THIRD (if error happens)
})
console.log('third step');
这段代码是这样运作的:
Promise 与 竞态条件
Promise最有趣的是,它产生 竞态条件 的场景。为了研究 竞态条件 问题,我写了一个简单的demo。
代码示例: advanced-react.com/examples/15...
这个页面的左边有tabs 栏。点击tab时,右边会渲染出对应的页面,并获取相关的页面。但是,当我们来回切换tab时,用户体验就会非常糟糕:点到了第一个tab,但还是在渲染第二个tab的内容,等等。
这个应用的代码大概是这样的:有两个组件,App
组件和Page
组件。App
组件用来管理当前生效页面的状态,而Page
组件负责渲染详情页面的内容。
js
const App = () => {
const [page, setPage] = useState('1');
return (
<>
{/*left column buttons*/}
<button onClick={() => setPage('1')}>Issue 1</button>
<button onClick={() => setPage('2')}>Issue 2</button>
{/*the actual content*/}
<Page id={page} />
</>
);
};
Page
组件接受当前页面的id作为属性,使用该id作为参数获取对应的数据,并渲染这些数据。简化后的 Page
组件大致是这样的:
js
const Page = ({ id }: { id: string }) => {
const [data, setData] = useState({});
// pass id to fetch relevant data
const url = `/some-url/${id}`;
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then((r) => {
// save data from fetch request to state
setData(r);
});
}, [url]);
// render data
return (
<>
<h2>{data.title}</h2>
<p>{data.description}</p>
</>
);
};
有了id,我们可以决定从哪里获取数据。数据是在useEffect
钩子里获取的,然后把数据保存起来。这段代码看起来很好。那么,这段代码是如何引起 竞态条件的 呢?
产生竞态条件的原因
产生这个问题的两个根本原因: Promise本身 和 React生命周期。
从 React生命周期角度而言:
App
组件被挂载了Page
组件被挂载了,其默认属性id的值为1Page
组件中 第一次出触发useEffect
钩子
之后,Promise生效了:useEffect
里的fetch
是一个promise,一个异步操作。这个promise发送了真实的请求,React之后继续运行,并没有等待promise的结果。两秒之后,promise返回了结果。promise的 .then
被触发了,在.then
中,又调用了setData
来更改状态,所以Page
组件用新的状态更新了视图。
如果,视图已经被渲染完成了,我点击tab按钮,会有这个事件流:
App
组件更新了page状态,并传递给Page
组件- 状态更新引起了
App
组件的重新渲染 - 因此,
Page
组件也重新渲染了 Page
组件useEffect
钩子的依赖参数里有id
, 因为id发生了变化,useEffect
再次被触发useEffect
中的fetch
被新的id
触发了,2秒后,setData
被再次调用,Page
组件被更新,我们可以根据数据被更新后的视图。
但是,如果我在一个fetch
还在拉取数据完成时,点击另一个tab按钮,更改了id
,会发生什么事情?一件非常有趣的事情会发生!
App
组件会导致Page
组件又重新渲染。useEffect
也会被触发(因为id
变化了)。fetch
会在此被触发,React会继续运行下去。- 之后,第一个
fetch
完成了。这个fetch
也有setData
的引用,而setData
属于同一个Page
组件(记住 - 它只是更新,组件还是那个组件)。 setData
在第一个fetch
内被触发了,所以Page
组件用第一个fetch
得到数据进行更新。- 之后,第二个
fetch
完成了。第二个fetch
依旧在这儿,只是在后台运行着罢了。第二个fetch
也有Page
组件的setData
的引用,Page
组件会更新,只不过,这次用的是第二个fetch
里面的数据。
然后,竞态条件就产生了。但跳转到新的页面后,我们会看到页面的闪烁:页面先渲染了第一次fetch
所获取的数据,紧接着又渲染了第二次fetch
所获取的数据。
如果第二个fetch
比第一个fetch
先完成,就更有趣了。我们会先看到要导航的页面的,之后又回到了先前的页面。
你可以在下面的代码里看到这个场景。等待所有内容首次加载完成,然后导航到第二页,接着迅速再导航回第一页。
代码示例: advanced-react.com/examples/15...
这段朴实无华的代码,给我们带来了可怕的bug。该怎么解决呢?