Playwright FormData:用 APIRequestContext 构造 multipart/form-data 请求的完整指南
本文基于 Playwright 官方 API 文档 docs/src/api/class-formdata.md,系统讲解 FormData 类的定位、set/append/create 方法语义、文件字段(Path 与 FilePayload)的三种传值方式,并结合 server/formData.ts 与 client/fetch.ts 的源码,深入解析 multipart 请求体在 Playwright 内部的编码流程与参数流转机制。读完后,你可以在 Java、.NET、Python(以及 JavaScript 原生 FormData)环境下正确构造含文件上传的 API 请求,并理解 boundary、Content-Type 推断等底层细节。
什么是 FormData,适用哪些语言
FormData 用于创建通过 APIRequestContext 发送的表单数据。该 API 自 v1.18 引入,API 参考文档面向 Java、C#(.NET)、Python 三种语言(文档元数据标注 langs: java, csharp, python)。在 JavaScript/TypeScript 生态中,Playwright 则直接使用 Node.js 全局的原生 FormData(需要 Node 18+),下文源码分析部分会说明两者的衔接方式。
最典型的用法是构造一个表单对象,然后作为 form 参数传给 page.request().post()(或 .NET 的 Multipart、Python 的 multipart=):
// Java
import com.microsoft.playwright.options.FormData;
FormData form = FormData.create()
.set("firstName", "John")
.set("lastName", "Doe")
.set("age", 30);
page.request().post("http://localhost/submit", RequestOptions.create().setForm(form));
# Python(async / sync 版本调用方式相同,仅 await 差异)
form = FormData()
form.set("firstName", "John")
form.set("lastName", "Doe")
form.set("age", 30)
await page.request.post("http://localhost/submit", form=form)
三种语言的关键差异:
- Java:
FormData是独立的 options 类,通过FormData.create()创建,链式调用返回自身(方法返回类型为<FormData>),最终以RequestOptions.create().setForm(form)传入; - .NET:通过
Context.APIRequest.CreateFormData()(或Page.APIRequest.CreateFormData())获取对象,以new() { Multipart = multipart }传入; - Python:直接
FormData()构造,作为关键字参数form=(仅文本字段)或multipart=(含文件字段)传入。
核心方法:set、append 与 create
| 方法 | 引入版本 | 语言 | 返回 | 语义 |
|---|---|---|---|---|
FormData.set(name, value) |
v1.18 | Java / C# / Python | FormData |
设置字段;若同名 key 已存在,覆盖旧值 |
FormData.append(name, value) |
v1.44 | Java / C# / Python | FormData |
追加字段;若同名 key 已存在,追加到已有值集合末尾,支持多值字段 |
FormData.create() |
v1.18 | 仅 Java | FormData |
工厂方法,创建新的 FormData 实例 |
set 与 append 的本质区别
set 与 append 的区别在于同名 key 的冲突处理:set 会用新值覆盖该 key 下的所有旧值;append 则把新值追加到既有值序列的末尾。因此需要提交同名字段(例如多个同名附件、复选框数组)时必须使用 append。
append 的官方示例(覆盖三种值形态):
FormData form = FormData.create()
// 仅设置 name 和 value(纯文本字段)。
.append("firstName", "John")
// name 和 value 已设置,filename 与 Content-Type 从文件路径推断。
.append("attachment", Paths.get("pic.jpg"))
// name、value、filename 与 Content-Type 全部显式设置。
.append("attachment", new FilePayload("table.csv", "text/csv", Files.readAllBytes(Paths.get("my-tble.csv"))));
page.request().post("http://localhost/submit", RequestOptions.create().setForm(form));
form = FormData()
# 仅设置 name 和 value。
form.append("firstName", "John")
# name 和 value 已设置,filename 与 Content-Type 从文件路径推断。
form.append("attachment", Path("pic.jpg"))
# name、value、filename 与 Content-Type 全部显式设置。
form.append("attachment", {
"name": "table.csv",
"mimeType": "text/csv",
"buffer": Path("my-table.csv").read_bytes(),
})
await page.request.post("http://localhost/submit", multipart=form)
var multipart = Context.APIRequest.CreateFormData();
// 仅设置 name 和 value。
multipart.Append("firstName", "John");
// name、value、filename 与 Content-Type 全部显式设置。
multipart.Append("attachment", new FilePayload()
{
Name = "pic.jpg",
MimeType = "image/jpeg",
Buffer = File.ReadAllBytes("john.jpg")
});
// 同名 attachment 追加第二个文件。
multipart.Append("attachment", new FilePayload()
{
Name = "table.csv",
MimeType = "text/csv",
Buffer = File.ReadAllBytes("my-tble.csv")
});
await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart });
set 的完整示例
set 同样支持 Path 与 FilePayload 两种文件值形态:
FormData form = FormData.create()
// 仅 name 和 value。
.set("firstName", "John")
// filename 与 Content-Type 从文件路径推断。
.set("profilePicture1", Paths.get("john.jpg"))
// 四项全部显式设置。
.set("profilePicture2", new FilePayload("john.jpg", "image/jpeg", Files.readAllBytes(Paths.get("john.jpg"))))
.set("age", 30);
page.request().post("http://localhost/submit", RequestOptions.create().setForm(form));
var multipart = Context.APIRequest.CreateFormData();
multipart.Set("firstName", "John");
multipart.Set("profilePicture", new FilePayload()
{
Name = "john.jpg",
MimeType = "image/jpeg",
Buffer = File.ReadAllBytes("john.jpg")
});
multipart.Set("age", 30);
await Page.APIRequest.PostAsync("https://localhost/submit", new() { Multipart = multipart });
form = FormData()
form.set("firstName", "John")
form.set("profilePicture1", Path("john.jpg"))
form.set("profilePicture2", {
"name": "john.jpg",
"mimeType": "image/jpeg",
"buffer": Path("john.jpg").read_bytes(),
})
form.set("age", 30)
await page.request.post("http://localhost/submit", multipart=form)
参数说明
set 与 append 的签名一致(以 set 为例,append 见 原始文档):
| 参数 | 类型 | 说明 |
|---|---|---|
name |
string |
字段名 |
value |
string | boolean | int | Path | Object(别名 FilePayload) |
字段值。文本/布尔/数字直接作为字段值;Path 时文件名与 MIME 类型自动推断;FilePayload 对象需包含三个属性:name(文件名,string)、mimeType(文件类型,string)、buffer(文件内容,Buffer) |
注意 .NET 的参数签名中 value 不含 Path 形态(官方文档为 .NET 单独标注了参数列表),即 C# 端文件值必须通过显式 FilePayload 对象传入。
源码解析:multipart 请求体如何被编码
服务端:手工拼接 multipart/form-data 字节流
Playwright 并不依赖宿主语言的 multipart 编码库,而是在 server 端自行实现了 MultipartFormData。其核心行为:
- boundary 生成:构造时调用
generateUniqueBoundaryString()(formData.ts#L86-L91),从预定义的 64 个字母数字字符映射表中随机取 16 个字符,拼成----WebKitFormBoundary前缀的 boundary 串(源码注释指明与 WebKit 中的同名实现保持一致); - Content-Type 请求头:
contentTypeHeader()返回multipart/form-data; boundary=<boundary>; - 文本字段(
addField):写入content-disposition: form-data; name="..."头,随后是字段值; - 文件字段(
addFileField):在 content-disposition 头后追加; filename="<name>"与content-type: <mimeType>头。其中 MIME 类型的推断逻辑为:优先使用显式传入的mimeType,否则用mime库按文件名推断,再否则回退到application/octet-stream——这正对应了文档中“filename 和 Content-Type 从文件路径推断”的行为; - finish():补上结尾的
--boundary--并合并所有 Buffer 片段,产出最终请求体。
客户端:form 与 multipart 参数的分流
在 client/fetch.ts 的 _fetch 逻辑中(约 L46-L47 的选项定义与 L198-L245 的分支处理),form 和 multipart 两个参数走不同分支:
form分支:若传入的是原生globalThis.FormData(Node 18+ 可用),逐项entries()取出;此时若遇到非字符串值(即 File),会主动抛出Expected string for options.form["<name>"], found File. Please use options.multipart instead.——从源码结构看,form参数语义上只接受纯文本字段,文件字段必须改走multipart;否则把普通对象转成{name, value}键值数组发给 server;multipart分支:同样支持原生FormData——字符串值直接透传,File值则转换为ServerFilePayload结构(取file.name、file.type作为 MIME、file.arrayBuffer()读取内容作为 buffer);普通对象值则经toFormField()做 Path/FilePayload 到ServerFilePayload的转换;- 最终
formData与multipartData随this._channel.fetch(...)一起经内部协议发送到 server 端执行实际请求。
类型定义层面,types.d.ts 中 APIRequestContext.fetch/post/get/... 等方法的签名均声明为 form?: { [key: string]: string|number|boolean; } | FormData 与 multipart?: FormData | { [key: string]: string|number|boolean|ReadStream|FilePayload; },即 JS 端原生 FormData 与键值对象两种写法等价。
测试用例印证
tests/library/browsercontext-fetch.spec.ts 中存在针对原生 FormData 的测试,并带有版本守护:it.skip(nodeVersion.major < 20, 'File is not available in Node.js < 20. FormData is not available in Node.js < 18')。从测试结构看,JS 端使用全局 FormData 上传文件至少要求 Node 18(FormData 全局可用),而完整行为验证在 Node 20+ 环境执行——这与上文客户端源码中 globalThis.FormData instanceof 的运行时探测逻辑相互印证。
form 与 multipart 参数如何选择
结合文档与源码可以归纳出清晰的选型规则:
- 纯文本/数字/布尔字段(如登录、搜索、JSON 化的表单提交):使用
form参数。Java 端对应RequestOptions.setForm(...),Python 端对应form=,.NET 端对应Form属性; - 含文件上传(图片、CSV、附件):使用
multipart参数(.NET 为Multipart,Python 为multipart=),文件值可用路径(文件名与 MIME 自动推断)或显式 FilePayload(name/mimeType/buffer 全指定); - 同名字段多值:必须使用
append而非set; - JavaScript/TypeScript 用户:不需要
FormData类的语言绑定,直接new globalThis.FormData()(Node 18+),form.append('key', fs.createReadStream('a.txt'))即可,与本文multipart分支的源码处理路径一致。
小结
FormData 是 Playwright API Testing 体系中处理 multipart/form-data 请求的专用容器:v1.18 引入 set/create,v1.44 补充 append 以支持同名多值字段;文件字段支持“路径推断”与“FilePayload 显式指定”两种形态,其 MIME 推断行为(显式 mimeType → mime 库按扩展名推断 → application/octet-stream 回退)可以在 server/formData.ts 中逐行验证。理解了客户端 form/multipart 分流与原生 FormData 适配(client/fetch.ts)之后,你就能在多语言环境下正确构造文件上传请求,并在遇到 “Please use options.multipart instead” 这类报错时快速定位到原因。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00