tRPC 处理 FormData 文件上传:基于 Next.js 的多部分表单端到端类型安全方案 tRPC 处理 FormData 文件上传基于 Next.js 的多部分表单端到端类型安全方案【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc本指南以仓库中的examples/next-formdata示例为核心讲解如何在 Next.js tRPC 应用中收发FormDatamultipart/form-data实现带文本字段与文件上传的端到端类型安全接口。读完本文你将掌握zod-form-data校验方案、splitLink按输入类型分流、multipart/form-data的服务端解析机制以及一个兼顾 JS 与无 JS 提交的完整表单实现。示例概览解决什么问题tRPC 默认以 JSON 序列化请求与响应支持httpBatchLink批量合并请求而FormData与Blob、Uint8Array等二进制类型无法被 JSON 表达。本示例位于 examples/next-formdata演示了三件事客户端把整个form直接构造为FormData并提交给 tRPC mutation服务端用zod-form-data对name文本与image文件做类型校验并把上传的文件流式写入磁盘提供了vanilla原生 HTML 表单与react-hook-form集成 React Hook Form两套页面写法并支持勾选无 JS 提交回到传统 POST。示例目录结构如下examples/next-formdata/ ├── src/ │ ├── pages/ │ │ ├── _app.tsx # 用 trpc.withTRPC 包裹整个应用 │ │ ├── index.tsx # 页面导航 │ │ ├── vanilla.tsx # 原生表单实现 │ │ ├── react-hook-form.tsx # React Hook Form 实现 │ │ └── api/trpc/[trpc].ts # Next.js Pages Router API 处理器 │ ├── server/ │ │ ├── trpc.ts # 初始化 tRPC、上下文与错误格式化 │ │ └── routers/room.ts # sendMessage mutation文件上传 │ └── utils/ │ ├── schemas.ts # zod-form-data 输入模式 │ ├── trpc.ts # 客户端 createTRPCNext 与 link 配置 │ └── writeFileToDisk.ts # 把 File 流式写入 public/uploads ├── package.json └── next.config.ts启动与安装按 examples/next-formdata/README.md 给出的命令即可完成脚手架、安装依赖并启动开发服务器npx create-next-app --example https://github.com/trpc/trpc --example-path examples/next-formdata trpc-formdata cd trpc-formdata npm i npm run dev对应依赖见 examples/next-formdata/package.json其中与本主题直接相关的有trpc/client、trpc/next、trpc/react-query、trpc/server、zod、zod-form-data、react-hook-form与hookform/resolvers。运行环境为 Next.js 15 React 19示例使用 Pages Router 的pages/api/trpc/[trpc].ts作为 API 端点。注意create-next-app会从远端拉取trpc官方仓库中的该示例目录如需本地查看本仓库内的代码直接浏览上述examples/next-formdata目录即可运行脚本见其package.jsondev/build/lint/start。服务端用 zod-form-data 校验多部分表单输入模式zfd.formData服务端最关键的是把输入校验建立在FormData之上。示例在 examples/next-formdata/src/utils/schemas.ts 定义模式import { zfd } from zod-form-data; export const uploadFileSchema zfd.formData({ name: zfd.text(), image: zfd.file(), });zfd.formData(...)声明这段输入是一个FormDatazfd.text()从其中取字符串字段namezfd.file()取文件字段image。这样校验失败如字段缺失、类型不符会在进入业务逻辑前被拦截且客户端与服务端共享同一份模式定义字段名与类型天然一致。Router 与 mutation文件上传被建模为一个 mutation。主路由在 examples/next-formdata/src/pages/api/trpc/[trpc].ts 中把roomRouter挂载为顶层room命名空间而 examples/next-formdata/src/server/routers/room.ts 实现具体过程import { uploadFileSchema } from ~/utils/schemas; import { writeFileToDisk } from ../../utils/writeFileToDisk; import { publicProcedure, router } from ../trpc; export const roomRouter router({ sendMessage: publicProcedure .input(uploadFileSchema) .mutation(async (opts) { return { image: await writeFileToDisk(opts.input.image), }; }), });示例中还保留了一个 viewer.ts 变体它演示了把整段FormData继续包装在普通z.object里{ formData: uploadFileSchema }同样可行不过当前样例的 API 处理器只挂载了room一个命名空间。注意sendMessage是.mutation而非.query。这并非巧合——见后文底层原理服务端对multipart/form-data请求只接受 POST mutation。文件落盘writeFileToDisk.ts 演示如何消费File以时间戳为目录名创建public/uploads/nonce把file.stream()转成 NodeReadable逐 chunk 写入磁盘最终返回可被img引用的 URLimport fs from node:fs; import path from node:path; import { Readable } from node:stream; export async function writeFileToDisk(file: File) { const rootDir __dirname /../../../../..; const nonce Date.now(); const fileDir path.resolve(${rootDir}/public/uploads/${nonce}); if (!fs.existsSync(fileDir)) { fs.mkdirSync(fileDir, { recursive: true }); } console.log(Writing, file.name, to, fileDir); const fd fs.createWriteStream(path.resolve(${fileDir}/${file.name})); const fileStream Readable.fromWeb(file.stream()); for await (const chunk of fileStream) { fd.write(chunk); } fd.end(); return { url: /uploads/${nonce}/${file.name}, name: file.name }; }opts.input.image之所以是标准的 WebFile正是因为服务端解析FormData后直接得到该对象详见下文 multipart 解析。从源码结构看该实现是教学性质的最小落盘方案真实产品中建议更换为对象存储或加文件名清洗但取File.stream()→ 逐块写入是通用模式。API 处理器配置examples/next-formdata/src/pages/api/trpc/[trpc].ts 除用createNextApiHandler绑定appRouter与createContext外还导出了一段对文件上传至关重要的 Next.js 配置export const config { api: { bodyParser: false, // 必须关闭内置 JSON body parser让 tRPC 拿到原始请求体 responseLimit: 100mb, // 放宽响应体积限制适配较大上传 }, };bodyParser: false是接收multipart/form-data的前提——Next.js 默认 bodyParser 只处理 JSON/URL 编码会干扰FormData的原始解析。responseLimit: 100mb则放宽了响应体上限例如 mutation 返回较大对象时。服务端初始化见 examples/next-formdata/src/server/trpc.tscreateContext把原始req暴露给过程errorFormatter在BAD_REQUEST且 cause 为ZodError时把zodErrorcause.flatten()的结构化结果合并进错误data方便前端直接展示字段级错误。客户端按输入类型自动选择传输方式为什么需要 splitLink客户端trpc/client的httpBatchLink默认把多个请求 JSON 化后合并成一次网络往返但FormData无法被 JSON 序列化也不应进入批量通道。解决方案是在 examples/next-formdata/src/utils/trpc.ts 中用splitLink按输入是否可被 JSON 序列化分流import { httpBatchLink, httpLink, isNonJsonSerializable, loggerLink, splitLink, } from trpc/client; import { createTRPCNext } from trpc/next; // ... export const trpc createTRPCNextAppRouter({ overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); // 先执行 useQuery 选项里的 onSuccess await opts.queryClient.invalidateQueries(); // 再失效全部查询 }, }, }, config() { const url getBaseUrl() /api/trpc; return { links: [ loggerLink({ enabled: (op) process.env.NODE_ENV development || (op.direction down op.result instanceof Error), }), splitLink({ condition: (op) isNonJsonSerializable(op.input), true: httpLink({ url }), // FormData/二进制 → 单发、保持原始 body false: httpBatchLink({ url }), // 普通 JSON → 走批量合并 }), ], }; }, ssr: false, });isNonJsonSerializable由trpc/client导出。其实现位于 packages/client/src/links/internals/contentTypes.tsexport function isOctetType(input: unknown): input is Uint8ArrayArrayBuffer | Blob { return ( input instanceof Uint8Array || input instanceof Blob // File extends from Blob仅在 Node 20 可用 ); } export function isFormData(input: unknown) { return input instanceof FormData; } export function isNonJsonSerializable(input: unknown) { return isOctetType(input) || isFormData(input); }它同时覆盖FormData与Uint8Array/Blob两类非 JSON 载荷并经由 packages/client/src/links/types.ts 重新导出。因此当你提交整个FormData时condition命中请求被路由到httpLink——它以原始FormData作为请求体发出浏览器自动带上Content-Type: multipart/form-data; boundary...而不是试图 JSON 编码。useMutation的overrides.onSuccess做了两步收尾先调用调用方在useQuery选项中定义的onSuccess再统一invalidateQueries()让上传成功后列表等查询自动刷新。getBaseUrl()处理了三种环境浏览器内返回相对路径、Vercel 部署返回https://${process.env.VERCEL_URL}、本地默认http://localhost:${process.env.PORT ?? 3000}。ssr: false表示关闭服务端渲染预取因为本示例依赖浏览器端FormData交互。_app.tsx中通过trpc.withTRPC(MyApp)注入 Provider见 examples/next-formdata/src/pages/_app.tsx。把整个form提交为 FormDatatRPC 客户端 util 之外前端两套页面都直接new FormData(formElement)提交。以 vanilla.tsx 为例其核心逻辑是const mutation trpc.room.sendMessage.useMutation({ onSuccess() { alert(success!); }, onError(err) { alert(Error: err.message); }, }); // ... form methodpost action{/api/trpc/${mutation.trpc.path}} encTypemultipart/form-data onSubmit{(e) { const formData new FormData(e.currentTarget); if (formData.get(nojs)) { return; // 勾选了 nojs不阻止默认行为走传统 POST } mutation.mutate(formData); e.preventDefault(); }} pinput namename defaultValuehaz upload //p pinput typefile nameimage //p p input typecheckbox idnojs namenojs value1 / label htmlFornojsDo oldschool POST w/o JS/label /p button typesubmitsubmit/button /form要点有三字段名name/image与服务端uploadFileSchema的键一一对应mutation.trpc.path是 tRPC 自动暴露给客户端的完整路径此处即room.sendMessage把它填进action后勾选无 JS 提交时浏览器会用传统multipart/form-dataPOST 直接打向同一个 tRPC 端点——等于为同一接口免费获得了一个渐进增强降级路径客户端拿到mutation.data.image.url后直接渲染img预览上传结果。mutation.trpc.path类型安全且可被 IDE 自动补全源码注释还提示可以CMD/CTRLClick跳到服务端定义、用 Rename Symbol 同步两端改名。与 React Hook Form 集成react-hook-form.tsx 展示了当表单托管给 React Hook Form 时如何让校验也基于真实FormData。关键是useZodForm包装器它以raw: true选项构造zodResolver(uploadFileSchema)并在自定义 resolver 中把FormProvider里的formRef指向的实际 DOM 表单重建为FormData后再交给 zod 校验const _resolver zodResolver(props.schema, undefined, { raw: true }); const form useFormTInput({ ...props, resolver: (_values, ctx, opts) { if (!form.formRef.current) { return { values: {}, errors: { root: { message: Form not mounted } } }; } const values new FormData(form.formRef.current); return _resolver(values as any, ctx, opts) as any; }, }) as ZodFormDataTInput; form.formRef useRefHTMLFormElement(null);raw: true使 resolver 拿到未经 React Hook Form 预处理的原始值zfd模式才能在FormData对象上正确提取File。提交时页面把event.target表单构造为FormData传给mutation.mutateAsyncvoid form.handleSubmit(async (values, event) { await mutation.mutateAsync(new FormData(event?.target)); })(_event);该页面同样在form上设置了methodpost、action{/api/trpc/${mutation.trpc.path}}与encTypemultipart/form-data因此在noJs勾选时也能降级为传统提交。index.tsxexamples/next-formdata/src/pages/index.tsx在/vanilla与/react-hook-form之间提供导航。底层原理multipart/form-data 如何被解析FormData 之所以开箱即用是因为服务端 HTTP 层把FormData/octet-stream列为与 JSON 并列的一等公民内容类型。处理器注册与选择逻辑位于 packages/server/src/unstable-core-do-not-import/http/contentType.ts其中 formData 处理器实现为const formDataContentTypeHandler: ContentTypeHandler { isMatch(req) { return !!req.headers.get(content-type)?.startsWith(multipart/form-data); }, async parse(opts) { const { req } opts; if (req.method ! POST) { throw new TRPCError({ code: METHOD_NOT_SUPPORTED, message: Only POST requests are supported for multipart/form-data requests, }); } const getInputs memo(async () { const fd await req.formData(); return fd; }); // ... return { calls: [/* batchIndex: 0, getRawInput: getInputs.read, ... */], isBatchCall: false, type: mutation, // ... }; }, };可据此确认几个实现事实通过请求头Content-Type是否以multipart/form-data开头来匹配isMatch请求必须是 POST否则抛出METHOD_NOT_SUPPORTED请求体经标准req.formData()解析后整体作为输入交给getRawInput该输入通道被固定标记为单发isBatchCall: false且类型为type: mutation——这解释了为何示例中文件上传过程必须声明为.mutation也解释了 multipart 请求不参与批量合并与 JSON/GET 通道不同multipart 通道不接受batch参数也不会把last-event-id之类的连接参数塞入输入。同理还有application/octet-stream处理器用于裸二进制 body与 JSON 处理器三者组成handlers数组按顺序匹配找不到匹配且请求为 GET 时回退 JSON否则抛出UNSUPPORTED_MEDIA_TYPE。这正是客户端按isNonJsonSerializable分流、服务端按Content-Type分流两端对称设计的关键证据。相关解析辅助逻辑如输入转对象与测试可在 packages/server/src/unstable-core-do-not-import/http/formDataToObject.ts 及其测试文件 formDataToObject.test.ts 中继续追踪官方对非 JSON 内容类型的更完整讲解见 www/docs/server/non-json-content-types.md。常见问题与排查要点请求被批量化导致上传失败若忘配splitLink而直接使用httpBatchLinkFormData无法被 JSON 编码症状通常是上传路径不被触发或类型错误。确认条件函数使用isNonJsonSerializable(op.input)。服务端拿不到原始 body / 校验对象为空检查 API 路由是否设置api.bodyParser: falseFormData必须交由 tRPC 的内容类型处理器用req.formData()解析。用 query 发文件不工作multipart 与 octet-stream 通道只支持 POST mutationtype: mutation、METHOD_NOT_SUPPORTED检查均在 contentType.ts 中硬编码请把过程声明为.mutation并走 POST。字段级错误展示服务端errorFormatter已把ZodError的flatten()结构放进err.data.zodError前端可在onError中据此高亮对应字段。无 JS 降级为form同时设置methodpost、action{/api/trpc/${mutation.trpc.path}}、encTypemultipart/form-data并在 JS 分支中preventDefault即可让同一端点既服务 tRPC 客户端、又服务原生浏览器提交。【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考