假定现有一个人力资源系统,页面如此:顶部一个 RadioGroup,有 3 个部门可以选择;下面是一个列表,显示所选部门的员工。列表中的数据通过网络获取(每切换一次部门,就会重新获取一次)。考虑到响应速度和带宽利用率,我们必须确保请求之间是异步的。假设用户频繁地发起了 3 次请求(切换了 3 个部门),由于接口返回数据的耗时不尽相同,第 1 次发起的请求可能晚于第 3 次请求返回。此时,我们的 RaidoGroup 显示第 3 部门,列表显示的却是第 1 部门的员工。这便是发生了请求竞态问题。
其实竞态不止发生在请求间,凡是异步操作同一数据的(包括 DOM 元素),都有可能出现竞态问题。而网络请求的频繁程度、响应时长的不确定性,使其成为最常见的竞态场景。
解决竞态问题也有很多方法,比如 Cancel Token 、AbortController,甚至是在响应中加入请求编号(当然,这很不优雅)。
在此,我们讲解如何通过 React Hooks 和闭包来解决这个问题。
React Hooks #
Hooks 是 React 16.8 加入的特性。这是一个重大的特性,不夸张地说,Hooks 的出现,改变了你我在使用 React 时的思维方式。
Hooks 的诸多优点和特性,我们不在此讲解。仅谈我们要用到的部分—— useEffect。
import React, { useState, useEffect } from 'react';
export default () => {
const [employees, setEmployees] = useState([]);
useEffect(() => {
queryEmployees(currentDepartment) // 这场发起一个网络请求
.then(res => {
setEmployees(res);
})
}, [currentDepartment])
****return(
<div>balabala</div>
)
}useEffect 接受两个参数,第一个是要执行的方法(暂且称之为目标方法),第二个是条件数组。
不传入条件数组的话,useEffect 的目标方法默认会在每次页面重新渲染(包括初次渲染)的时候被执行。在此处我们传入了条件数组[currentDepartment],则 React 会在每次渲染的时候检查 currentDepartment的值,若相较上一次渲染发生了变化,目标方法则会被执行。
咱们再来看目标方法,很简单,发起了一个网络请求,传入了要查询的部门,接收到返回值后通过 setEmployees 方法(这是一个 React 的 State Hook 方法)更新员工数据。
可以看到,当每一次 currentDepartment 更新时,都会发起一个网络请求,各个请求返回后,都会调用 setEmployees 方法。而请求间返回的顺序不定,便造成结果与预期不符。
闭包 #
接下来开始讨论闭包的话题了。闭包并不是一个多么复杂的概念,但它确实是一个有着强大用处,且在使用中很容易出错的功能。再加上闭包这个名字的确不太直观,因而易使人望而生畏。
闭包本质上来说是一个作用域的问题。
几个关键概念我们先明确以下:
- 内部方法使用外部作用域中变量的行为,称之为闭包
- 只有方法才会产生闭包,没有方法就没有闭包(此处方法是指内部方法,而非指外部作用域。外部作用域可以是一个外部方法的作用域,也可以是一个普通的作用域——比如花括号可以构成一个作用域——甚至全局作用域。)
- 闭包不是对外部变量的值快照,而是真实访问,可进行值更新等操作
- 可以出现多个方法对同一外部作用域变量产生闭包,他们共享对同一变量的访问
概念或许过于抽象,我们来看代码。
function makeCounter() {
var count = 0;
return function getCurrent() {
count = count + 1;
return count;
};
}
var hits = makeCounter();
hits(); // 1
hits(); // 2
hits(); // 3如上,调用 makeCounter,其会返回一个 getCurrent 方法。执行完 var hits = makeCounter(); 后,makeCounter 方法便结束了,按理说,其 count 变量应当被回收,而 getCurrent 方法作为返回值,返回给了 hits,当我们调用 hits 时,也就调用了 getCurrent 方法。然而有趣的是,getCurrent中直接使用了 count 变量,正常逻辑,我们调用 getCurrent 的时候,count 应该已经被回收了。之所以我们还能顺利地使用它,并且更新它,完成计数功能,正是由于闭包的存在。
当我们的内部方法(getCurrent)使用了外部作用域(makeCounter)中的变量(count)时,闭包便形成了。此后,当 makeCounter 执行完,count 并不会被回收,直到 getCurrent 也不被引用了,count 才和 getCurrent 一起被回收。
解决竞态问题 #
Hooks 和闭包都介绍了,怎么通过它们来解决竞态问题呢?我们对一开始的那个代码做一下改造。
import React, { useState, useEffect } from 'react';
export default () => {
const [employees, setEmployees] = useState([]);
useEffect(() => {
let didCancel = false;
queryEmployees(currentDepartment) // 这场发起一个网络请求
.then(res => {
if (!didCancel) {
setEmployees(res);
}
})
return () => {
didCancel = true;
}
}, [currentDepartment])
****return(
<div>balabala</div>
)
}所有的改动都在 useEffect 的目标方法内,我们新加了一个 didCancel 变量,当其不为 true 时,才会进行 setEmployees 操作。然后我们又给目标方法增加了一个返回值,其返回一个方法(注意,这里其实也是一个闭包!),此方法将 didCancel 赋值成了 true 。
这里有一个 Effect Hook 的关键内容我们前面没讲,那就是目标方法的返回值(也是一个方法)。这个被返回的方法,被称之为 cleanup 方法。在已经理解了闭包的基础上,我们只要理解了cleanup,也就理解了竞态问题的解决原理。
我们可以看到,在目标方法中,queryEmployees 和 cleanup 方法形成了两个闭包,这两个闭包使用了同一个 didCancel 变量。也就是说,cleanup 中改变 didCancel 的值,会影响 queryEmployees 的执行结果。另外,由于每一次画面重新渲染,useEffect 都会产生一个新的目标方法(注意,useEffect 方法本身又是一个闭包!),每个目标方法又会产生一个新的属于自己的 let didCancel = false; 变量,因而 didCancel 是不会在目标方法之间产生影响的。
那么问题就来了,连续发出三次请求,就会产生三个目标方法,每一个目标方法都有自己的 didCancel ,而每一个 didCancel 又只会影响本目标方法的执行。那,我们要处理的竞态,是目标方法和目标方法之间的问题,我们是如何做到后面的请求,能够影响前面的请求,使得前面的请求返回不被处理呢?
一切的秘密就在 cleanup 里。
我们对目标方法里的内容很明了,一个变量 didCancel,和两个使用这个变量的闭包(queryEmployees 和 cleanup)。queryEmployees 会根据 didCancel 来决定是否更新员工列表,而 cleanup 则会修改 didCancel 的值。由此可见,如果 cleanup 的执行先于 queryEmployees 的响应返回的话,则返回的员工数据不会更新到 employees 变量中。那么关键就在于,cleanup 何时被执行呢?
Cleanup 会在下面两种情况下被执行:
- 组件销毁的时候
- 下一次将要调用此 effect 时
咱们把注意力集中在第二种情况。
看明白了没,咱们此次触发的 effect,其 cleanup 什么时候执行,取决于下一次触发此 effect 的时间!也就是说,在咱们这个查询员工的例子里,当用户点击触发一次查询后,这个查询的 cleanup 的执行时间,取决于用户什么时候触发下一次查询。如果用户 5 秒后才触发下一次查询,那么这个 cleanup 就会在 5 秒后才执行,这个时候咱们此次的查询数据大抵早已返回了;如果用户在半秒内即触发了第二次查询,那么 cleanup 即在半秒内触发,此时咱们的响应可能还没返回,等它返回的时候,didCancel 已经被 cleanup 置为 true 了,这时我们不更新员工数据,因为这个响应在还未返回前已经被后面的请求给取消了;如果用户不触发下一次请求,那么它的逻辑和 5 秒是一样的,只是比 5 秒更长了,长到此组件被销毁的时候,才会被 cleanup。
闭包是 JavaScript 里面不太起眼却又十分强大的功能,而 Hooks 则可以看作是 React 对于闭包的一个集大成的使用。闭包和 Hooks 都不是那么好掌握的,并不是因为它的原理有多么复杂,只是其不那么符合直觉,因而需要更多的实践经验。但是一旦能够掌握好闭包和 React Hooks,开发的效率将会倍增。