[FE] 用 FormData 上传多个文件到 MultipartFile[] 接口

背景

最近有一个场景,在提交表单的时候,需要实现添加附件的功能,
表单内容要先提交到服务端,创建一个 issue,然后再将附件添加到这个 issue 中。

所以,附件在用户添加的时候,是没有立即上传的,
用户可以随意在浏览器端添加和删除,issue 创建后再一起上传。

前端采用的组件库是 antd,用到了 upload 组件。
服务端接口是自定义实现的,也许并不支持 antd upload 上传组件的规范。

POST /api/issue/attachment
字段 类型 说明
issueId String 关联的 issue id
files MultipartFile[] 文件数组
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.validation.Valid;

@RestController
@RequestMapping("/api/issue")
@AllArgsConstructor
public class XXXController {

    @PostMapping("/attachment")
    public XXXResponse<Void> upload(@RequestParam String issueId, @RequestParam MultipartFile[] files) {

    }
}

服务端接受数据时,使用了 MultipartFile,这是 Spring 框架中常用的写法

1. <input type="file" />

我们先看看 html input[type=file] 组件默认行为,

<input type="file" />

<script>
  const $file = document.querySelector("input[type=file]");
  $file.onchange = (e) => {
    const {
      target: {
        files: [file],
      },
    } = e;
    debugger;
  };
</script>

点击 “选择文件”,浏览器会弹出一个窗口,


选中一个文件,点 “打开”,就会触发 onchange 事件,

onchange 事件中,可以通过 e.target.files[0] 拿到刚才上传的那个 File 对象

2. <Upload />

再来看一下 upload 组件的默认行为,

<Form>
  <Form.Item label="附件" name="attachment">
    <Upload>
      <Button>添加</Button>
    </Upload>
  </Form.Item>
</Form>

点击 “添加”,浏览器也会弹出那个选择文件的窗口,


选中一个文件,点 “打开”,发现上传失败了。


打开控制台,看到 upload 组件向 / 这个地址发送了一个 POST 请求,

数据格式如下,


我们可以向 upload 组件传入 action 参数,修改 POST 请求地址,

<Form>
  <Form.Item label="附件" name="attachment">
    <Upload action="/api/issue/attachment">
      <Button>添加</Button>
    </Upload>
  </Form.Item>
</Form>

但是,选中文件后立即上传不符合我们的场景,我们需要提交表单之后,将多个文件统一上传。
所以我们得自定义 upload 组件的行为。

3. customRequest

upload 组件的有一个 customRequest 属性(#api),
它可以配置自定义的上传行为。

参数 说明 类型 默认值 版本
customRequest 通过覆盖默认的上传行为,可以自定义自己的上传实现 function -

我们的思路是,先将选中后自动上传的行为取消掉,然后再在提交表单后统一上传。
取消自动上传的实现片段如下,

// 调用 onSuccess,告诉 upload 组件,已上传成功,更新页面
const onAddAttachment = ({ onSuccess }) => onSuccess();

<Form>
  <Form.Item label="附件" name="attachment">
    <Upload customRequest={onAddAttachment}>
      <Button>添加</Button>
    </Upload>
  </Form.Item>
</Form>

我们只需要在 customRequest 回调中,调用它的 onSuccess 参数即可。

删除也是可以用的,


4. FormData

现在我们添加两个附件,


接着来看前端怎样将这些附件,统一上传给服务端,具体实现如下,

POST /api/issue/attachment
字段 类型 说明
issueId String 关联的 issue id
files MultipartFile[] 文件数组
<Form onFinish={onSubmitForm}>
  <Form.Item label="附件" name="attachment">
    <Upload customRequest={onAddAttachment}>
      <Button>添加</Button>
    </Upload>
  </Form.Item>
  <Form.Item>
    <Button type="primary" htmlType="submit">
      提交
    </Button>
  </Form.Item>
</Form>
// 表单提交事件
const onSubmitForm = formValues => {
  const {
    // 附件没上传:`attachment === undefined`
    // 上传后:`attachment === {file,fileList}` 我们取 fileList 作为当前上传的文件列表
    attachment,
  } = formValues;

  if(attachment == null) {
    // 没有添加附件就不上传
    return;
  }

  const issueAttachment = {
    issueId,

    // 传入 antd 包装过的 input[type=file] 原始文件对象
    files: attachment.fileList.map(({ originFileObj }) => originFileObj),
  };

  // 使用 FormData 上传文件
  const request$ = addAttachmentToIssue(issueAttachment, httpClient);
};
// 向指定 issue 添加附件
const addAttachmentToIssue = (issueAttachment, httpClient) => {
  const { issueId, files } = issueAttachment;

  // MultipartFile[] 接口,需要接收前端 FormData 中的数据
  const formData = new FormData();
  formData.append('issueId', issueId);  // 其他字段
  files.forEach(file => formData.append('files', file));  // 上传多个文件

  // 发送 xhr 或 fetch 请求,这里可忽略这些细节
  const url = `/api/issue/attachment`;
  const request$ = httpClient.post(url, formData);
  return request$;
};

可以看到请求成功了(项目中的 url 跟本例稍有不同,下图只为了示意),


POST 了 3 块数据,一个 issueId,还有两个同名的 files(表示我们上传了 2 个文件)。

还有几个需要注意的点:

  • antd upload 组件包装了原始的 html <input type="file" /> 组件,FormData 需要传入原始的 File 对象,所以要通过 originFileObj 获取一下
  • formData.append('files', ...) 可以多次执行,表示添加了多个名为 files 的字段
  • FormData 是 Web API,直接挂载在了 window 下面(window.FormData),浏览器 支持情况 如下,

5. CORS

上文 httpClient.post 实际调用了 XMLHttpRequest 发送请求,可能会遇到跨域的问题。
所以在调试上传接口的时候,需要检查一下服务端的配置,是否支持跨域请求。

(1)预检请求

CORS 相关的内容大致如下:

浏览器首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨源请求。
服务器确认允许之后,才发起实际的 HTTP 请求。
(某些请求不会触发 CORS 预检请求,这样的请求称为 “简单请求”)

在预检请求阶段,服务端对 OPTIONS 请求的响应头中会包含 Access-Control-Allow-Origin

Access-Control-Allow-Origin: http://foo.example

表明服务端接受该域 http://foo.example 的跨域请求。

注:
这里需要后端实现 OPTIONS 方法,后端框架一般会通过配置方式统一处理(返回 200 或 204,不能是 4xx)。
如果未配置统一处理方式,框架可能会直接返回 404 导致预检请求失败,CORS 请求也会失败。

(2)携带 cookie

使用 XMLHttpRequest 发送请求时,也可以携带 cookie 信息,

const xhr = new XMLHttpRequest();

// 设置请求中携带 cookie 信息
xhr.withCredentials = true;

同时 预检请求中服务端响应头,也要包含 Access-Control-Allow-Credentials,否则就不会发送 cookie

Access-Control-Allow-Credentials: true

对于附带 cookie 的请求,服务器不能设置 Access-Control-Allow-Origin 的值为 “*”,否则请求将会失败。
而将 Access-Control-Allow-Origin 的值设置为具体的地址 http://foo.example,请求才能成功。

(3)回到示例

我们上传功能用到了携带 cookie 的跨域请求,
可以看到服务端响应头中确实包含了,Access-Control-Allow-CredentialsAccess-Control-Allow-Origin 两个字段。


参考

Spring: Uploading Files
Spring: org.springframework.web.multipart #MultipartFile

ant-design v4.11.1
Ant Design - Upload #API

MDN: CORS

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容