技术前端开发分页的竞态问题

前言 📝

在渡一老师的一个视频中,提到了关于列表分页快速翻页下的竞态问题。

问题描述大致是:假如有一个列表,当用户连续快速点击翻页按钮时,如何正确显示列表内容?

pagination-race-condition

这个问题的难点在于:用户每次点击翻页都会发送一次网络请求,而每次请求所需时间是不确定的,因此请求的发送顺序并不能代表响应的接收顺序。

渡一老师给出了一个解决方案:取消请求。即每次发起新请求时,都取消上一次的请求。

虽然这可以解决竞态问题,但在实际开发中,我们通常会采用其他方案。

请求库 🚀

在实际开发中,我们一般会使用请求库来解决这种竞态问题,例如 @tanstack/react-querySWR 等。

那么,这些请求库是如何解决这个问题的呢?

这里我以 SWR 为例,来介绍它的实现原理(Vue 的请求库也是类似的原理)。

swr_request

粗略实现 🔍

在列表中使用 SWR 请求数据时,快速点击翻页按钮会产生如下效果:

swr_request_2

源码分析 💻

那么,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 在每次渲染时,都会优先使用缓存中的数据。Cachekey 就是上面我们调用 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])

竞态问题处理 🏃

这个时候,第一个请求已经发出,然后有两种情况的竞态问题需要考虑

  1. 同一时间有多个相同 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)
}
  1. 如果在第一次请求发出后,还有其他正在进行的请求。
req1------------------>res1        (当前请求)
    req2---------------->res2

我们应该忽略当前请求,始终保留后发起的请求。

  // 如果请求已经被清理或者时间戳不匹配
  if (!FETCH[key] || FETCH[key][1] !== startAt) {
    if (shouldStartNewRequest) {
      if (callbackSafeguard()) {
        getConfig().onDiscarded(key)
      }
    }
    return false
  } 

总结 ✨

以上,就是 SWR 在初始化过程中对于请求竞态问题的处理。后续的突变(mutation),即手动更新数据时也有类似的逻辑。正是因为 SWR 的这些处理,我们在使用时可以完全不用担心竞态问题。

因此,在实际开发中,我们通常使用 SWR 来管理服务端的状态。在简单项目中,无需额外引入其他全局状态管理库。

swr_request_3