这篇文章将讨论前端日常开发中常见的异步请求导致页面错乱的问题,总结常见的解决方法。
直接看例子,假设我们正在开发一个管理后台的查询列表页面,页面功能很简单,每次搜索条件变更时触发 Table 列表重新加载和渲染。
很容易写出大致实现(这里以 React
为例):
const [dataSource, setDataSource] = useState([])
const [filters, setFilters] = useState({
status: 0
})
const onFilterChange = useCallback((newValue) => {
setFilters(newValue)
}, [])
const fetchData = async (params) => {
const { list } = await fetch('/foo/bar', {
params
})
setDataSource(list)
}
useEffect(() => {
fetchData(filters)
}, [filters])
return (
<>
<Filters value={filters} onChange={onFilterChange} >
<Table dataSource={dataSource} />
</>
)
Filters
是个受控的搜索组件,接收 value,和 onChange props。每次筛选条件变更时触发 fetchData
方法,并在异步请求结束之后,更新 Table
内容。
例子中的 fetch 不是浏览器自带的 fetch,通常在项目中我们会包裹 fetch 加上 response.json,错误拦截等操作。
这样的查询列表需求非常常见,几乎在每个管理后台系统中都能找到类似的实现。然而,这个实现是存在缺陷的。
问题在于,开发者乐观地认为网络请求会非常顺利,在每次重新拉取数据之前,上一次请求已经完成。
而真实用户网络状况非常复杂,用户可能在信号📶非常微弱的地方打开管理系统(比如在野外度假的时候突然被老板要求提供某些数据),即便用户的网络良好,每一次请求经过的链路复杂多样,每个请求的缓存情况,执行时间有所差异,甚至每一个为请求提供服务的服务器 CPU 繁忙程度也不一样。
总而言之,乐观的认为所有请求都会按顺序返回是容易出现问题的。比如出现下面这种情况,第一次请求在第二次请求完成之后才响应,这将会导致查询条件和列表展示不一致。
下图,搜索条件是 status: inactive, 而“筛选”出来的列表项 status 为 active。
常见的解决方法有以下几种:
方法一 阻断用户操作
既然问题是由于多次请求导致的,那么从这个角度上就可以想到阻断的方法:如果存在一个尚未完成的请求,则不允许用户再发起请求。
使用管理系统查询页面作为例子,我们可以给筛选器加上禁用状态,从源头上避免第二次请求发生。
const [loading, setLoading] = useState(false)
const fetchData = async (params) => {
setLoading(true)
try {
const { list } = await fetch('/foo/bar', {
params
})
setDataSource(list)
} catch (error) {
} finally {
setLoading(false)
}
}
//...
return (
<>
<Filters value={filters} onChange={onFilterChange} disabled={loading} >
<Table dataSource={dataSource} loading={loading} />
</>
)
当 fetchData 请求发起时,将 loading
置为 true,并传递给 Filters 的 disabled 属性,不允许用户再修改筛选条件。等到请求结束时再将 loading
修改为 false,允许用户修改筛选条件。
Filters
的 disabled props 会再赋予每一个 FormItem,这里的细节就不展开了。
阻断式的体验很不好,当某个请求响应缓慢时,用户要么继续等待,要么强刷页面,重新进行筛选,无论哪种方式都容易引起用户的不适,不推荐这种方案(当代人很容易暴躁💥)。
方法二 节流限制
错乱的问题多见于 Input 组件,比如在管理页面中的关键字搜索,在用户尚未完成键入之前的查询列表请求是没有意义的,所以很容易想到,就是给 fetchData
也加上 debounce
。
一小段时间内等候,若有其他条件变更导致需要重新拉取数据,则放弃前一次请求,重新设置定时器。若不需要再次拉取数据,则按计划发起请求。
const fetchData = useCallback(
debounce(async (params) => {
const { list } = await fetch('/foo/bar', {
params
})
setDataSource(list)
}, 300),
[]
)
例子中使用了 React Hooks 的写法,useCallback
是为了保持 fetchData 方法在每次 render 时都是同一个 debounced 的 fetch 方法,而不是每次重新 render 都是创建新的方法。
这个方案可以快速解决大部分的问题,但无法完全避免错乱的发生,并且对于正常使用也会产生多余的等待时间。
方法三 版本检查
结合前两种方法,最好的实现当然是用户操作不受阻塞,可以随意修改筛选条件,始终以最后一次操作结果为准,前面的请求都可以舍弃不要。
基于这个想法进行改善,加入版本的概念。给每个请求加上版本,在异步请求完成时,判断是否为当前最新,若不是,舍弃请求结果,若是,应用结果。
const lastRequestVersionRef = useRef<number | null>(null)
const fetchData = async (params) => {
const requestVersion = lastRequestVersionRef.current = Date.now()
try {
const { list } = await fetch('/foo/bar', {
params
})
// 如果当前请求的版本和最新的版本一致
if (requestVersion === lastRequestVersionRef.current) {
setDataSource(list)
}
} catch (error) {
}
}
这里使用了 useRef
来记录每次请求的版本号,示例中在每次请求开始前都会更新版本号为当前时间戳,还有一个临时的变量 requestVersion
作为当次请求的版本。
在异步请求完成时进行判断,若当前请求的版本号和最新的版本号不一致,则忽略请求结果。
你可能会认为这里的 requestVersion 会始终等于 lastRequestVersionRef.current,实际上 lastRequestVersionRef 可能会被新的请求更新(在另外一次 render 的过程中)。lastRequestVersionRef 是个引用值,所有请求都共用这个对象,所以请求结束时读取 lastRequestVersionRef.current 会是时下最新的,不一定和异步请求前取到的值一致。
方法四 中断请求
使用版本控制的方法,已经可以很好的解决用户体验和页面错乱的问题。但并没有中止网络请求,只是在请求完成之后遗弃。假设每个请求服务端都会吐回大量的数据(比如返回一个 mp4),但实际上又用不到是很浪费的。是否可以中断请求呢?
仍然以上面的例子,使用 fetch 方法在 React 中的取消请求:
const abortControllerRef = useRef(null)
const fetchData = async (params) => {
try {
const { list } = await fetch('/foo/bar', {
params,
signal: abortControllerRef.current.signal
})
setDataSource(list)
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
}
}
}
useEffect(() => {
abortControllerRef.current = new AbortController()
fetchData(filters)
return () => abortControllerRef.current.abort()
}, [filters])
上面的例子,每次重新请求之前创建一个 AbortContorller,将 abortController 的 signal 传给 fetch 方法。把取消方法回传给 useEffect,这样每次重新请求列表或者组件 unmount 时就不会再走到正常流程了。这样还避免了修改已 unmount 组件 state 的警告。
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
关于 AbortController 的更多内容,可以看 MDN 文档
其他请求方法的中断请求:
XMLHttpRequest:
const xhr = new XMLHttpRequest()
xhr.open("GET","/foo/bar")
xhr.send()
xhr.abort()
axios:
import { CancelToken } from 'axios'
const source = CancelToken.source()
axios.get('/foo/bar', {
cancelToken: source.token,
})
source.abort()
结语
⚠️ 时间仓促,示例中的代码没有经过严格测试,仅可作为参考示例。