tRPC 客户端 Links 链接链完全指南:数据流定制、自定义 Link 与终止 Link
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
本篇技术指南围绕 tRPC 官方文档中「Links Overview」一节(www/versioned_docs/version-10.x/client/links/overview.md)展开,系统讲解 tRPC 客户端 Links 的核心设计:什么是 link、如何将多个 link 组合成一条"链接链"、如何按三部分结构编写自定义 link、终止 link 的作用,以及如何通过op.context在链接链内传递与修改上下文。读完本文,你将掌握在 Next.js 集成或 vanilla 客户端中正确配置links数组、编写可复用的自定义 link、并按需选择httpBatchLink/httpLink/wsLink等终止 link 的完整实战能力,同时理解其背后的类型系统与可观察对象(observable)机制。
Links 是什么:连接客户端与服务端请求管道的可组合单元
在 tRPC 中,Link 是用于定制 tRPC 客户端与服务端之间数据流的单元。一个理想的设计约束是:一个 link 只做一件事,这件事可以是:
- 对一次 tRPC 操作(
query、mutation或subscription)做自包含的修改,例如调整请求头、改写输入、重试失败请求; - 或者基于这次操作产生的副作用,例如记录日志、埋点上报。
由于每个 link 只承担单一职责,真实场景下通常需要把多个 link组合起来使用。tRPC 官方文档给出的典型例子是loggerLink(负责打印请求与响应日志)加httpBatchLink(负责把多个请求合并成一个 HTTP 请求发送出去),二者通过客户端配置中的links数组串联,构成完整的请求处理流程。
需要先说明:下面示例以 Next.js 的
createTRPCNext为例,但同样的写法也可以直接套用在 vanilla tRPC 客户端(createTRPCProxyClient)上——它们都接收一个带links属性的配置对象,见 官方相关示例目录。
链接链(Link Chain):数组顺序与双向执行
把多个 link 放进links数组并交给 tRPC 客户端,得到的是一条link chain(链接链)。链接链的含义是:
- 发起请求时,tRPC 客户端按照
links数组中 link 的先后顺序依次执行它们; - 处理响应时,会按相反顺序再次经过这些 link。
也就是说,排在最前面的 link 最"外层",它先看到请求、最后拿到结果;排在最后的 link 负责真正把请求送到服务器(因此必须是一个终止 link)。每次请求都会穿过整条链并原路返回。
下图是该机制的官方示意图(原图位于 www/static/img/links-diagram.svg,概念源自 Apollo Client 的 link 架构):
最小可用配置:在客户端接入 links
在 Next.js 集成中,你通常在config()里返回一个带links的对象,例如utils/trpc.ts:
import { httpBatchLink, loggerLink } from '@trpc/client'; import { createTRPCNext } from '@trpc/next'; export default createTRPCNext<AppRouter>({ config() { const url = `http://localhost:3000`; return { links: [ loggerLink(), httpBatchLink({ url, }), ], }; }, });如果你使用的是 vanilla 客户端,写法几乎一致——把createTRPCNext换成createTRPCProxyClient,links数组的内容完全相同,例如:
import { createTRPCProxyClient, httpBatchLink, loggerLink } from '@trpc/client'; import type { AppRouter } from '../server'; const client = createTRPCProxyClient<AppRouter>({ links: [ loggerLink(), httpBatchLink({ url: 'http://localhost:3000' }), ], });执行过程解读:
loggerLink()在链最前:请求"上行"时先记录>>日志;httpBatchLink({ url })是链尾的终止 link:它把操作真正发往服务端;- 响应"下行"时按反序先回到
httpBatchLink内部,再流经loggerLink,后者记录<<日志并携带elapsedMs(耗时)。
若按反序把httpBatchLink放前面、loggerLink放后面,日志打印与请求发送的相对顺序就会改变——理解数组顺序即执行顺序,对排查链接链行为至关重要。
Link 的类型本质:从TRPCLink到OperationResultObservable
官方文档对 link 的定义可以拆成三个嵌套层次,而这三个层次在仓库源码的类型声明里有非常直接的对应——见 packages/client/src/links/types.ts:
// packages/client/src/links/types.ts(节选) export type OperationLink< TInferrable extends InferrableClientTypes, TInput = unknown, TOutput = unknown, > = (opts: { op: Operation<TInput>; next: ( op: Operation<TInput>, ) => OperationResultObservable<TInferrable, TOutput>; }) => OperationResultObservable<TInferrable, TOutput>; export type TRPCLink<TInferrable extends InferrableClientTypes> = ( opts: TRPCClientRuntime, ) => OperationLink<TInferrable>;对照这张类型定义,可以把自定义 link 的"三段式"结构讲清楚:
- 第一层(最外层工厂函数):link 是一个返回函数、且形参为
TRPCClientRuntime的函数。这个runtime参数由 tRPC 在创建客户端时传入,通常用于在创建终止 link时传递运行时配置。如果你写的不是终止 link,可以不声明任何参数,直接写成一个返回中间层函数的函数——此时该 link 应当不加括号地放进links数组(例如links: [..., myLink, httpBatchLink(...)]),因为调用myLink这件事由 tRPC 客户端替你完成。 - 第二层(中间层中间件):第一层返回的函数接收一个对象,内含两个属性:
op:客户端正在执行的Operation(包含type、path、input、context、id、signal等字段);next:用于把操作交给链条中下一个 link的函数。
- 第三层(观察者订阅层):第二层返回的函数最终返回由
@trpc/server/observable提供的observable。observable接收一个以observer为参数的回调,link 通过这个observer向上游(外层 link)通知操作结果的去向——你可以原样return next(op)直通下游,也可以订阅next的结果,从而获得处理操作结果(含响应值、错误、完成)的能力。
Operation与OperationResultEnvelope:链接链中流动的数据契约
为了让三层结构可操作,需要先理解链接链中"流动"的对象长什么样。从 types.ts 可以看到一次操作Operation的完整字段:
export interface OperationContext extends Record<string, unknown> {} export type Operation<TInput = unknown> = { id: number; // 本次操作的唯一编号 type: 'mutation' | 'query' | 'subscription'; // 操作类型 input: TInput; // 过程输入参数 path: string; // 过程路径,例如 'post.byId' context: OperationContext; // 可读写的上下文对象(跨 link 传递元数据) signal: Maybe<AbortSignal>; // 中止信号,用于取消 };中间 link 之间通过next(op)传递的是Operation;终止 link 在真正完成网络请求后,向上返回的是 OperationResultEnvelope,其中包含服务端的结果(或连接状态)以及可能被修改的context。
从源码看,客户端初始化时会对配置里的每个 link 执行一次"外层工厂调用",把整条链"实例化"好。见 packages/client/src/internals/TRPCUntypedClient.ts:
this.runtime = {}; // Initialize the links this.links = opts.links.map((link) => link(this.runtime));这正是文档中所说"每个 link 的初始化在每个 app 中只发生一次"的底层实现依据——后续每次请求只是复用这条已实例化的链。
编写一个自定义 Link:完整示例与逐步拆解
下面是从官方文档原样保留的完整自定义 link 示例,它用console.log打印每个操作与结果,演示了观察并转发上游/下游流量的写法:
import { TRPCLink } from '@trpc/client'; import { observable } from '@trpc/server/observable'; import type { AppRouter } from 'server/routers/_app'; export const customLink: TRPCLink<AppRouter> = () => { // here we just got initialized in the app - this happens once per app // useful for storing cache for instance return ({ next, op }) => { // this is when passing the result to the next link // each link needs to return an observable which propagates results return observable((observer) => { console.log('performing operation:', op); const unsubscribe = next(op).subscribe({ next(value) { console.log('we received value', value); observer.next(value); }, error(err) { console.log('we received error', err); observer.error(err); }, complete() { observer.complete(); }, }); return unsubscribe; }); }; };对照官方文档的"三步法",这段代码可以逐行理解为:
- 初始化阶段(每 app 一次):
export const customLink: TRPCLink<AppRouter> = () => {...}这一层只在应用初始化时执行一次。注释中特别提示:这里很适合做"进程级缓存"等一次性初始化工作。因为它是非终止 link,所以在links数组中直接写customLink(不调用),由客户端在构造时注入runtime。 - 每请求阶段:
return ({ next, op }) => {...}在每个操作进入该 link 时执行。op告诉你"这次要做什么",next让你把操作原样交给下一环。 - 订阅与转发:
return observable((observer) => {...})建立观察者回调。代码先打印performing operation,然后next(op).subscribe({...})订阅下游结果,并在回调里:- 收到值
value时打印并observer.next(value)继续向上游转发; - 收到错误
err时打印并observer.error(err); - 收到完成信号时
observer.complete()。
- 收到值
- 清理:
return unsubscribe把取消订阅函数交还给 observable 运行时,保证操作被取消或链路中断时能正确释放订阅。这是可观察对象模式的典型资源回收点。
什么时候该"只转发",什么时候该"订阅"?
如果你的 link 只是修改输入或给请求附加东西(比如注入 header),可以不必订阅next,直接return next(op)把下游产生的 observable 原样返回,让整条链保持透传。只有当 link 需要在结果返回时做额外处理(如日志、缓存写入、错误重试、请求拆分)时,才需要next(op).subscribe(...)并在回调中把事件继续转发给observer。
想找真实的参考实现?
官方文档建议:如果需要一个更贴近真实场景的自定义 link 参考,直接去看 tRPC 自带的一批内置 link 源码。它们就在本仓库的 packages/client/src/links/ 目录下,包括:
- httpLink.ts:最基础的 HTTP 终止 link,一次操作一个请求;
- httpBatchLink.ts:支持批量的 HTTP 终止 link;
- httpBatchStreamLink.ts:基于流式响应的批量链接;
- httpSubscriptionLink.ts 与 wsLink/:处理订阅类操作的链接;
- loggerLink.ts:内置日志链接;
- splitLink.ts 与 retryLink.ts:条件分流与自动重试。
其中loggerLink是实现"副作用型 link"的最好范本——它并不修改请求,而是在请求>>上行时与结果<<下行时分别调用日志函数。从源码 loggerLink.ts 可以看到其内部正是通过next(op).pipe(tap({ next, error }))观察下游事件后再.subscribe(observer)完成转发。
终止 Link(Terminating Link):链接链的最后一环
终止 link 是链接链中最后一个 link。与普通 link 不同,终止 link不调用next——因为链条到这里就到头了,它负责真正把组装好的 tRPC 操作发送给 tRPC 服务端,并把响应包装成OperationResultEnvelope返回给上游。
两个必须记住的硬性约束:
- 客户端配置里的
links数组至少要有一个 link; - 这个 link(或数组中最后一个 link)必须是终止 link。如果数组末尾不是终止 link,tRPC 操作将永远无法送达服务端——请求会在链中被"吞掉"。
内置的终止 link 怎么选
tRPC 官方推荐使用httpBatchLink作为默认终止 link,其余终止 link 包括httpLink与wsLink。它们各自适用不同的场景:
| 终止 link | 请求行为 | 典型场景 |
|---|---|---|
httpBatchLink | 把同一事件循环 tick 内发起的多个操作合并成一个 HTTP 请求(批量) | 默认首选,减少请求数量 |
httpLink | 每次操作单独发一个 HTTP 请求 | 需要单独请求、逐请求取消等场景 |
wsLink | 通过 WebSocket 发送操作 | 需要真正的服务端订阅、推送场景 |
从源码看httpBatchLink的批处理细节
以官方推荐的httpBatchLink为例,其实现位于 httpBatchLink.ts。从中可以看到几个值得了解的内部行为:
- 它内部为
query与mutation各维护了一个 dataLoader 批处理器,把同一个 tick 内到达的多个操作合并为一次请求; - 提供了
maxURLLength与maxItems两个上限选项,默认值均为Infinity(见 httpBatchLink.ts),用于在 URL 过长或批内条目过多时回退到不合并的逐条发送; subscription类型的操作会被显式拒绝并抛出错误,提示改用httpSubscriptionLink或wsLink(见 httpBatchLink.ts)。
也就是说,查询与变更类操作适合批量;而订阅是长连接语义,必须走专门的订阅链路。这也是为什么splitLink、wsLink这类链接链组合在真实项目中非常重要。
管理上下文(Context):跨 link 传递元数据
当一次操作沿链接链流动时,它携带一个上下文对象(context),链上的每个 link 都可以读取和修改它。这样,链路中靠前的 link 可以把元数据放进 context,供后面其他 link 在执行逻辑时使用——例如标记"这个请求不要走批量"。
用法要点:
- 读取/修改当前上下文:通过
op.context拿到当前操作的上下文对象并直接修改,例如op.context.foo = 'bar'; - 设置初始值:在一次具体操作开始时设置 context 的初始值,方法是给
query/useQuery(或mutation、subscription等)的调用传入context参数。
实战案例:对特定请求禁用批处理
官方文档在链接到 splitLink 一节时给出了 context 最经典的用途——按需禁用批量请求。
假设你的客户端一直使用httpBatchLink(批量开启)。若某个请求必须单独发送(例如涉及超大 body、需要独立缓存或希望该请求独立失败重试),可以在配置中用splitLink根据 context 动态切换终止 link:
import { createTRPCProxyClient, httpBatchLink, httpLink, splitLink, } from '@trpc/client'; import type { AppRouter } from '../server'; const url = `http://localhost:3000`; const client = createTRPCProxyClient<AppRouter>({ links: [ splitLink({ condition(op) { // 检查上下文中的 skipBatch 标记 return op.context.skipBatch === true; }, // 条件为真时:走单请求 true: httpLink({ url, }), // 条件为假时:继续批量 false: httpBatchLink({ url, }), }), ], });splitLink本身也是一个 link(实现见 packages/client/src/links/splitLink.ts):它的condition接收op并返回布尔值,true/false两个分支各接收单个 link 或 link 数组,且每个分支都必须以终止 link 收尾。需要注意:当你给splitLink传 link 时,它会基于你传入的链接新建一条完整的链接链,所以只给一个 link 时必须给终止 link,给多个 link 时要把终止 link 放在分支数组末尾。
然后在调用方为单次操作设置context.skipBatch:
const postResult = proxy.posts.query(null, { context: { skipBatch: true, }, });如果你在用 React hooks,则把 context 放进trpc.context(官方文档使用的 v10 写法):
export function MyComponent() { const postsQuery = proxy.posts.useQuery(undefined, { trpc: { context: { skipBatch: true, }, }, }); return ( <pre>{JSON.stringify(postsQuery.data ?? null, null, 4)}</pre> ); }这个例子完整展示了 tRPC context 机制的三个要素:初始值来自调用方 →op.context在链上可见 → 后续 link(这里是splitLink的condition)据此决定行为。它也是链接链上"上游决策、下游执行"协作模式的教科书级用法。
总结
tRPC 的 Links 机制把"客户端如何发送请求、如何处理响应"抽象成一条高度可组合、可观测、可扩展的链接链:
- 每个 link 只做一件事,多个 link 通过
links数组按序组合,请求时正向执行、响应时反向流动; - 一个 link 本质上是 types.ts 中定义的嵌套函数类型——外层工厂(每客户端初始化一次)、中间层中间件(每请求执行)、内层 observable(建立订阅并向上游转发事件);
- 链条必须终止于终止 link(
httpBatchLink为官方首选,其次httpLink、wsLink),否则操作不会真正发出; - 通过
op.context可以在链上读写元数据,配合splitLink即可实现"按请求禁用批量"这类精细控制。
如果想继续深入,推荐在本仓库继续阅读以下关联章节:
- httpBatchLink 详解 与 httpLink 详解(两个最常用终止 link 的完整选项说明)
- wsLink 详解(WebSocket 订阅链路)
- loggerLink 详解(日志链接的全部可选项,含
enabled/console/colorMode等) - splitLink 详解(条件分流)
- httpBatchStreamLink 详解(流式批量)
- 内置链接源码目录 packages/client/src/links/,以及客户端源码入口 packages/client/src/internals/TRPCUntypedClient.ts,可进一步印证链接链的初始化与调用细节
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考