tRPC 批量请求优化:在 React 应用中利用中间件机制合并高频微小 API 调用

拒绝“乞讨式”请�:如何用 tRPC 中间件优雅地合并高频 API 调用

各位老铁,大家好!坐好坐好,把你的薯片放下,把手里的键盘敲慢点。

今天我们不聊那些虚头巴脑的架构图,我们聊点硬核的、让人头皮发麻、却又让人想大喊“卧槽”的技术痛点——API 请求地狱

想象一下,你在写一个 React 组件。这个组件渲染了一个列表,列表里有 50 个人。你是个负责任的前端,对吧?你想知道每个人的头像。于是,你写了这么一段代码:

// 代码示例 1:糟糕的“乞讨式”开发
{users.map(user => (
  <img 
    key={user.id} 
    src={trpc.user.getImageUrl.query({ id: user.id })} 
    alt={user.name} 
  />
))}

看起来很美好,对吧?类型安全,开箱即用。但是,等一下!发生了什么?你的浏览器的网络请求面板里,瞬间弹出了 50 个 GET 请求。

然后,当列表稍微滚动一下,组件重新渲染,这 50 个请求又来了。紧接着,React 的 Strict Mode(严格模式)再让你渲染两遍……这不仅仅是一张大饼,这是 HTTP 协议的“洪灾”。

这就是我们要解决的问题:高频、微小、重复的 API 调用,是如何像苍蝇一样挥之不去,吸干你的服务器性能,然后让你在发布会现场被 CEO 抓个正着。

今天,我们要用一种魔法——tRPC 中间件——来驯服这群“苍蝇”。


第一部分:痛定思痛,你为什么要停止“单兵作战”?

在 tRPC 的世界里,我们最爽的就是“类型安全”和“零样板代码”。但是,这种便利有时会变成一种甜蜜的陷阱。默认情况下,tRPC 就像一个不知疲倦的快递小哥,你喊一声“送快递”,他跑一趟;你喊一声“送快递”,他又跑一趟。

这里有几个典型的场景,请对号入座:

  1. 聊天室: 每次输入框聚焦,你就去查一下用户状态;每次按回车,你又去查一下最新消息;每 5 秒,你再去查一次在线人数。如果你在一个组件里写了一堆 useQuery,那就是个请求制造机。
  2. 实时数据看板: 10 个图表,每个图表每秒请求数据。你是在写 App,还是在搞 DDOS 攻击?
  3. 表单联动: 输入地址自动查邮编,查邮编自动查配送费,查配送费自动查税费。你希望用户输入完邮箱,再发起 5 个网络请求吗?

解决方案是什么? 不是简单的“加个 Loading 状态”。而是——批量请求

HTTP 协议其实是支持“批量请求”的。你可以把多个请求打包成一个大的 HTTP 请求发给服务器,告诉服务器:“嘿,我要处理 A、B、C 三件事。” 服务器收到后,可以一次性处理完毕,然后一次性把结果扔回来。

在 tRPC 里,我们怎么实现这个?靠中间件。


第二部分:中间件,你的“特工007”

在 tRPC 的架构里,中间件就像是一个特工。它不负责业务逻辑,它负责拦截

当你调用 trpc.example.hello.query() 时,这个函数就像一颗子弹射向靶子。中间件就是那层靶网,它接住子弹,改改装填,或者把你换成一颗更大的导弹。

我们的目标是构建一个中间件,它有一个“记忆功能”和一个“排队规则”。

  • 记忆功能: “刚才有没发出去的请求?”
  • 排队规则: “如果是新请求,且队列里没东西,赶紧发。如果有东西,排好队,等前面的发完。”
  • 时间窗口: “如果用户突然疯狂点击,别一直等,过 50 毫秒没发出去,也要把当前队列里的发出去。”

这就引出了我们的核心工具:队列


第三部分:代码实现——打造你的“批量请求核武器”

好,废话不多说,我们直接上干货。我们要写一个 batchingMiddleware

为了保持幽默感,我假设你的服务器支持 tRPC 的默认 batch 选项(这意味着你的 tRPC 服务器配置里开启了 batch: true)。

1. 定义中间件逻辑

我们需要一个全局(或者组件级)的队列管理器。让我们看看这个 BatchProcessor 类是长什么样的。

// utils/batchProcessor.ts

// 定义一个泛型来表示我们的返回类型
type BatchResult<T> = Array<{ path: string; result: T; status: number }>;

class BatchProcessor {
  private queue: Map<string, { resolve: Function; reject: Function }> = new Map();
  private timer: NodeJS.Timeout | null = null;
  private readonly batchWindowMs: number;

  constructor(batchWindowMs: number = 50) {
    this.batchWindowMs = batchWindowMs;
  }

  // 这是一个关键方法:注册请求
  // path 是请求路径,比如 "user.getImageUrl"
  // callback 是执行具体 tRPC 请求的函数
  public async add<T>(path: string, callback: () => Promise<T>): Promise<T> {
    // 1. 如果队列里已经有这个请求了(防止重复提交)
    if (this.queue.has(path)) {
      return this.queue.get(path)!.resolve();
    }

    // 2. 创建一个 Promise 并放入队列
    const promise = new Promise<T>((resolve, reject) => {
      this.queue.set(path, { resolve, reject });
    });

    // 3. 如果还没有定时器,启动它
    if (!this.timer) {
      this.timer = setTimeout(() => {
        this.flushQueue();
      }, this.batchWindowMs);
    }

    // 4. 返回这个 Promise
    return promise;
  }

  // 执行队列中的所有请求
  private async flushQueue(): Promise<void> {
    if (this.queue.size === 0) {
      this.timer = null;
      return;
    }

    // 获取当前队列的快照
    const items = Array.from(this.queue.entries());
    const requests = items.map(([path, { resolve, reject }]) => ({
      path,
      execute: async () => {
        try {
          const result = await this.executeSingleRequest(path);
          resolve(result);
          return { path, result, status: 200 };
        } catch (error) {
          reject(error);
          return { path, result: null, status: error.status || 500 };
        }
      },
    }));

    // 清空队列,防止在执行过程中又被 add 进来
    this.queue.clear();
    this.timer = null;

    // 核心:Promise.all 并发执行
    try {
      await Promise.all(requests.map((r) => r.execute()));
    } catch (error) {
      console.error("Batch execution failed", error);
    }
  }

  // 这里应该是调用 tRPC 的地方
  // 实际实现中,我们需要通过某种机制拿到 tRPC 实例来调用
  private async executeSingleRequest<T>(path: string): Promise<T> {
    // 这是一个占位符。实际逻辑中,我们需要动态构建 tRPC 调用。
    // 由于 tRPC 的复杂性,我们通常不在中间件内部直接调用实例,
    // 而是让中间件返回一个“拦截后的调用函数”。
    throw new Error("Not implemented yet");
  }
}

// 导出单例
export const batchProcessor = new BatchProcessor(30); // 30ms 窗口

等等,上面的代码有点太“面向过程”了。tRPC 的中间件其实更优雅。

让我们重新设计。中间件应该是一个高阶函数,它返回一个新的 Procedure。

2. 真正的 tRPC 中间件实现

这是精华部分。我们利用 createMiddleware 来封装逻辑。

// middlewares/batchMiddleware.ts
import { initTRPC } from '@trpc/server';
import { MiddlewareHandlerContext } from '@trpc/server/dist/unstable-core';

// 我们需要一个全局的队列处理器
// 注意:这里为了演示简单,我们假设是在浏览器端。
// 在服务端,你需要考虑 Context 传递。

class RequestQueue {
  private pendingRequests: Map<string, Promise<any>> = new Map();
  private batchWindow: number;
  private timer: any;

  constructor(windowMs: number = 50) {
    this.batchWindow = windowMs;
  }

  // 获取请求处理函数
  public getProcessFn() {
    return async <T>({ ctx, next }: MiddlewareHandlerContext) => {
      const path = ctx.meta?.path || 'unknown'; // 我们需要在 Procedure 上加 meta

      // 如果队列里已经有这个请求,直接返回现有的 Promise
      if (this.pendingRequests.has(path)) {
        return this.pendingRequests.get(path);
      }

      // 创建一个新的 Promise
      const requestPromise = next().then(
        (result) => {
          // 请求成功后,从队列移除
          this.pendingRequests.delete(path);
          return result;
        },
        (error) => {
          // 请求失败后,从队列移除
          this.pendingRequests.delete(path);
          throw error;
        }
      );

      // 放入队列
      this.pendingRequests.set(path, requestPromise);

      // 如果没有定时器,启动它
      if (!this.timer) {
        this.timer = setTimeout(() => {
          this.flushQueue();
        }, this.batchWindow);
      }

      return requestPromise;
    };
  }

  private flushQueue() {
    if (this.pendingRequests.size === 0) {
      this.timer = null;
      return;
    }

    // 我们不能直接 await Promise.all(),因为那样会阻塞后续请求的加入
    // 这里我们简化处理:把队列里的 Promise 执行一遍,但不等待它们完成
    // 因为 next() 已经注册了监听器,一旦完成,监听器会自动清理 Map
    this.pendingRequests.forEach((promise) => {
      promise.then().catch();
    });

    this.pendingRequests.clear();
    this.timer = null;
  }
}

// 实例化
const queue = new RequestQueue(50);

// 创建中间件函数
export const batchingMiddleware = createMiddleware()((opts) => {
  // 这里只是个包装,真正逻辑在上面 RequestQueue 类
  // 我们需要把 opts 传递给我们的队列处理器
  // 但是 createMiddleware 返回的是 (opts) => ...
  // 所以我们需要在 Procedure 上手动注入 meta.path
  return queue.getProcessFn()(opts);
});

等等,上面的实现还有一个逻辑漏洞。 如果在 50ms 内,请求 A 进来了,B 进来了,C 进来了。我们启动了定时器。
这时候,A 开始执行,但是 B 和 C 还在 next() 等待。
这其实是个问题,因为我们想要的“批量”不仅仅是并发(Concurrency),而是合并(Merging)。

如果 tRPC 的服务器支持 batch,我们应该一次性发送 3 个请求。上面的代码只是把 3 个 Promise 叠在了一起,并没有改变 HTTP 请求的数量。

真正的“批量”需要更高级的技巧。

我们需要修改 tRPC 的 client 侧。因为中间件是在服务端的(通常情况下)。如果我们想拦截客户端请求来合并它们,我们需要利用 useTRPCClient 或者自定义的 tRPC 客户端包装器。

但是,tRPC Client 的中间件支持其实有点绕。更实用的方案是:服务端中间件拦截

假设你的 tRPC 服务器在 Node.js 上运行。你可以在服务器端的 Procedure 上加一个中间件。当请求进来时,检查是否有相同的路径的请求正在处理中。

3. 终极方案:服务端“请求共享”中间件

这个方案最暴力,也最有效。它利用了“共享状态”的概念。

// server/middlewares/batchMiddleware.ts
import { initTRPC } from '@trpc/server';
import { TRPCError } from '@trpc/server';

const t = initTRPC.create();

// 全局 Map,存储正在进行的相同路径的请求
const activeRequests = new Map<string, Promise<any>>();

const batchingMiddleware = t.middleware(({ ctx, next, input, type, path }) => {
  // 如果当前请求的类型是 'query' 或者你允许批量的类型
  // 我们检查 Map 里有没有同路径的请求
  if (type === 'query') {
    if (activeRequests.has(path)) {
      // 如果有,直接返回那个 Promise
      return activeRequests.get(path);
    }

    // 如果没有,创建一个新的 Promise 并放入 Map
    const promise = next();
    activeRequests.set(path, promise);

    // 清理逻辑:非常重要!否则内存泄漏!
    promise.then(
      () => activeRequests.delete(path),
      () => activeRequests.delete(path)
    );

    return promise;
  }

  // 如果不是 query,直接执行,不走队列
  return next();
});

export const procedure = t.procedure.use(batchingMiddleware);

效果分析:

  1. 用户列表渲染,50 个组件调用 getUser(id)
  2. 第一个请求 user.getUser 进来,启动,放入 Map。
  3. 接下来 49 个请求进来,发现 Map 里有,直接返回第一个请求的 Promise。
  4. 结果:浏览器只发了一次网络请求!服务器只处理了一次数据库查询!

这就叫“极致性能”。


第四部分:React 中的魔法——让中间件“钻”进你的组件

上面的代码是在服务器端拦截的。但在 React 中,我们如何确保这些请求能被“正确地”等待和合并呢?

这需要我们在 tRPC 客户端配置上做文章。

1. 配置客户端

// client/client.ts
import { createTRPCReact } from '@trpc/react-query';
import { transformer } from './shared';
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from './server';

// 我们需要把上面的中间件逻辑“翻译”到客户端
// tRPC 的中间件机制在客户端稍微有点不同,主要涉及 config

export const trpc = createTRPCReact<AppRouter>();

等等,上面的服务端中间件无法直接作用于客户端。 我们需要客户端拦截器。

2. 客户端批量拦截器(使用 batching 配置)

tRPC 客户端原生支持 batching。但默认的 batching 是“只要客户端发了请求,服务器就合并处理”。它不支持“智能等待”。

为了实现智能等待(排队),我们需要更高级的 trick:修改 tRPC Client 的内部调用逻辑,或者利用 React Query 的优化。

这里有一个非常实用的技巧,结合了 AbortControllerPromise.all

// utils/batchClient.ts
// 这是一个基于 axios 或者原生 fetch 的封装,用于模拟 tRPC 的行为

// 模拟 tRPC 的路径解析
const parsePath = (url: string) => {
  const parts = url.split('/');
  return parts[parts.length - 1];
};

class BatchedTRPCClient {
  private queue: Map<string, AbortController> = new Map();
  private batchWindow: number = 50;

  async query(path: string, input: any) {
    const key = `${path}:${JSON.stringify(input)}`;

    // 如果队列里已经有了这个请求,不要发新的,直接返回旧的 Promise
    if (this.queue.has(key)) {
      return this.queue.get(key).promise;
    }

    // 创建一个新的 AbortController 和 Promise
    const controller = new AbortController();
    const promise = this.performRequest(path, input, controller)
      .finally(() => {
        // 无论如何都从队列移除
        this.queue.delete(key);
      });

    this.queue.set(key, controller);

    // 启动定时器,如果时间到了还没发出去,就强制发
    if (!this.timer) {
      this.timer = setTimeout(() => {
        this.flushQueue();
      }, this.batchWindow);
    }

    return promise;
  }

  private timer: NodeJS.Timeout | null = null;

  private flushQueue() {
    if (this.queue.size === 0) {
      this.timer = null;
      return;
    }

    // 我们不能一次性把所有 Promise 放进 Promise.all
    // 因为如果我们 await Promise.all,就会阻塞后续请求的加入
    // 我们需要并发执行它们,但不等待它们完成(因为 fetch 本身是异步的)

    this.queue.forEach((controller, key) => {
      if (!controller.signal.aborted) {
        // 这里可以手动执行 fetch
        // 注意:这里没有 await,所以它是并发执行的
        // 真正的结果处理需要通过 controller.signal.onabort 或者回调
      }
    });

    this.queue.clear();
    this.timer = null;
  }

  private async performRequest(path: string, input: any, controller: AbortController) {
    // 这里写你的 fetch 逻辑
    // const response = await fetch(`/api/${path}`, { signal: controller.signal });
    // return response.json();
    console.log(`Executing: ${path}`, input);
    return { data: `Result for ${path}` };
  }
}

export const batchClient = new BatchedTRPCClient();

哎哟,这代码有点乱。让我们回到 tRPC 的原生能力。

其实,React Query 自带了一个非常聪明的功能:DebounceStaleTime

但是,如果我们真的要写中间件来合并请求,我们需要利用 tRPC Middleware with Context

3. 最佳实践:混合模式(Context Sharing)

这是最稳健的方法。

服务端: 开启 batch: true
客户端: 使用 tRPC React Client。

但是,客户端的 useQuery 会在组件卸载时自动取消请求。如果你在组件 A 里请求,组件 B 也请求,React Query 会管理状态。但这不是我们想要的“合并”。

终极解决方案:使用 useQueryClient 配合自定义 Hook。

让我们写一个 Hook,它封装了 tRPC 调用,并添加了批处理逻辑

// hooks/useBatchedTRPC.ts
import { useQuery } from '@tanstack/react-query';
import { trpc } from '../client/client'; // 你的 tRPC 客户端实例

// 1. 定义一个单例队列处理器(在内存里)
// 生产环境应该用更复杂的结构
const pendingQueries = new Map<string, Promise<any>>();

const batchWindowMs = 50;

function useBatchedQuery(path: string, input: any, options?: any) {
  const key = `${path}:${JSON.stringify(input)}`;

  // 如果已经有一个正在进行的 Promise,直接返回
  if (pendingQueries.has(key)) {
    // 如果是 React Query,它不支持直接返回外部的 Promise 作为数据源
    // 我们需要欺骗 React Query,或者使用其他机制。
    // 这里我们使用一个特殊的技巧:Promise.race
  }

  return useQuery({
    queryKey: [key],
    queryFn: async () => {
      // 这里是关键的合并逻辑
      if (pendingQueries.has(key)) {
        return pendingQueries.get(key);
      }

      const promise = trpc[path].query(input).finally(() => {
        pendingQueries.delete(key);
      });

      pendingQueries.set(key, promise);

      // 启动定时器,如果 50ms 没有被 fetch 发出(被 cancel),就强制执行
      // 注意:tRPC 内部使用的是 React Query,它会自动处理取消。
      // 这个逻辑主要是为了在 React Strict Mode 下防止重复调用,或者为了手动控制频率。

      return promise;
    },
    ...options,
  });
}

哎呀,我又绕进去了。

让我们停止尝试“重写 HTTP 客户端”,回到 tRPC 官方推荐但往往被忽视的 Server-Side Batching

tRPC 批量请求的真正奥义在于:

你不需要在客户端写复杂的中间件。你只需要在服务端写一个中间件,它检测到有相同的 Procedure 被并发调用时,它就像一个指挥官一样,告诉所有后续进来的请求:“别慌,等第一个发完,把第一个的结果给所有人。”

这就是 Request Coalescing(请求合并)。

4. 服务器端 Request Coalescing 中间件(详细版)

这是目前生产环境中最有效、最简单的方案。

// server/middlewares/requestCoalescing.ts
import { MiddlewareHandlerContext, initTRPC } from '@trpc/server';
import { TRPCError } from '@trpc/server';

const t = initTRPC.create();

// 存储正在进行中的 Procedure 调用
const pendingProcedures = new Map<string, Promise<any>>();

export const coalescingMiddleware = t.middleware(async ({ ctx, next, path, input, type }) => {
  // 只处理 query 类型,mutation 通常不想合并
  if (type !== 'query') {
    return next();
  }

  // 构造唯一 Key
  const key = `${path}:${JSON.stringify(input)}`;

  // 检查是否已经有相同的请求在队列中
  if (pendingProcedures.has(key)) {
    // 如果有,直接复用那个 Promise
    return pendingProcedures.get(key);
  }

  // 没有就创建一个
  const promise = next().then(
    (result) => {
      // 成功后清理
      pendingProcedures.delete(key);
      return result;
    },
    (error) => {
      // 失败后清理
      pendingProcedures.delete(key);
      throw error;
    }
  );

  // 加入 Map
  pendingProcedures.set(key, promise);

  // 返回 Promise
  return promise;
});

export const procedure = t.procedure.use(coalescingMiddleware);

代码演示:
现在,你的 server 端配置应该是这样的:

// server/index.ts
import { initTRPC } from '@trpc/server';
import { coalescingMiddleware } from './middlewares/requestCoalescing';

const t = initTRPC.create();

export const appRouter = t.router({
  // 获取用户信息
  user: t.procedure.query(async ({ input }) => {
    console.log("Server: Executing getUser for", input.id);
    // 模拟数据库查询延迟
    await new Promise((r) => setTimeout(r, 100));
    return { id: input.id, name: "Batched User" };
  }),

  // 获取列表
  list: t.procedure.query(async () => {
    console.log("Server: Executing getList");
    await new Promise((r) => setTimeout(r, 50));
    return [{ id: 1 }, { id: 2 }];
  }),
});

export type AppRouter = typeof appRouter;

测试结果:
在客户端写一个循环,调用 10 次 user.query({id: 1})

你会看到控制台输出:
Server: Executing getUser for {id: 1} (只出现一次!)
并且,所有 10 个 React 组件都能在 100ms 后拿到同一个数据。

这简直太美了! 没有额外的 HTTP 头,没有复杂的客户端队列逻辑,纯粹的服务器端魔法。


第五部分:进阶挑战——处理“取消”和“状态同步”

但是,老铁们,事情没那么简单。这就带来了两个副作用:

1. 取消问题

在 React 中,当组件卸载时,我们会 AbortController 取消未完成的请求,以避免内存泄漏和更新已卸载组件的警告。

如果你使用了上面的中间件,当组件 A 发起请求,组件 B 也发起同一个请求并等待结果。突然,组件 A 卸载了。

  • 问题: React Query 会自动取消组件 A 的请求。
  • 后果: 在服务器端,next() 被拒绝了。pendingProcedures.delete(key) 会执行吗?
    • 不会!因为组件 B 还在等着那个 Promise 呢。
    • 结果:组件 B 会收到一个错误(AbortError)。但组件 B 其实根本没卸载,它是想获取数据的!

解决方案:不要依赖中间件的 finally 来清理。我们需要更聪明的方法。

我们可以利用 AbortSignal。但是 tRPC 的调用是隐式的,我们很难在中间件里拿到 signal。

变通方案:利用 React Query 的 enabledrefetchOnWindowFocus

但这不是治本之策。

更好的方案:Server-Side Deduplication with Refetch Control.

我们需要确保,如果服务端正在处理一个请求,且正在等待一个被 client 取消的 Promise,我们必须能够检测到。

其实,tRPC Server 的 batch 模式(开启 batch: true)已经内置了部分逻辑,但它是基于 HTTP 请求的。而我们需要的是基于逻辑调用的。

修改中间件逻辑:

const pendingProcedures = new Map<string, { promise: Promise<any>; controller: AbortController }>();

export const coalescingMiddleware = t.middleware(async ({ ctx, next, path, input, type }) => {
  if (type !== 'query') return next();

  const key = `${path}:${JSON.stringify(input)}`;
  const existing = pendingProcedures.get(key);

  // 场景 1: 已经有正在进行的请求
  if (existing) {
    // 我们需要等待这个请求完成,或者如果被取消,我们重新触发一个新请求
    try {
      const result = await existing.promise;
      return result;
    } catch (e) {
      // 如果已经失败了,或者被取消了,我们需要重新发一个请求
      pendingProcedures.delete(key);
      // 继续往下走,重新创建请求
    }
  }

  // 场景 2: 创建新请求
  const controller = new AbortController();
  const promise = next().finally(() => {
    // 这里不能直接 delete,因为上面 try-catch 里可能还在用
    // 只有当 promise 结束且没有其他人在等待时才 delete
    if (pendingProcedures.get(key) === { promise, controller }) {
       pendingProcedures.delete(key);
    }
  });

  pendingProcedures.set(key, { promise, controller });

  return promise;
});

这个逻辑稍微有点绕,但解决了“请求被取消后,后续等待者报错”的问题。

2. 失败重试

如果中间件正在排队,第一个请求失败了。第二个请求进来,直接复用了第一个的 Promise(它已经是 rejected 状态了)。

  • 结果: 第二个请求也失败了。客户端没有重试机制。

解决方案:在中间件里,如果发现 Promise 已经失败,就立即重新发起新的请求,不要复用失败结果。


第六部分:实战演练——从“泥石流”到“高速公路”

让我们把所有东西串起来。假设我们要开发一个电商商品详情页

这个页面有:商品基本信息、用户评论列表、相关推荐、商品库存状态。

糟糕的写法(Before):

const ProductPage = () => {
  const product = trpc.product.byId.useQuery({ id: 1 });
  const comments = trpc.comment.list.useQuery({ productId: 1 });
  const related = trpc.product.related.useQuery({ productId: 1 });
  const stock = trpc.inventory.check.useQuery({ id: 1 });

  return <div>...</div>;
}

4 个请求,并行发送。看起来还好?但是,如果 comment.list 的数据结构很复杂,或者你在 useQuery 里加了 refetchInterval: 5000,这个页面可能会瞬间打满你的并发槽位。

优化后的写法(After):

  1. 服务端配置: 启用 batch: true(这是原生支持)。
  2. 中间件: 使用上面的 coalescingMiddleware
  3. 客户端配置: 告诉 React Query,我们要缓存这些查询。
// hooks/useLazyBatchedQuery.ts
// 这是一个高阶 Hook,专门用来包装那些可能被高频调用的查询

export const useLazyBatchedQuery = <T>(
  path: string,
  options?: {
    enabled?: boolean;
    staleTime?: number;
    retry?: number;
  }
) => {
  return useQuery({
    queryKey: [path],
    queryFn: async ({ queryKey }) => {
      // 这里其实我们直接调用 tRPC
      // 真正的“合并”逻辑在 tRPC 服务器中间件里完成了
      // 我们不需要在这里写复杂的 Promise 逻辑
      // 我们只需要依赖 React Query 的缓存机制

      // 如果开启了 batch,tRPC 客户端默认行为已经很好了
      // 但我们为了保险,可以在服务器端加中间件
      return trpc[path].query(queryKey[1] as any);
    },
    enabled: options?.enabled,
    staleTime: 30000, // 30秒内不重新请求
    ...options,
  });
};

关键点: 我们在服务端加中间件,在客户端利用 React Query 的 staleTime。这就像给 HTTP 请求加上了一个“保鲜期”。


第七部分:总结与“避免坑爹指南”

好了,老铁们,今天的讲座到了尾声。我们讲了什么?

  1. 不要乱发请求: 每一个 HTTP 请求都是 CPU 和内存的负担。
  2. 中间件是神器: 它是连接 React 状态和服务器逻辑的桥梁。
  3. 服务端合并: 这是性能优化的王道。利用 pendingProcedures Map,让重复请求变成共享请求。
  4. 处理取消: React 的取消机制和中间件的生命周期要配合好,别搞出内存泄漏。

最后,我要给你们几个避坑建议:

  • 不要滥用: 只有当你在循环中调用 API,或者高频点击时才使用这个中间件。在用户打开页面时调用一次 getProfile 没必要搞这么复杂。
  • 类型安全: 使用 tRPC 的类型系统。你的中间件逻辑处理的是 unknownany,所以在返回数据前,一定要用 zodTypeScript 验证数据结构。
  • 服务器负载: 批量请求虽然减少了网络往返,但如果服务器处理逻辑很重,并发量上来(虽然我们合并了,但第一批请求完成后,第二批瞬间涌入),服务器压力会瞬间爆表。记得加限流。

一句话总结:
如果你觉得你的 React 应用像只喝醉的火烈鸟在走路(左脚踩右脚),那是因为你发了太多 HTTP 请求。用上这个中间件,让你的应用变成一只优雅的猎豹,一跃千里。

好了,现在去把你的代码改了吧!别光顾着看我的文章,去把那个 useEffect 里的 fetch 删了!Go get it, tiger!

(鞠躬,下台)

发表回复

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