前言 📝
在渡一老师的一个视频中,提到了关于列表分页快速翻页下的竞态问题。
问题描述大致是:假如有一个列表,当用户连续快速点击翻页按钮时,如何正确显示列表内容?
这个问题的难点在于:用户每次点击翻页都会发送一次网络请求,而每次请求所需时间是不确定的,因此请求的发送顺序并不能代表响应的接收顺序。
渡一老师给出了一个解决方案:取消请求。即每次发起新请求时,都取消上一次的请求。
虽然这可以解决竞态问题,但在实际开发中,我们通常会采用其他方案。
请求库 🚀
在实际开发中,我们一般会使用请求库来解决这种竞态问题,例如 @tanstack/react-query
、SWR
等。
那么,这些请求库是如何解决这个问题的呢?
这里我以 SWR
为例,来介绍它的实现原理(Vue 的请求库也是类似的原理)。
粗略实现 🔍
在列表中使用 SWR
请求数据时,快速点击翻页按钮会产生如下效果:
源码分析 💻
那么,SWR
是如何实现缓存、解决竞态问题,确保只使用最新且正确的数据,并实现服务端状态管理的呢?
让我们从头开始分析 SWR
的源码。
背景知识 📚
序列化 🔄
当我们在使用 useSWR
时。首先,会序列化 key
值。
const {
data,
error,
isLoading,
isValidating,
mutate
} = useSWR(key, fetcher)
// `key` 是 SWR 内部状态的标识符
// `fnArg` 是从 key 中解析出来的参数,会被传递给 fetcher 函数
// 它们都是从 `useSWR` 中第一个参数 `_key` 派生出来的
const [key, fnArg] = serialize(_key)
serialize
函数的作用就是将 key
序列化。如果 key
是函数,那么会调用 key
函数,获取到 key
的值。
如果是字符串,就直接使用 key
的值。不是字符串,也不是空数组,则使用 stableHash 函数对 key
进行哈希处理,
确保内部可以直接比较两个 key
是否一样。
export const serialize = (key: Key): [string, Arguments] => {
if (isFunction(key)) {
try {
key = key()
} catch (err) {
// 依赖项未就绪
key = ''
}
}
// 使用原始的 key 作为 fetcher 的参数。它可以是字符串或值数组
const args = key
// 如果 key 不是字符串也不是空数组,则对其进行哈希处理
key =
typeof key == 'string'
? key
: (Array.isArray(key) ? key.length : key)
? stableHash(key)
: ''
return [key, args]
}
这就是为什么我们在一些比较复杂的列表,在我们后端甚至用 POST
请求时,我们也可以直接传入对象或者数组的形式。
简化我们的代码。这时,如果我们的 params
改变了,SWR
就会认为 key
改变了,从而发起新的请求更新缓存数据。
const fetcher = ([url, params]) => fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
}).then(res => res.json())
const { data, error, isLoading, isValidating, mutate } = useSWR(
['/api/list', params],
fetcher
)
缓存对象以及全局状态 🗄️
SWR
内部有一个 Cache
对象和全局状态 SWRGlobalState
,
export interface Cache<Data = any> {
keys(): IterableIterator<string>
get(key: string): State<Data> | undefined
set(key: string, value: State<Data>): void
delete(key: string): void
}
export type State<Data = any, Error = any> = {
data?: Data
error?: Error
isValidating?: boolean
isLoading?: boolean
}
// 全局状态用于去重请求和存储监听器
export const SWRGlobalState = new WeakMap<Cache, GlobalState>()
SWR
在每次渲染时,都会优先使用缓存中的数据。Cache
的 key
就是上面我们调用 serialize
方法后的序列化后的 第一个key
值,第二个值 fnArg
将直接被传递给 fetcher
进行请求。
在初始状态下,Cache
是空的,我们会发起请求来获得数据,将其存储在 Cache
中。
SWRGlobalState
是全局共享的状态,默认时存储在内存中。因为是 WeakMap
, 当组件被卸载时 Cache
对象不再使用时,会自动清理相关状态,避免内存泄漏
请求过程梳理 🔃
有了以上两个背景知识,我们来梳理一下 SWR
的请求过程,来看它是如何彻底解决请求竞态问题的。
发起请求 🚀
我们可以直接从源码中的 useIsomorphicLayoutEffect
开始 (这个方法在客户端就是 useLayoutEffect
,以下源码忽略服务端的逻辑)。
export const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect
在 useLayoutEffect
中,使用 SWR
会调用一次 revalidate
方法。
const WITH_DEDUPE = { dedupe: true }
useIsomorphicLayoutEffect(() => {
if (!key) return
const softRevalidate = revalidate.bind(UNDEFINED, WITH_DEDUPE)
}, [key])
在 revalidate
方法中,首先会记录请求以及开始的时间戳。
// FETCH 是全局状态中的一个对象,用于存储请求信息。让 swr 有了服务端状态管理的能力
const [EVENT_REVALIDATORS, MUTATION, FETCH, PRELOAD] = SWRGlobalState.get(cache) as GlobalState
const revalidate = useCallback(async (revalidateOpts?: RevalidatorOptions): Promise<boolean> => {
let newData: Data
let startAt: number;
// 是否应该发起新的请求,此时 !FETCH[key] 为 true,会发起新的请求
const shouldStartNewRequest = !FETCH[key] || !opts.dedupe
// 开始请求并保存时间戳
FETCH[key] = [
currentFetcher(fnArg as DefinitelyTruthy<Key>),
getTimestamp()
]
// 等待正在进行的请求完成
;[newData, startAt] = FETCH[key]
newData = await newData
}, [key, cache])
竞态问题处理 🏃
这个时候,第一个请求已经发出,然后有两种情况的竞态问题需要考虑
- 同一时间有多个相同
key
的请求发出,我们要去除重复请求,只保留一个请求
const cleanupState = () => {
const requestInfo = FETCH[key]
// 如果请求信息存在,并且请求时间戳匹配,则删除请求信息
if (requestInfo && requestInfo[1] === startAt) {
delete FETCH[key]
}
}
if (shouldStartNewRequest) {
// 上面第一个请求已经发出,SWR 默认的 dedupingInterval 是 2000ms
// 也就是在 2 秒内,如果同一时间发出多个请求,那么会去除重复请求,只会保留一个请求
setTimeout(cleanupState, config.dedupingInterval)
}
- 如果在第一次请求发出后,还有其他正在进行的请求。
req1------------------>res1 (当前请求)
req2---------------->res2
我们应该忽略当前请求,始终保留后发起的请求。
// 如果请求已经被清理或者时间戳不匹配
if (!FETCH[key] || FETCH[key][1] !== startAt) {
if (shouldStartNewRequest) {
if (callbackSafeguard()) {
getConfig().onDiscarded(key)
}
}
return false
}
总结 ✨
以上,就是 SWR
在初始化过程中对于请求竞态问题的处理。后续的突变(mutation),即手动更新数据时也有类似的逻辑。正是因为 SWR
的这些处理,我们在使用时可以完全不用担心竞态问题。
因此,在实际开发中,我们通常使用 SWR
来管理服务端的状态。在简单项目中,无需额外引入其他全局状态管理库。