什么是 ‘Request Collapsing’?在 React 并发请求中如何合并完全相同的 API 调用以减少带宽损耗

各位同仁,各位技术爱好者,大家好。

今天,我们将深入探讨一个在前端开发,尤其是在现代React应用中至关重要的性能优化技术——请求合并(Request Collapsing)。这是一个能够显著减少网络带宽消耗、减轻服务器压力、并提升用户体验的强大模式。我们将从概念入手,逐步深入到其原理、实现细节、在React中的应用,以及与相关技术的比较。

一、什么是请求合并(Request Collapsing)?

请求合并,有时也被称为请求去重(Request Deduplication),其核心思想是:在短时间内,当多个代码路径或组件同时尝试请求完全相同的资源时,我们只实际发送一次网络请求到服务器,并将该请求的结果共享给所有发起方。

想象一下这样的场景:你的React应用中有三个不同的组件,它们都需要展示用户的个人资料。在一次页面加载或用户操作中,这三个组件可能几乎同时被渲染,并且各自独立地调用 fetch('/api/user/profile')。如果没有请求合并,浏览器将向 /api/user/profile 发送三次相同的网络请求。

请求合并的目标就是识别出这些重复的并发请求,并确保只有第一个请求真正发送出去。后续的相同请求不会再次触发网络活动,而是等待第一个请求的结果,然后共享这个结果。

核心收益:

  1. 减少带宽消耗: 最直接的好处。避免了重复下载相同数据,尤其对于大型数据或慢速网络环境至关重要。
  2. 减轻服务器负载: 服务器不必处理多个完全相同的请求,从而降低了其CPU和网络资源的使用。
  3. 提升用户体验: 减少了不必要的网络等待时间,数据加载更快,应用响应更迅速。
  4. 简化数据同步: 确保了所有依赖相同数据的组件都获取到的是同一份最新数据,避免了潜在的数据不一致问题。

二、为什么我们需要请求合并?React并发请求的陷阱

在React这类组件化框架中,由于组件生命周期、状态管理、以及异步渲染的特性,很容易无意中触发重复的网络请求。

考虑以下一个简单的React应用结构:

// App.tsx
import React from 'useState';
import UserProfileCard from './UserProfileCard';
import UserSettingsPanel from './UserSettingsPanel';
import UserActivityLog from './UserActivityLog';

function App() {
  const [userId, setUserId] = useState('user-123'); // 假设用户ID是固定的或通过上下文获取

  return (
    <div>
      <h1>用户中心</h1>
      <UserProfileCard userId={userId} />
      <UserSettingsPanel userId={userId} />
      <UserActivityLog userId={userId} />
    </div>
  );
}

export default App;

现在,我们来看这三个子组件可能如何获取用户数据:

// UserProfileCard.tsx
import React, { useEffect, useState } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

interface UserProfileCardProps {
  userId: string;
}

function UserProfileCard({ userId }: UserProfileCardProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUserProfile = async () => {
      setLoading(true);
      setError(null);
      try {
        console.log(`[UserProfileCard] Fetching user profile for ${userId}...`);
        const response = await fetch(`/api/user/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data: User = await response.json();
        setUser(data);
      } catch (e: any) {
        setError(e.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUserProfile();
  }, [userId]);

  if (loading) return <div>加载用户资料...</div>;
  if (error) return <div>加载失败: {error}</div>;
  if (!user) return null;

  return (
    <div style={{ border: '1px solid #ccc', padding: '10px', margin: '10px' }}>
      <h3>{user.name}</h3>
      <p>邮箱: {user.email}</p>
      <img src={user.avatar} alt={user.name} width="50" height="50" />
    </div>
  );
}

export default UserProfileCard;

// UserSettingsPanel.tsx (结构类似UserProfileCard,只是展示不同的数据)
// ... 内部逻辑也会调用 fetch(`/api/user/${userId}`) ...
import React, { useEffect, useState } from 'react';

// 假设 User 接口与 UserProfileCard 中相同
interface User {
  id: string;
  name: string;
  email: string;
  // ... other user settings related fields
}

interface UserSettingsPanelProps {
  userId: string;
}

function UserSettingsPanel({ userId }: UserSettingsPanelProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUserSettings = async () => {
      setLoading(true);
      setError(null);
      try {
        console.log(`[UserSettingsPanel] Fetching user settings for ${userId}...`);
        const response = await fetch(`/api/user/${userId}`); // 注意这里也是相同的请求
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data: User = await response.json();
        setUser(data);
      } catch (e: any) {
        setError(e.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUserSettings();
  }, [userId]);

  if (loading) return <div>加载用户设置...</div>;
  if (error) return <div>加载失败: {error}</div>;
  if (!user) return null;

  return (
    <div style={{ border: '1px solid #ccc', padding: '10px', margin: '10px' }}>
      <h3>用户设置</h3>
      <p>用户名: {user.name}</p>
      <p>电子邮件: {user.email}</p>
      {/* ... other settings */}
    </div>
  );
}

export default UserSettingsPanel;

// UserActivityLog.tsx (同样会调用 fetch(`/api/user/${userId}`))
// ... 内部逻辑也会调用 fetch(`/api/user/${userId}`) ...
import React, { useEffect, useState } from 'react';

// 假设 User 接口与 UserProfileCard 中相同
interface User {
  id: string;
  name: string;
  // ... other user activity related fields
}

interface UserActivityLogProps {
  userId: string;
}

function UserActivityLog({ userId }: UserActivityLogProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUserActivity = async () => {
      setLoading(true);
      setError(null);
      try {
        console.log(`[UserActivityLog] Fetching user activity for ${userId}...`);
        const response = await fetch(`/api/user/${userId}`); // 再次相同的请求
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data: User = await response.json();
        setUser(data);
      } catch (e: any) {
        setError(e.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUserActivity();
  }, [userId]);

  if (loading) return <div>加载用户活动日志...</div>;
  if (error) return <div>加载失败: {error}</div>;
  if (!user) return null;

  return (
    <div style={{ border: '1px solid #ccc', padding: '10px', margin: '10px' }}>
      <h3>活动日志</h3>
      <p>用户: {user.name}</p>
      {/* ... activity details */}
    </div>
  );
}

export default UserActivityLog;

App 组件渲染时,UserProfileCardUserSettingsPanelUserActivityLog 会同时挂载并触发各自的 useEffect。由于它们都尝试从 /api/user/user-123 获取数据,在浏览器的网络面板中,您将观察到三次几乎同时发出的 /api/user/user-123 请求。这不仅浪费了资源,也可能导致数据在短时间内呈现不一致(尽管API返回相同)。

这就是请求合并要解决的核心问题。

三、请求合并的核心机制:Promise的共享

请求合并的核心思想是利用 JavaScript Promise 的特性。Promise 一旦被创建并开始执行,它就会保持其状态(pending, fulfilled, rejected),直到操作完成。我们可以利用这一点来存储“正在进行中”的请求。

基本原理:

  1. 一个存储介质: 我们需要一个地方来存储正在进行的请求的 PromiseMap 对象是理想的选择,它的键可以是我们请求的唯一标识符(例如,API URL),值则是对应的 Promise
  2. 请求的生命周期管理:
    • 当一个请求(由其唯一标识符 key 标识)首次被发起时:
      • 检查 Map 中是否已经存在一个针对该 keyPromise
      • 如果不存在,则发起实际的网络请求,获取其返回的 Promise,并将其存储在 Map 中,然后返回这个 Promise
      • 为了确保 Map 在请求完成后被清理,我们需要在 Promisefinallycatch 块中移除该 key 对应的 Promise
    • 当同一个请求(相同的 key)再次被发起时:
      • 检查 Map 中是否已经存在一个针对该 keyPromise
      • 如果存在,直接返回这个已经存在的 Promise,而不发送新的网络请求。

示意图:

          +-------------------+
          |     组件 A        |
          |  请求 /api/data   |
          +--------+----------+
                   |
                   | 请求发起
                   v
          +--------+----------+
          | 请求合并器 (Collapser) |
          |  (pendingRequests Map) |
          +--------+----------+
                   |
                   | 检查 Map 中是否存在 /api/data 的 Promise
                   |
                   +-- 不存在 --+
                   |            |
                   v            v
          +--------+----------+  +-------------------+
          | 发起实际网络请求    |  |     组件 B        |
          | fetch('/api/data') |  |  请求 /api/data   |
          +--------+----------+  +--------+----------+
                   |                         ^
                   |                         |
                   | 存储返回的 Promise 到 Map | 检查 Map 中是否存在 /api/data 的 Promise
                   |  (key: '/api/data', value: Promise)  |
                   v                         |
          +--------+----------+            | 存在
          | 返回 Promise 给组件 A |<---------+
          +-------------------+            |
                                           v
                                  +-------------------+
                                  | 返回已存在的 Promise 给组件 B |
                                  +-------------------+

当 Promise 完成 (成功/失败) 时:
    +-------------------------------------+
    | 从 Collapser 的 pendingRequests Map 中移除该 Promise |
    +-------------------------------------+

四、基础的请求合并实现

让我们从一个简单的 TypeScript 模块开始,它提供一个通用的请求合并功能。

// requestCollapser.ts

/**
 * 这是一个通用的请求合并工具。
 * 它维护一个 Map 来存储正在进行中的 Promise,以确保对于相同的请求,
 * 只有一个实际的网络请求会被发送,而其他并发的相同请求将共享同一个 Promise。
 */
class RequestCollapser {
  // 存储正在进行中的请求的 Promise。
  // 键是请求的唯一标识符(例如,API URL),值是该请求对应的 Promise。
  private pendingRequests = new Map<string, Promise<any>>();

  /**
   * 合并一个请求。
   * 如果该请求已经在进行中,则返回现有 Promise;否则,发起新请求并存储其 Promise。
   *
   * @param key 请求的唯一标识符。
   * @param requestFn 一个返回 Promise 的函数,代表实际的网络请求。
   * @returns 一个 Promise,它会解析为请求的结果。
   */
  public collapseRequest<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
    // 1. 检查 Map 中是否已经存在该 key 对应的 Promise
    if (this.pendingRequests.has(key)) {
      console.log(`[RequestCollapser] Returning existing promise for key: ${key}`);
      return this.pendingRequests.get(key) as Promise<T>;
    }

    // 2. 如果不存在,则发起新的请求
    console.log(`[RequestCollapser] Initiating new request for key: ${key}`);
    const requestPromise = requestFn();

    // 3. 将新请求的 Promise 存储在 Map 中
    this.pendingRequests.set(key, requestPromise);

    // 4. 在请求完成后(无论成功或失败),从 Map 中移除该 Promise,以便后续可以重新发起请求
    requestPromise.finally(() => {
      console.log(`[RequestCollapser] Clearing promise for key: ${key}`);
      this.pendingRequests.delete(key);
    });

    return requestPromise;
  }

  /**
   * 清除指定 key 的正在进行中的请求。
   * 这在某些情况下可能有用,例如,当你知道某个请求不再需要时。
   * @param key 请求的唯一标识符。
   */
  public clearRequest(key: string): void {
    if (this.pendingRequests.has(key)) {
      console.log(`[RequestCollapser] Manually clearing pending request for key: ${key}`);
      this.pendingRequests.delete(key);
    }
  }

  /**
   * 清除所有正在进行中的请求。
   */
  public clearAllRequests(): void {
    console.log(`[RequestCollapser] Clearing all pending requests.`);
    this.pendingRequests.clear();
  }
}

// 创建一个 RequestCollapser 的单例实例,供全局使用
export const requestCollapser = new RequestCollapser();

在 React 组件中使用:

现在,我们可以修改之前的 UserProfileCard 组件来使用这个 requestCollapser

// UserProfileCard.tsx (修改后)
import React, { useEffect, useState } from 'react';
import { requestCollapser } from './requestCollapser'; // 引入请求合并器

interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

interface UserProfileCardProps {
  userId: string;
}

function UserProfileCard({ userId }: UserProfileCardProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const requestKey = `/api/user/${userId}`; // 定义请求的唯一键

    const fetchUserProfile = async () => {
      setLoading(true);
      setError(null);
      try {
        // 使用 requestCollapser.collapseRequest 包装实际的 fetch 调用
        const data: User = await requestCollapser.collapseRequest(requestKey, async () => {
          console.log(`[UserProfileCard] Actually making network request for ${requestKey}`);
          const response = await fetch(requestKey);
          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
          }
          return await response.json();
        });
        setUser(data);
      } catch (e: any) {
        setError(e.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUserProfile();
  }, [userId]);

  if (loading) return <div>加载用户资料...</div>;
  if (error) return <div>加载失败: {error}</div>;
  if (!user) return null;

  return (
    <div style={{ border: '1px solid #ccc', padding: '10px', margin: '10px' }}>
      <h3>{user.name}</h3>
      <p>邮箱: {user.email}</p>
      <img src={user.avatar} alt={user.name} width="50" height="50" />
    </div>
  );
}

export default UserProfileCard;

// UserSettingsPanel.tsx 和 UserActivityLog.tsx 同样修改,
// 用 requestCollapser.collapseRequest 包装它们的 fetch 调用。

现在,当 App 组件渲染时,尽管三个子组件都尝试获取 /api/user/user-123 的数据,但由于 requestCollapser 的作用,只有第一个组件的 fetch 会真正触发网络请求。其他组件会共享这个 Promise,并在其解析后获得相同的数据。您会在控制台看到类似这样的输出:

[UserProfileCard] Actually making network request for /api/user/user-123
[RequestCollapser] Initiating new request for key: /api/user/user-123
[UserSettingsPanel] Returning existing promise for key: /api/user/user-123
[UserActivityLog] Returning existing promise for key: /api/user/user-123
... (当请求完成后) ...
[RequestCollapser] Clearing promise for key: /api/user/user-123

在浏览器的网络面板中,您将只看到一个 /api/user/user-123 的请求。

存在的问题:

上述基础实现解决了并发请求去重的问题,但它有几个局限性:

  1. 没有缓存: 一旦请求完成并从 pendingRequests 中清除,如果稍后(即使是几秒后)再次发起相同请求,它会再次触发网络请求。对于那些不经常变化的数据,这仍然是浪费。
  2. 错误处理: 如果 requestFn 抛出错误,requestCollapser 会将这个失败的 Promise 返回给所有调用者。虽然这是正确的行为,但在某些场景下,我们可能希望失败的请求不被缓存,或者有重试机制。
  3. 全局性: requestCollapser 是一个全局单例。这对于大多数场景是好事,但有时可能需要更细粒度的控制。

五、增强型请求合并:考虑实际场景

为了解决上述问题,我们需要引入缓存机制和更健壮的错误处理。

A. 错误处理与重试

requestCollapser.tsfinally 块中清除 Promise 是正确的,因为它确保了无论成功还是失败,请求状态都会被重置。如果 Promise 失败了,所有等待它的组件都会收到同一个拒绝的 Promise。如果需要重试,组件需要自行实现重试逻辑,或者在 requestFn 内部处理重试。

B. 缓存与过期策略 (TTL – Time To Live)

为了避免数据在短时间内被重复请求,我们可以引入一个“已解决请求缓存”。

// requestCollapserWithCache.ts

/**
 * 这是一个增强型的请求合并工具,支持请求去重和响应数据缓存。
 * 它维护两个 Map:
 * 1. pendingRequests: 存储正在进行中的 Promise,用于并发请求去重。
 * 2. resolvedCache: 存储已成功完成的请求的响应数据及其时间戳,用于缓存。
 */
class RequestCollapserWithCache {
  // 存储正在进行中的请求的 Promise
  private pendingRequests = new Map<string, Promise<any>>();
  // 存储已成功完成的请求的响应数据及其缓存时间戳
  private resolvedCache = new Map<string, { data: any; timestamp: number }>();

  /**
   * 合并并缓存一个请求。
   * 优先从缓存中获取数据(如果未过期)。
   * 如果请求正在进行中,则返回现有 Promise。
   * 否则,发起新请求,存储其 Promise,并在成功后缓存结果。
   *
   * @param key 请求的唯一标识符。
   * @param requestFn 一个返回 Promise 的函数,代表实际的网络请求。
   * @param options 配置选项,包括缓存过期时间(maxAge)。
   * @returns 一个 Promise,它会解析为请求的结果。
   */
  public collapseAndCacheRequest<T>(
    key: string,
    requestFn: () => Promise<T>,
    options?: { maxAge?: number }
  ): Promise<T> {
    const maxAge = options?.maxAge ?? 5 * 60 * 1000; // 默认缓存 5 分钟

    // 1. 检查是否存在正在进行中的请求
    if (this.pendingRequests.has(key)) {
      console.log(`[CollapserWithCache] Returning existing pending promise for key: ${key}`);
      return this.pendingRequests.get(key) as Promise<T>;
    }

    // 2. 检查缓存中是否存在未过期的数据
    const cachedEntry = this.resolvedCache.get(key);
    if (cachedEntry && Date.now() - cachedEntry.timestamp < maxAge) {
      console.log(`[CollapserWithCache] Returning cached data for key: ${key}`);
      return Promise.resolve(cachedEntry.data as T);
    }

    // 3. 既没有进行中的请求,也没有有效的缓存,发起新的请求
    console.log(`[CollapserWithCache] Initiating new network request for key: ${key}`);
    const requestPromise = requestFn();

    // 4. 将新请求的 Promise 存储在 pendingRequests 中
    this.pendingRequests.set(key, requestPromise);

    // 5. 在请求完成后(无论成功或失败),从 pendingRequests 中移除
    // 如果成功,则更新 resolvedCache
    requestPromise
      .then((data: T) => {
        this.resolvedCache.set(key, { data, timestamp: Date.now() });
        console.log(`[CollapserWithCache] Data successfully fetched and cached for key: ${key}`);
        return data;
      })
      .catch((error) => {
        // 如果请求失败,不应该缓存失败结果,但可以根据业务逻辑决定是否清除缓存
        // 例如,如果之前有缓存,失败后可以选择清除,强制下次请求
        // this.resolvedCache.delete(key);
        console.error(`[CollapserWithCache] Request failed for key: ${key}, error:`, error);
        throw error; // 重新抛出错误,以便调用者处理
      })
      .finally(() => {
        console.log(`[CollapserWithCache] Clearing pending promise for key: ${key}`);
        this.pendingRequests.delete(key);
      });

    return requestPromise;
  }

  /**
   * 手动使某个 key 的缓存失效。
   * 这在数据更新后非常有用,可以强制下次请求重新获取数据。
   * @param key 请求的唯一标识符。
   */
  public invalidateCache(key: string): void {
    if (this.resolvedCache.has(key)) {
      console.log(`[CollapserWithCache] Invalidating cache for key: ${key}`);
      this.resolvedCache.delete(key);
    }
    // 确保也清除掉正在进行的请求,防止它完成时又把旧数据写回缓存
    this.pendingRequests.delete(key);
  }

  /**
   * 清除所有缓存和正在进行中的请求。
   */
  public clearAll(): void {
    console.log(`[CollapserWithCache] Clearing all caches and pending requests.`);
    this.pendingRequests.clear();
    this.resolvedCache.clear();
  }
}

// 创建一个 RequestCollapserWithCache 的单例实例
export const requestCollapserWithCache = new RequestCollapserWithCache();

C. 缓存失效 (Invalidation)

invalidateCache(key) 方法允许我们手动清除特定键的缓存。这在以下场景非常有用:

  • 数据更新: 当用户执行了一个操作(例如,更新个人资料),导致服务器上的数据发生变化时,我们需要让客户端知道之前的缓存已经过期,下次请求应该获取新数据。
  • 登出/权限变更: 用户登出或权限发生变化时,可能需要清除所有敏感数据的缓存。

D. 类型安全

我们使用了 TypeScript 的泛型 <T> 来确保 collapseAndCacheRequest 函数返回的 Promise 具有正确的类型,提高了代码的健壮性和可维护性。

六、在 React 中集成请求合并

为了让请求合并在 React 应用中更加便捷和符合 Hook 范式,我们可以将其封装成一个自定义 Hook。

A. 自定义 Hook (useCollapsibleQuery)

这个 Hook 将管理数据加载状态、错误以及与 requestCollapserWithCache 的交互。

// hooks/useCollapsibleQuery.ts
import { useEffect, useState, useCallback } from 'react';
import { requestCollapserWithCache } from '../requestCollapserWithCache'; // 导入我们增强的合并器

interface QueryOptions {
  maxAge?: number; // 缓存过期时间,单位毫秒
  enabled?: boolean; // 是否启用请求,默认为 true
}

interface QueryResult<T> {
  data: T | undefined;
  loading: boolean;
  error: Error | undefined;
  refetch: () => Promise<T | undefined>; // 手动重新获取数据
  invalidate: () => void; // 使当前查询的缓存失效
}

/**
 * 一个自定义 React Hook,用于执行具有请求合并和缓存功能的异步查询。
 *
 * @param queryKey 查询的唯一标识符。通常是 API URL 或包含参数的字符串。
 * @param queryFn 一个返回 Promise 的函数,用于执行实际的数据获取逻辑。
 * @param options 查询选项,例如缓存过期时间。
 * @returns 包含数据、加载状态、错误和重试/失效方法的对象。
 */
function useCollapsibleQuery<T>(
  queryKey: string,
  queryFn: () => Promise<T>,
  options?: QueryOptions
): QueryResult<T> {
  const [data, setData] = useState<T | undefined>(undefined);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | undefined>(undefined);

  const enabled = options?.enabled ?? true;

  const fetchData = useCallback(async () => {
    if (!enabled) return;

    setLoading(true);
    setError(undefined); // 清除之前的错误
    try {
      const result = await requestCollapserWithCache.collapseAndCacheRequest(queryKey, queryFn, {
        maxAge: options?.maxAge,
      });
      setData(result);
      return result; // 返回数据以便 refetch 可以使用
    } catch (err: any) {
      setError(err);
      setData(undefined); // 错误时清空数据
      throw err; // 重新抛出错误,以便外部调用者(如 refetch)能够捕获
    } finally {
      setLoading(false);
    }
  }, [queryKey, queryFn, options?.maxAge, enabled]);

  useEffect(() => {
    // 只有当 queryKey 或 queryFn 变化,并且 enabled 为 true 时才执行
    fetchData();
  }, [fetchData]); // 依赖 fetchData 本身,它已经包含了所有必要的依赖

  // 手动重新获取数据的方法
  const refetch = useCallback(async () => {
    // 重新获取之前,先清除当前 key 的缓存,以确保获取最新数据
    requestCollapserWithCache.invalidateCache(queryKey);
    return fetchData();
  }, [queryKey, fetchData]);

  // 使当前查询的缓存失效的方法
  const invalidate = useCallback(() => {
    requestCollapserWithCache.invalidateCache(queryKey);
  }, [queryKey]);

  return { data, loading, error, refetch, invalidate };
}

export default useCollapsibleQuery;

现在,我们的 UserProfileCard 组件将变得更加简洁和强大:

// UserProfileCard.tsx (使用 useCollapsibleQuery)
import React from 'react';
import useCollapsibleQuery from './hooks/useCollapsibleQuery'; // 导入自定义 Hook

interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

interface UserProfileCardProps {
  userId: string;
}

function UserProfileCard({ userId }: UserProfileCardProps) {
  const { data: user, loading, error, refetch } = useCollapsibleQuery<User>(
    `/api/user/${userId}`, // queryKey
    async () => {
      console.log(`[UserProfileCard] Fetching user profile for ${userId} (via useCollapsibleQuery)`);
      const response = await fetch(`/api/user/${userId}`);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return await response.json();
    },
    { maxAge: 5 * 60 * 1000 } // 缓存 5 分钟
  );

  if (loading) return <div>加载用户资料...</div>;
  if (error) return <div>加载失败: {error.message} <button onClick={() => refetch()}>重试</button></div>;
  if (!user) return null;

  return (
    <div style={{ border: '1px solid #ccc', padding: '10px', margin: '10px' }}>
      <h3>{user.name}</h3>
      <p>邮箱: {user.email}</p>
      <img src={user.avatar} alt={user.name} width="50" height="50" />
      <button onClick={() => refetch()}>刷新资料</button>
    </div>
  );
}

export default UserProfileCard;

UserSettingsPanel.tsxUserActivityLog.tsx 也可以以同样的方式进行修改。

B. 全局 vs. 局部合并

我们目前实现的 requestCollapserWithCache 是一个全局单例。这意味着在整个应用中,所有 useCollapsibleQuery 实例都会共享同一个 pendingRequestsresolvedCache。这正是我们实现请求合并和缓存的目标。

如果每个 useCollapsibleQuery 都创建自己的 RequestCollapserWithCache 实例,那么它们将无法共享 pendingRequestsresolvedCache,从而失去了请求合并的意义。

在某些复杂的应用中,您可能需要多个独立的合并器实例(例如,一个用于匿名请求,一个用于认证请求)。这可以通过创建不同的 RequestCollapserWithCache 实例并将其通过 React Context 提供给组件树的不同部分来实现。

// providers/CollapserProvider.tsx
import React, { createContext, useContext, useMemo, ReactNode } from 'react';
import { RequestCollapserWithCache } from '../requestCollapserWithCache'; // 假设 RequestCollapserWithCache 已导出为类

// 创建一个 Context 来提供 Collapser 实例
const CollapserContext = createContext<RequestCollapserWithCache | undefined>(undefined);

interface CollapserProviderProps {
  children: ReactNode;
}

export const CollapserProvider: React.FC<CollapserProviderProps> = ({ children }) => {
  // 使用 useMemo 确保 Collapser 实例在组件重新渲染时保持不变
  const collapser = useMemo(() => new RequestCollapserWithCache(), []);

  return (
    <CollapserContext.Provider value={collapser}>
      {children}
    </CollapserContext.Provider>
  );
};

// 创建一个 Hook 来方便地使用 Collapser 实例
export const useCollapser = () => {
  const context = useContext(CollapserContext);
  if (context === undefined) {
    throw new Error('useCollapser must be used within a CollapserProvider');
  }
  return context;
};

// 修改 useCollapsibleQuery.ts 以使用 useCollapser
// ...
// function useCollapsibleQuery<T>(...) {
//   const collapser = useCollapser(); // 从 Context 获取 Collapser 实例
//   // ... 然后在 fetchData 中使用 collapser.collapseAndCacheRequest(...)
// }
// ...

然后在应用的顶层:

// main.tsx 或 App.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { CollapserProvider } from './providers/CollapserProvider';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <CollapserProvider> {/* 在这里包裹整个应用 */}
      <App />
    </CollapserProvider>
  </React.StrictMode>
);

C. 与Suspense的结合

React Suspense for Data Fetching 是一种更高级的、声明式的数据获取方式,它允许组件在数据准备好之前“暂停”渲染。像 React QuerySWR 这样的库已经很好地集成了 Suspense。在这些库的底层,请求合并和缓存是它们实现高性能数据获取的关键优化之一。我们的 useCollapsibleQuery 虽然没有直接实现 Suspense,但其内部的合并和缓存逻辑与 Suspense 的优化目标是相符的。

七、请求合并的进阶考量与相关概念

在深入理解请求合并后,我们需要将其与一些相关但不同的概念进行区分和比较。

A. 请求批处理 (Request Batching)

  • 目标: 将多个 不同相关 的请求合并成一个请求发送到服务器。
  • 场景: 解决 N+1 问题。例如,在一个列表中显示10个用户,每个用户又需要获取其最近的3条评论。如果没有批处理,可能需要1个请求获取用户列表,然后对每个用户发送1个请求获取评论,总共 1 + 10 = 11 个请求。批处理可以将其合并为 1 个请求获取用户列表,1 个请求获取所有用户的所有评论。
  • 实现: 通常需要服务器端的支持(如 GraphQL 的 DataLoader,或自定义批处理 API 端点)。客户端负责收集一段时间内的请求,然后统一发送。
  • 与请求合并的区别: 请求合并处理的是 完全相同 的并发请求,而请求批处理处理的是 不同相关 的请求。

B. 防抖 (Debouncing) 与 节流 (Throttling)

  • 防抖 (Debouncing): 在一段时间内,如果事件被频繁触发,只执行最后一次。
    • 场景: 搜索框输入(用户停止输入一段时间后才发起搜索请求)。
    • 与请求合并的区别: 防抖是为了减少事件触发频率,从而减少请求次数。它通常关注的是用户交互事件,而不是并发请求的去重。防抖会延迟请求,而请求合并旨在立即响应并共享结果。
  • 节流 (Throttling): 在一段时间内,无论事件触发多少次,只执行一次。
    • 场景: 滚动事件、窗口 resize 事件(每隔固定时间才响应一次)。
    • 与请求合并的区别: 节流是控制执行频率,与防抖类似,目的都是减少不必要的重复执行,但不是针对并发相同请求的去重。

以下表格总结了这些概念的核心差异:

特性 请求合并 (Request Collapsing) 请求批处理 (Request Batching) 防抖 (Debouncing) 节流 (Throttling)
目标 去除 完全相同 的并发请求 合并 不同但相关 的请求 在短时间内只执行 最后一次 在固定时间内只执行 一次
处理对象 相同 URL/参数的并发请求 逻辑相关但独立的请求 频繁触发的事件 频繁触发的事件
何时发送 第一次请求立即发送,后续共享 收集一定数量或时间后统一发送 停止触发后等待一段时间发送 每隔固定时间发送一次
影响 减少重复网络请求,共享结果 减少总请求数,优化 N+1 问题 减少函数执行次数,优化性能 限制函数执行频率,平滑响应
典型场景 多个组件同时请求用户资料 获取用户列表及每个用户的评论 搜索输入、窗口 resize 滚动事件、拖拽

C. 乐观更新 (Optimistic Updates)

乐观更新是一种用户体验优化策略,它假设某个操作会成功,并在客户端立即更新UI,而不是等待服务器响应。如果服务器响应失败,UI会回滚。

  • 与请求合并的关系: 乐观更新和请求合并是正交的。乐观更新关注的是操作的即时反馈,而请求合并关注的是数据获取的效率。它们可以在同一个应用中并行使用。例如,你可以乐观更新一个用户名称,然后发送一个更新请求,同时,其他组件如果需要获取用户资料,仍然可以通过请求合并来获取(可能需要 invalidateCache 来确保更新请求成功后获取最新数据)。

D. 服务器端优化

即使客户端做了请求合并,服务器端的优化仍然至关重要。

  • HTTP/2 多路复用: 允许在单个 TCP 连接上同时发送多个请求和响应,减少了连接建立的开销。
  • CDN 缓存: 将静态资源和一些不经常变动的数据缓存在离用户更近的边缘节点,进一步减少延迟。
  • 服务器端缓存: 服务器内部对数据库查询结果进行缓存,减少对数据库的压力。
  • 高效的 API 设计: 设计能够一次性返回所需数据的 API,减少客户端多次请求的需要。

请求合并是客户端的优化,它并不能替代服务器端的优化,而是与服务器端优化协同工作,共同提升应用的整体性能。

八、现有库的解决方案

虽然我们已经详细探讨了如何手动实现请求合并和缓存,但在实际项目中,强烈推荐使用成熟的第三方库。这些库通常提供了更全面、更健壮、更易用的解决方案,包括:

  1. React Query (TanStack Query):

    • 特点: 功能强大,提供了开箱即用的请求合并、缓存、后台数据同步(stale-while-revalidate)、自动重试、乐观更新、分页等功能。它将服务器状态与UI状态分离,极大地简化了异步数据管理。
    • 请求合并: 默认内置,对于相同的 queryKey,它会自动合并并发请求。
    • 缓存: 智能缓存管理,包括垃圾回收、过期时间、缓存失效等。
  2. SWR:

    • 特点: 由 Vercel 开发,专注于“Stale-While-Revalidate”策略。它会先返回缓存数据(stale),同时在后台发起请求重新验证(revalidate),并在数据更新后自动更新UI。
    • 请求合并: 同样内置了请求合并机制,对于相同的 key,在短时间内只会发送一个请求。
    • 缓存: 轻量级但高效的缓存策略。
  3. Apollo Client / Relay (针对 GraphQL):

    • 特点: 这些是专门为 GraphQL 设计的客户端库。GraphQL 本身就鼓励客户端声明所需数据,服务器一次性返回。
    • 请求合并与批处理: Apollo Client 和 Relay 都有内置的查询去重(deduplication)和请求批处理(batching)功能,可以显著优化 GraphQL 请求。它们的规范化缓存也能有效减少不必要的网络请求。

为什么推荐使用这些库?

  • 成熟稳定: 经过大量生产环境验证,Bug 少,功能完善。
  • 功能全面: 除了请求合并,还提供了错误处理、重试、后台刷新、预加载、分页等高级功能。
  • 易于使用: 提供了友好的 Hook API,与 React 生态系统无缝集成。
  • 社区支持: 活跃的社区和丰富的文档,遇到问题容易找到解决方案。
  • 性能优化: 它们的内部实现通常比我们手动编写的更优化、更鲁棒。

九、性能监控与调试

即使使用了请求合并,也需要对其效果进行监控和调试,以确保其正常工作并发挥最大效益。

  1. 浏览器网络面板 (Network Tab): 这是最直观的工具。
    • 观察请求数量: 在实现请求合并前后,对比相同 API 的请求数量。理想情况下,对于相同的并发请求,只会看到一个请求。
    • 观察请求时间: 检查请求的瀑布流,确保后续依赖相同数据的请求能够快速完成,因为它直接从 Promise 或缓存中获取结果,而不是等待网络。
    • 缓存命中: 如果有缓存,观察请求是否从 Service Worker 或浏览器缓存中返回(如果配置了)。
  2. 自定义日志: 在我们的 RequestCollapser 实现中,我们加入了 console.log。这些日志在开发环境中非常有用,可以清晰地看到何时发起了新的网络请求,何时返回了已存在的 Promise,何时命中了缓存。
  3. React DevTools: 可以观察组件的渲染次数和状态变化,帮助理解数据流和副作用。
  4. 性能分析工具: 使用 Lighthouse、WebPageTest 等工具进行端到端性能测试,评估请求合并对整体页面加载时间、首次内容绘制(FCP)、最大内容绘制(LCP)等指标的影响。

十、优化数据获取:效率与体验的平衡

请求合并是前端性能优化中的一项基础且强大的技术。它通过共享Promise和引入缓存机制,有效地减少了不必要的网络请求,减轻了服务器压力,并显著提升了用户体验。虽然我们可以从零开始实现它,但对于生产环境,我们强烈推荐使用像 React QuerySWR 这样成熟的数据获取库,它们将请求合并、缓存、后台刷新等高级功能封装得既强大又易用。理解其核心原理,将帮助我们更好地利用这些工具,并构建出高性能、高可维护性的React应用。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注