前端数据请求治理

前端和后端打交道,绕不开数据请求这一层。这篇文章梳理了我在实际项目里遇到的几个高频问题,这些问题很容易在真实项目里反复出现。本文围绕下面四个问题展开:

  • token 无感刷新:401 触发时静默换新 token,不打断用户操作;
  • 并发请求控制:浏览器同源并发有限,列表/批量场景要排队;
  • 请求缓存与去重:相同请求不重复执行,必要时按 TTL 失效;
  • 请求重试:网络抖动场景下用指数退避兜底。

token 过期后,如何实现无感刷新

实际场景

  • 登录态过期:Access Token 已经过期但 Refresh Token 还有效,需要拿到新 Token 后继续请求;
  • 并发请求雪崩:多个请求同时触发 401,不能每个请求都去刷新一次 Token;
  • 用户体验:把用户踢到登录页会打断正在进行的操作,应该静默重试。

实现思路(双令牌 + 单点刷新)

双令牌机制:短期的访问令牌(Access Token)+ 长期的刷新令牌(Refresh Token)。

  • Access Token:每次 API 请求携带,有效期短(如 15 分钟),泄露风险低。更稳妥的做法是放在后端 HttpOnly Cookie 中;如果业务需要前端手动携带 Token,则至少避免以明文形式存储;
  • Refresh Token:仅用于刷新 Access Token,有效期长(如 7 天)。

请求流程(登录之后的循环):

当多个请求同时触发 401,用 isRefreshing 标志位保证只有一个请求去刷新 token,其他挂起等待;新 token 拿到后再批量唤醒队列里的请求。实现如下:

import axios from 'axios'

const instance = axios.create({
  baseURL: 'https://api.example.com',
})

let isRefreshing = false
let requests: Array<(token: string) => void> = []

instance.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

instance.interceptors.response.use(
  (response) => response,
  (error) => {
    const { config, response } = error
    const originalRequest = config

    if (response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 如果正在刷新,将当前请求加入队列
        return new Promise((resolve) => {
          requests.push(resolve)
        }).then((newToken) => {
          originalRequest.headers.Authorization = `Bearer ${newToken}`
          originalRequest._retry = true
          return instance(originalRequest)
        })
      }

      let refreshSucceeded = false
      isRefreshing = true

      return refreshToken()
        .then((newToken) => {
          refreshSucceeded = true
          localStorage.setItem('access_token', newToken)

          // 执行之前挂起的请求
          requests.forEach((resolve) => resolve(newToken))
          requests = []

          // 重试当前请求
          originalRequest.headers.Authorization = `Bearer ${newToken}`
          originalRequest._retry = true
          return instance(originalRequest)
        })
        .catch((err) => {
          // 只有刷新 token 本身失败才跳转登录
          if (!refreshSucceeded) {
            localStorage.removeItem('access_token')
            window.location.href = '/login'
          }
          return Promise.reject(err)
        })
        .finally(() => {
          isRefreshing = false
        })
    }

    return Promise.reject(error)
  }
)

注意事项

  • 401 拦截要避免死循环:用 _retry 标志位标记已重试过的请求;
  • Refresh Token 本身刷新失败(如过期)才跳转到登录页。

如何批量控制并发请求

实际场景

  • 列表页一次加载 100 张图片,浏览器对同源最大并发只有 6 个(HTTP/1.1),多余的请求会被阻塞;
  • 批量调用查询接口,后端有限流时不能一次性把所有请求都发出去;
  • 大文件上传或下载,并发过大容易触发浏览器内存或网络问题。

实现思路(任务池排队)

核心是维护一个运行中的任务池,限制同时执行的数量。任务来了先入队,池子有空位再出队执行:

class RequestPool {
  private max: number
  private running = 0
  private queue: Array<() => void> = []

  constructor(max = 6) {
    this.max = max
  }

  run<T>(fn: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push(async () => {
        this.running++
        try {
          resolve(await fn())
        } catch (err) {
          reject(err)
        } finally {
          this.running--
          this.next()
        }
      })
      this.next()
    })
  }

  private next() {
    if (this.running < this.max && this.queue.length) {
      const task = this.queue.shift()!
      task()
    }
  }
}

调用方只需要把请求包进任务池:

const pool = new RequestPool(6)
const urls = Array.from({ length: 100 }, (_, i) => `/api/image/${i}`)
const results = await Promise.all(urls.map((url) => pool.run(() => instance.get(url))))

注意事项

  • max 数值不是越大越好:图片加载 6~8 比较合适,API 调用要根据后端限流策略调;
  • HTTP/2 及以上通过多路复用缓解了同源连接数限制,但浏览器、服务端并发流和后端处理能力仍然有上限;
  • 长时间排队的任务要加超时,避免请求堆积。

如何控制请求缓存

实际场景

  • 用户基本信息、字典数据等“基本不变”或“偶尔变化”的数据,每次都重新请求会造成浪费;
  • 短时间内对同一接口发起多次相同请求(搜索框抖动、列表页快速切换);
  • 多个组件同时请求同一份数据,重复请求浪费带宽和后端资源。

实现思路(LRU + 请求去重)

数据更新频率差异很大,先按变化频率选策略更稳妥:

场景策略例子
基本不变强缓存 + 长 TTL(Time To Live,缓存存活时间)字典、地区码
偶尔变化SWR(stale-while-revalidate)用户基本信息
频繁变化不缓存订单状态、库存

缓存可以存最终数据,也可以短时间存正在进行的 Promise。前者减少重复读取,后者主要用来合并并发请求。

LRUCache 实现:

class LRUCache<K = string, V = unknown> {
  private maxSize: number
  private defaultTTL: number
  private cache = new Map<K, { value: V; expire: number }>()

  constructor(maxSize = 100, defaultTTL = 60_000) {
    this.maxSize = maxSize
    this.defaultTTL = defaultTTL
  }

  get(key: K): V | undefined {
    const item = this.cache.get(key)
    if (!item) return undefined
    if (item.expire < Date.now()) {
      this.cache.delete(key)
      return undefined
    }
    // 命中后挪到队尾,标记为最近使用
    this.cache.delete(key)
    this.cache.set(key, item)
    return item.value
  }

  has(key: K): boolean {
    const item = this.cache.get(key)
    if (!item) return false
    return item.expire >= Date.now()
  }

  set(key: K, value: V, ttl?: number): void {
    if (this.cache.has(key)) {
      this.cache.delete(key)
    } else if (this.cache.size >= this.maxSize) {
      const oldest = this.cache.keys().next().value
      if (oldest !== undefined) this.cache.delete(oldest)
    }
    this.cache.set(key, { value, expire: Date.now() + (ttl ?? this.defaultTTL) })
  }

  delete(key: K): boolean {
    return this.cache.delete(key)
  }

  clear(): void {
    this.cache.clear()
  }

  get size(): number {
    return this.cache.size
  }
}

基于 LRUCache 可以封装一个支持请求去重的 useFetch hook,让短时间内相同 key 的请求复用同一个 Promise:

type ResponseData<T> = {
  data: T | undefined
  error: Error | null
  isLoading: boolean
}

const cache = new LRUCache(200, 3000)

export function useFetch<T>(key: string, fetcher: (key: string) => Promise<T>) {
  const [state, setState] = useState<ResponseData<T>>({ data: undefined, isLoading: false, error: null })
  const fetcherRef = useLatest(fetcher)

  const getPromise = useCallback((): Promise<T> => {
    const hit = cache.get(key)
    if (hit) return hit as Promise<T>
    const promise = fetcherRef.current(key)
    cache.set(key, { value: promise })
    return promise
  }, [key])

  useEffect(() => {
    if (!key) return
    let isCancelled = false

    setState((prev) => ({ ...prev, error: null, isLoading: true }))
    getPromise()
      .then((data: T) => {
        if (!isCancelled) {
          setState({ data, error: null, isLoading: false })
        }
      })
      .catch((error) => {
        if (!isCancelled) {
          setState({ data: undefined, error, isLoading: false })
        }
      })

    return () => {
      isCancelled = true
    }
  }, [key])

  return state
}

使用示例(仅作演示,省略了部分边界判断):

function UserCard({ id }: { id: string }) {
  const { data, error, isLoading } = useFetch(`/api/user/${id}`, (url) => instance(url))

  if (isLoading) return <p>加载中…</p>
  if (error) return <p>出错:{error.message}</p>

  return (
    <div>
      <h3>{data.name}</h3>
    </div>
  )
}

注意事项

  • TTL 要按数据更新频率设置:基本不变的数据 TTL 长一些,频繁变化的不缓存;
  • LRU 容量不能太大:缓存本身占内存,几百到几千条比较合理。

如何控制请求重试

实际场景

  • 网络抖动导致偶发的请求失败(5xx、超时),重试一次就能成功;
  • 表单提交、订单创建等场景不适合盲目重试,要区分错误类型;
  • 后端短暂过载,重试几次能拿到正确结果。

实现思路(指数退避 + 随机抖动)

指数退避 + 随机抖动:失败后等 1s、2s、4s 再重试,避免雪崩重试把后端打挂。

type RetryOptions = {
  shouldRetry?: (err: unknown) => boolean
  baseDelay?: number
  maxRetries?: number
}

async function withRetry<T>(fn: () => Promise<T>, { shouldRetry = () => true, baseDelay = 1000, maxRetries = 3 }: RetryOptions = {}): Promise<T> {
  let attempt = 0
  while (true) {
    try {
      return await fn()
    } catch (err) {
      attempt++
      if (attempt > maxRetries || !shouldRetry(err)) throw err
      const delay = baseDelay * 2 ** (attempt - 1) + Math.random() * 200
      await new Promise((r) => setTimeout(r, delay))
    }
  }
}

调用时显式包一层 withRetryinstance 本身不会自动重试:

const { data } = await withRetry(() => instance.get('/api/data'))

注意事项

  • 一般重试 3 次以内就行,超过 3 次基本属于业务层面兜底而不是网络重试能解决的问题;
  • 4xx 通常是业务/客户端错误,不要盲目重试:参数不变重试大概率还是同样的 4xx;
  • 优先只对幂等请求重试;POST / PUT 这类写请求要有幂等键或服务端去重保护,否则可能导致重复写入。

总结

四个问题看起来独立,实际在生产环境里经常要组合使用:

  • token 刷新 + 缓存:token 换了之后,缓存里基于旧身份拉到的数据也该失效。刷新成功后要清理相关缓存,或者把用户身份纳入缓存 key,避免用旧身份读到数据;
  • 并发控制 + 重试pool.run(() => withRetry(...)) 时,pool 控制同时飞的请求数,withRetry 负责单次请求的退避,两层互不干扰;
  • 缓存 + 重试:缓存层负责命中和去重,重试层只包真实请求;缓存命中时不发网络请求,也就不会进入失败后的退避重试。

工程上没有银弹,关键是看具体业务对"延迟"、"成功率"和"后端压力"的取舍。