背景
最近有一个场景,在提交表单的时候,需要实现添加附件的功能,
表单内容要先提交到服务端,创建一个 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 upload(@RequestParam String issueId, @RequestParam MultipartFile[] files) {
}
}
服务端接受数据时,使用了 MultipartFile,这是 Spring 框架中常用的写法。
1.
我们先看看 html input[type=file]
组件默认行为,
点击 “选择文件”,浏览器会弹出一个窗口,
选中一个文件,点 “打开”,就会触发 onchange
事件,
在 onchange
事件中,可以通过 e.target.files[0]
拿到刚才上传的那个 File 对象。
2.
再来看一下 upload 组件的默认行为,
点击 “添加”,浏览器也会弹出那个选择文件的窗口,
选中一个文件,点 “打开”,发现上传失败了。
打开控制台,看到 upload 组件向 /
这个地址发送了一个 POST
请求,
数据格式如下,
我们可以向 upload 组件传入 action
参数,修改 POST
请求地址,
但是,选中文件后立即上传不符合我们的场景,我们需要提交表单之后,将多个文件统一上传。
所以我们得自定义 upload 组件的行为。
3. customRequest
upload 组件的有一个 customRequest
属性(#api),
它可以配置自定义的上传行为。
参数 | 说明 | 类型 | 默认值 | 版本 |
---|---|---|---|---|
customRequest | 通过覆盖默认的上传行为,可以自定义自己的上传实现 | function | - |
我们的思路是,先将选中后自动上传的行为取消掉,然后再在提交表单后统一上传。
取消自动上传的实现片段如下,
// 调用 onSuccess,告诉 upload 组件,已上传成功,更新页面
const onAddAttachment = ({ onSuccess }) => onSuccess();
我们只需要在 customRequest
回调中,调用它的 onSuccess
参数即可。
删除也是可以用的,
4. FormData
现在我们添加两个附件,
接着来看前端怎样将这些附件,统一上传给服务端,具体实现如下,
POST /api/issue/attachment
字段 | 类型 | 说明 |
---|---|---|
issueId | String | 关联的 issue id |
files | MultipartFile[] | 文件数组 |
// 表单提交事件
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
组件,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-Credentials
和 Access-Control-Allow-Origin
两个字段。
参考
Spring: Uploading Files
Spring: org.springframework.web.multipart #MultipartFile
ant-design v4.11.1
Ant Design - Upload #API
MDN: CORS