处理 竞态条件:强制重新挂载
从本质上讲,第一个解决方案甚至算不上真正的解决方案,它更像是一种解释,说明了为什么这些竞态条件实际上并不常发生,以及为什么我们在常规的页面导航过程中通常不会遇到它们。
先忘记之前的示例代码,我们现在看到了这样的代码:
js
const App = () => {
const [page, setPage] = useState('issue');
return (
<>
{page === 'issue' && <Issue />}
{page === 'about' && <About />}
</>
)
}
这个App
组件没有传递属性给子组件,而Issue
和 About
组件有其获取数据的、独特的URL。而在useEffect
里获取数据的逻辑,和之前是一样的:
js
const About = () => {
const [about, setAbout] = useState();
useEffect(() => {
fetch("/some-url-for-about-page")
.then((r) => r.json())
.then((r) => setAbout(r));
}, []);
...
}
这个应用没有出现 竞态条件 问题。无论你切换导航多快,这个导航功能都是正常运行的。
代码示例: advanced-react.com/examples/15...
为什么?
答案在{page === 'issue' && <Issue />}
。Issue
和 About
在page
值变动时,并不是被重新渲染,而是被重新挂载了。当page
从issue
变为about
,Issue
组件被卸载了,而About
组件挂载了。
而从数据获取的角度,发生了:
App
组件首先被渲染,加载了Issue
,之后开启了数据获取。- 之后我点击了
About
组件,此时Issue
的fetch
还在进行中,而App
组件卸载了About
组件,开始挂载Issue
组件,此时,Issue
的fetch
被终止了。
当React卸载一个组件时,意味着这个组件消失了。当一个组件消失时,人们无法访问它,关于这个组件的内的事务也消失了,包括状态。而这个代码与之前的代码是这样的,<Page id={page} />
。这个Page
不会被卸载。
而使用{page === 'about' && <About />}
时,当我被导航到About
页面时,Issue
组件的fetch
请求结束了,要调用setIssue
来改变状态了。但是这个组件已经消失了。所以从React的视角看,它不存在了。所以这个promise请求也没了。
顺便问一下,你还记得那个吓人的警告 "无法对已卸载的组件执行 React 状态更新" 吗?以前在这类情况中就会出现这个警告:当类似数据获取这样的异步操作在组件已经被卸载之后才完成。说 "以前" 是因为这个警告现在也没了。它是最近才被移除的。
w不管怎样,从理论上讲,这种行为可以用来解决原始应用程序中的竞态条件问题:我们所需要做的就是在导航时强制页面(Page)组件重新挂载。为此,我们可以使用 "key" 属性:
js
<Page id={page} key={page} />
我们从第六章的diff原理可知,当一个元素的"key"发生变化时,React会卸载旧的元素,再挂载新的元素,即使这两个元素类型相同。
然而,这个方法并不是解决 竞态条件 问题的 最优解。使用这个方法有太多事情要顾虑了:应用的性能被影响了,会有很多意外的bugs,等等。
处理 竞态条件:跳过错误的结果
有一个更轻量级的解决方法(不用再卸载组件了):校验.then
回调里的结果与当前"生效"的id
是否一致。
如果结果返回的是用于生成该网址的那个 ID,我们就可以直接对它们进行比较。而如果它们不匹配,就忽略这些结果。这里的诀窍在于绕开 React 的生命周期以及函数中局部作用域的数据,并且在 useEffect 的所有迭代中,即便那些 "过时" 的迭代,也能获取到 "最新的" ID。这又是一个关于 Refs 的应用场景,我们在第 9 章 "Refs:从存储数据到命令式 API" 中讨论过 Refs。
js
const Page = ({ id }) => {
// create ref
const ref = useRef(id);
useEffect(() => {
// update ref value with the latest id
ref.current = id;
fetch(`/some-data-url/${id}`)
.then((r) => r.json())
.then((r) => {
// compare the latest id with the result
// only update state if the result actually belongs to that id
if (ref.current === r.id) {
setData(r);
}
});
}, [id]);
};
代码示例: advanced-react.com/examples/15...
或者这样:
js
const Page = ({ id }) => {
// create ref
const ref = useRef(id);
useEffect(() => {
// update ref value with the latest url
ref.current = url;
fetch(`/some-data-url/${id}`).then((result) => {
// compare the latest url with the result's url
// only update state if the result actually belongs to that url
if (result.url === ref.current) {
result.json().then((r) => {
setData(r);
});
}
});
}, [url]);
};
代码示例: advanced-react.com/examples/15...
处理 竞态条件:跳过所有以前的结果
不喜欢之前的解法,或者觉得使用ref很奇怪?没问题,还有一个方法。useEffect
内部有一个"清除"函数,它可以用来清除订阅。
这个"清除"函数的语法是这样的:
js
// normal useEffect
useEffect(() => {
// "cleanup" function - function that is returned in useEffect
return () => {
// clean something up here
};
// dependency - useEffect will be triggered every time url has changed
}, [url]);
这个清楚函数会在组件被卸载时运行,或者在每次依赖变化导致的重新渲染之前。所以,在重新渲染期间,是按照这个顺序进行的:
url
变化- "清除函数"被触发
useEfffect
被触发了
JavaScript的函数和闭包,我们可以这样:
js
useEffect(() => {
// local variable for useEffect's run
let isActive = true;
// do fetch here
return () => {
// local variable from above
isActive = false;
};
}, [url]);
我们引入了一个本地的布尔值isActive
,在useEffct
运行时,令isActive
为true,在"清除"函数执行时,令isActive
为false。useEffct
里的函数会在每次重新渲染时重新创建,所以对于最新执行的useEffct
,isActive
永远是true。但是,之前运行的"清除"函数,依然有权限访问之前执行的函数的上下文,它会把isActive
设置为false。这是JavaScript闭包的工作原理。
虽然fetch
Promise是异步运行的,它依然有一个闭包,也有权限去访问启动它的useEffect
里的函数。所以我们在检查.then
回调里的isActive
时,只有最新的运行并没有启动"清除"函数,也只有最新的运行里isActive
为true。所以我们只要通过isActive
为真来判定是否在最新的闭包里。如果在最新的闭包,我们就调用setData
函数。如果不是最新的,就什么都不做,而这些数据也会消失在虚空中。
js
useEffect(() => {
// set this closure to "active"
let isActive = true;
fetch(`/some-data-url/${id}`)
.then((r) => r.json())
.then((r) => {
// if the closure is active - update state
if (isActive) {
setData(r);
}
});
return () => {
// set this closure to not active before next re-render
isActive = false;
};
}, [id]);
代码示例: advanced-react.com/examples/15...
处理 竞态条件:取消之前的所有请求
如果你也觉得处理JavaScript的闭包问题很烧脑,那我们还有另一个解法。
除了调用"清除"函数或者比较是否是正确的结果外,我们还可以清除所有之前的数据请求。如果之前的请求没有完成,那么.then
回调函数里的setData
方法将永远不会生效,这个问题也就不复存在了。我们可以使用AbortController
来实现这个效果。
在useEffect
里创建AbortController
和使用它,也是非常简单的。
js
useEffect(() => {
// create controller here
const controller = new AbortController();
// pass controller as signal to fetch
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then((r) => {
setData(r);
});
return () => {
// abort the request here
controller.abort();
};
}, [url]);
所有,每次重新渲染时,原来还在进行中的请求会被停止,只有当前渲染所触发的useEffct
钩子会被执行,并调用setData
。
中止一个正在进行的请求会导致 Promise 被拒绝,所以你需要捕获错误,以消除控制台中那些吓人的警告信息。但无论是否使用AbortController
,正确处理 Promise 的拒绝情况都是个好主意,所以这是你在任何策略中都应该做的事情。因AbortController
而导致的拒绝会产生一种特定类型的错误,这使得将其从常规错误处理中排除变得很容易。
js
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then((r) => {
setData(r);
})
.catch((error) => {
// error because of AbortController
if (error.name === 'AbortError') {
// do nothing
} else {
// do something, it's a real error!
}
});
代码示例: advanced-react.com/examples/15...
Async/await 改变了什么吗
并没有。Async/await 就是把promise换了个写法。它就是把异步执行的promise变得看起来像同步执行的代码。
以前是这样的:
js
fetch('/some-url')
.then((r) => r.json())
.then((r) => setData(r));
用了 Async/await后:
js
const response = await fetch('/some-url');
const result = await response.json();
setData(result);
代码示例: advanced-react.com/examples/15...
除了写法不一样,处理 竞态条件 的逻辑都是一样的。
知识概要
- 竞态条件 问题,可能在一个promise完成后,多次调用React的修改状态函数导致的
js
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then((r) => {
// this is vulnerable to the race conditions
setData(r);
});
}, [url]);
- 我们可以这样修复:
- 卸载使用旧数据的组件,重新挂载使用新数据的组件。
- 将返回的结果与触发该 Promise 的变量进行比较,如果它们不匹配,就不设置状态。
- 使用"清除"函数来确保当前的promise是最新的promise
- 使用
AbortController
来取消之前的请求。