tRPC 非 JSON 内容类型实战:以 minimal-content-types 示例解析 FormData 与二进制文件上传的端到端类型安全实现
2026/9/6 19:48:10 网站建设 项目流程

tRPC 非 JSON 内容类型实战:以 minimal-content-types 示例解析 FormData 与二进制文件上传的端到端类型安全实现

【免费下载链接】trpc🧙‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc

本篇指南以 examples/minimal-content-types 示例工程为主体,讲解 tRPC 如何处理默认 JSON 之外的内容类型——FormDataFile及其他二进制输入。读完后你能掌握:如何在服务端用z.instanceof(FormData)octetInputParser声明非 JSON 输入并在类型系统内消费它们,如何在客户端零额外配置地通过httpLink发送File/FormData,以及如何完整运行、构建和端到端验证这套示例。

示例定位:为什么需要 non-JSON content types

tRPC 默认收发 JSON 可序列化的数据。但文件上传、表单提交等场景天然产生FormDataFileBlob这类无法(或不适合)JSON 序列化的输入。官方文档(非 JSON 内容类型)将其列为 tRPC 服务端的一等能力:服务端会根据请求的Content-Type头自行解析请求体,把二进制内容转成可在 procedure 内消费的ReadableStream

examples/minimal-content-types就是为这一能力准备的最小可运行 React 示例,README 明确给出了两条基线信息:

  • 依赖Node 18(因为使用了全局fetchFormData/File/Blob等 Web API);
  • 客户端是一个 Vite + React 应用,服务端是独立 HTTP 服务,通过类型AppRouter实现端到端类型检查。

项目结构与运行方式

示例采用 npm workspaces 组织两个子包,目录结构如下:

examples/minimal-content-types/ ├── client/ # Vite + React 前端 │ └── src/ │ ├── App.tsx # 客户端入口组件,创建 tRPC client │ ├── SendFileButton.tsx # 发送 File 的按钮 │ ├── SendMultipartFormDataButton.tsx # 发送 FormData 的按钮 │ └── utils/trpc.ts # createTRPCReact<AppRouter>() ├── server/ │ └── index.ts # API handler + standalone HTTP 服务器 ├── test/smoke.test.ts # Playwright 冒烟测试 ├── playwright.config.ts └── package.json

根 package.json 中的脚本把两个子包编排在一起(-w即 workspaces):

{ "scripts": { "build": "run-s build:server build:client", "dev": "run-p dev:*", "start": "run-p start:*", "test:e2e": "playwright test", "test-dev": "start-server-and-test dev 3000 test:e2e" } }

按 README 的指引运行:

# 开发模式(Node 18+) npm i npm run dev # 生产构建 npm run build npm run start

dev会并行启动 Vite 客户端(vite.config.ts 中固定port: 3000)与服务端(监听2022端口,见下文)。README 还提示:可以直接编辑 TS 文件,观察端到端类型检查如何即时生效——这正是本示例的教学价值所在。

服务端实现:两种非 JSON 输入的声明与消费

完整服务端代码见 server/index.ts,核心是一个包含两条 mutation 的 router:

import { initTRPC } from '@trpc/server'; import { createHTTPServer } from '@trpc/server/adapters/standalone'; import { octetInputParser } from '@trpc/server/http'; import cors from 'cors'; import { z } from 'zod'; const t = initTRPC.create(); const publicProcedure = t.procedure; const router = t.router; const appRouter = router({ // 1) FormData 输入:期望输入已整体加载到内存 formData: publicProcedure .input(z.instanceof(FormData)) .mutation(async ({ input }) => { const object = {} as Record<string, unknown>; for (const [key, value] of input.entries()) { if (value instanceof File) { object[key] = { name: value.name, type: value.type, size: value.size, text: await value.text(), }; } else { object[key] = value; } } console.log('FormData: ', object); return { text: 'ACK', data: object }; }), // 2) File / 二进制输入:octetInputParser 产出 ReadableStream file: publicProcedure.input(octetInputParser).mutation(async ({ input }) => { const chunks = []; const reader = input.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } const content = Buffer.concat(chunks).toString('utf-8'); console.log('File: ', content); return { text: 'ACK', data: content }; }), }); // 只导出类型定义给客户端,不暴露实现 export type AppRouter = typeof appRouter; // standalone 适配器直接创建 HTTP 服务器 createHTTPServer({ middleware: cors(), router: appRouter, createContext() { return {}; }, onError(opts) { console.error('Error', opts.error); }, }).listen(2022);

这里有三个关键设计点值得展开:

1. FormData 输入:z.instanceof(FormData)即校验器

z.instanceof(FormData)直接把「输入必须是FormData实例」写进了 zod schema,因此opts.input在 handler 内被推断为FormData类型,可以直接调用entries()逐项处理。示例对每个条目做了instanceof File判别:文件项只读取元信息(name/type/size)并调用value.text()取出内容,非文件项(如nameoccupation)原样存入返回对象。源码注释特别标注了这类输入的语义——"should expect the input to be loaded into memory",即FormData 在 tRPC 解析后是完整驻留内存的,超大表单需要自行评估内存占用。

2. 二进制输入:octetInputParserReadableStream

octetInputParser@trpc/server/http导出。从源码看,packages/server/src/http.ts 仅做一行转发:

export * from './@trpc/server/http';

而 packages/server/src/@trpc/server/http.ts 明确导出了octetInputParser及其配套类型OctetInputFileLikeUtilityParser。tRPC 会把多种 octet 内容类型(BlobUint8ArrayFile等)统一转换成ReadableStream,所以file这条 mutation 里input就是一个标准的 Web 流:通过input.getReader()循环read(),直到done,再用Buffer.concat(chunks)拼装出完整内容。这种流式消费方式意味着服务端不需要一次性把整个文件读进内存,是处理大文件上传的关键前提。

3. standalone 适配器与端口约定

示例没有套 Express/Fastify,而是用 createHTTPServer(@trpc/server/adapters/standalone)直接起一个 Node HTTP 服务并.listen(2022)middleware: cors()处理跨域(因为 Vite 客户端跑在 3000 端口,属于跨源请求)。onError钩子统一打印错误——在更大应用中可以在此接日志/告警。

另请注意 server/package.json 所在工作区的职责划分:server 子包只依赖@trpc/servercorszod,不依赖任何框架;而 client/src/utils/trpc.ts 直接import type { AppRouter } from '../../../server',跨包引用服务端类型——这就是 README 所说"edit the ts files to see the type checking in action"的机制:类型错误会在客户端包中直接报出。

客户端实现:httpLink 原生支持非 JSON 内容类型

客户端入口在 App.tsx:

const [trpcClient] = useState(() => trpc.createClient({ links: [ httpLink({ url: 'http://localhost:2022', }), ], }), ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> <SendMultipartFormDataButton /> <SendFileButton /> </QueryClientProvider> </trpc.Provider> );

要点是:httpLink开箱即支持非 JSON 内容类型。按官方文档的说明,如果你的客户端只用httpLink,现有配置无需任何改动即可发送File/FormData。本示例的 links 配置正是最简形态——只有一个url,没有 transformer、没有 batch 逻辑。

两个按钮组件演示了两种 mutation 调用方式:

  • SendMultipartFormDataButton.tsx:在onClick中构造FormData,塞入两个字符串字段和一个Filenew File(['hi bob'], 'bob.txt', { type: 'text/plain' })),然后mutation.mutate(fd)一把发出;
  • SendFileButton.tsx:<input type="file">选择后,直接mutation.mutate(file)把原始File作为输入。
const mutation = trpc.file.useMutation(); // ... const file = e.target.files.item(0)!; mutation.mutate(file);

链接选型提醒:批处理 link 需要 splitLink

虽然本示例只用了httpLink,但结合官方文档 www/docs/server/non-json-content-types.md 可以得出一个重要的工程约束:并非所有 link 都支持非 JSON 内容类型。如果你同时使用httpBatchLinkhttpBatchStreamLink(批处理 link 会把多个操作合并成一个 JSON 请求,二进制输入无法参与),需要用splitLink+isNonJsonSerializable按内容类型分流:

createTRPCClient<AppRouter>({ links: [ splitLink({ condition: (op) => isNonJsonSerializable(op.input), true: httpLink({ url }), false: httpBatchLink({ url }), }), ], });

另外两条文档给出的配套规则也值得记住:

  1. 若服务端配置了transformer,客户端对应 link 也必须定义transformer(TS 会强制检查);
  2. 服务端框架(如 Express)不要在 tRPC 接管路由之前解析请求体,否则会触发Failed to parse body as XXX错误——示例用 standalone 适配器完全绕开了这个问题,这也是它作为"minimal"示例的又一价值。

端到端验证

示例自带 Playwright 冒烟测试 test/smoke.test.ts:

test('go to /', async ({ page }) => { await page.goto('/'); await page.waitForSelector(`text=tRPC user`); });

配合package.json中的test-dev/test-start脚本(start-server-and-test dev 3000 test:e2e),先等 3000 端口(Vite 客户端)就绪后再跑 E2E,覆盖开发模式与生产构建两条路径。手动验证时,开发模式下访问http://localhost:3000即可看到 "Send FormData" 与 "Send File" 两个控件,点击后服务端控制台会打印FormData: .../File: ...,客户端 mutation 返回{ text: 'ACK', data: ... }

小结

examples/minimal-content-types用不到 200 行代码覆盖了 tRPC 非 JSON 内容类型全链路:

能力服务端写法客户端写法源码依据
FormData 输入.input(z.instanceof(FormData))mutation.mutate(formData)server/index.ts
File / 二进制输入.input(octetInputParser),以ReadableStream流式消费mutation.mutate(file)server/index.ts
类型贯通export type AppRouter = typeof appRoutercreateTRPCReact<AppRouter>()client/src/utils/trpc.ts
跨源请求standalone 适配器 +cors()中间件httpLink指向http://localhost:2022client/src/App.tsx

需要牢记的适用前提:Node 18+(全局 fetch 与 Web 文件 API)、FormData输入整体驻留内存、二进制输入以流式消费、批处理 link 需splitLink分流。以此为骨架,你可以在自己的项目里替换 standalone 适配器(Express/Fastify/Next.js 等)而保持同样的输入声明与客户端调用方式不变。

【免费下载链接】trpc🧙‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询