聊一聊前端上传那些事
Web 上传一直是前端绕不开的话题,同是也是一个难点。关于上传需求多种多样:立即上传,非立即上传、文件预览、大文件上传、断点问题... 虽说大部分遇到的场景直接用 UI 组件都能搞定,但一些特殊需求还是需要手动来写,这里把上传相关总结一下。
前提
为了能够模拟真实上传,遂起一个 server。这里用 Express + formidable来简单写一个上传接口:
app.post('/uploads', (req, res, next) => { const form = new formidable.IncomingForm(); form.uploadDir = './files'; form.keepExtensions = true; form.multiples = true; form.hash = 'md5'; form.parse(req, async (error, fields, files) => { res.status(200).send({ success: true }) }); }); app.listen(3001, function () { console.log('app is listening at port 3001') })
下面统一调用http://localhost:3001/uploads
这个接口,文件会上传到./files这个文件夹里
。
传统 Form 表单上传
谈一谈 multipart/form-data
直接上代码:
<form action="http://localhost:3036/uploads" method="post" enctype="multipart/form-data" target="_blank"> <fieldset> <legend>Upload File:</legend> <label for="upload"><input id="upload" type="file" name="formFile" multiple /></label> <input type="submit" value="Upload"> </fieldset> </form>
需要注意的是,当做表单上传时,要在 form 标签添加enctype="multipart/form-data"
,而普通文本表单上传则不需要刻意定义,因为 form 的enctype
存在一个默认值,即x-www-form-urlencoded
。
谈一谈 boundary
这里探讨一下multipart/form-data
和x-www-form-urlencoded
的区别:
// 普通文本的Content-Type Content-Type: application/x-www-form-urlencoded // 上传文件的Content-Type Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryj38DKRgyVvwG8PYC
可见multipart/form-data
多了一个boundary
.
先思考普通文本表单,无论是 get 请求还是 post,实际都是以username=222&password=222&age=18
这种形式传递给后端的,只不过前者用 req.query 拿到,后者解析后用 req.body 拿到。所以,普通文本表单用于分割每个实体的 "boundary" 就是 & 符。
同理在 multipart 下,也需要边界来分隔每个实体,如下图这种形式:
在点击提交按钮时,实际上只会给后端发送一件“包裹”,但包裹里即有文件
也有文本
,但显然文件
和文本
不可能简单的用&
符分割,所以需要 boundary 来做分隔。
最后来看rfc1341的第一段:
In the case of multiple part messages, in which one or more different sets of data are combined in a single body, a "multipart" Content-Type field must appear in the entity's header. The body must then contain one or more "body parts," each preceded by an encapsulation boundary, and the last one followed by a closing boundary. Each part starts with an encapsulation boundary, and then contains a body part consisting of header area, a blank line, and a body area. Thus a body part is similar to an RFC 822 message in syntax, but different in meaning.
关于实现原理,先种草,今天先搞清boundary
的意义是什么。
谈一谈优缺点
先谈缺点吧,因为不涉及 JS,所以没有 onChange、onProgress 之类的事件监听,也就无法实现一些过程的交互;此外 form 提交会发生页面跳转,现代网页一般很难容忍这种方式。
当然 form 上传仍有有它存在的场景,印象里最近一次用到 form 表单还是在上家公司,为了安全考虑,请求接口会拿到一个后端写好的 form,这个 form 里面除了上传相关的标签,还有一些 input[type="hidden"]的加密标签。思考一下这个场景,form 上传还是有它的用武之地。
FormData
FormData 是 XMLHttpRequest Level 2 新增的一个接口,它用一些键值对来模拟一系列表单控件,FormData 的最大优点就是可以异步上传一个二进制文件。
首先在 body 里创建一个 input 标签:
<input type="file" accept="application/java-archive, image/jpeg" multiple />
然后在 script 写:
const fileTag = document.querySelector('input[type="file"]'); fileTag.addEventListener('change', () => { // 创建一个FormData实例 const formData = new FormData(); // 拿到input标签上传的file const fileList = fileTag.files; if (fileList) { // 将input标签上传的file追加到FormData对象里 Object.values(fileList).map(item => formData.append('file', item)); // 请求上传接口 fetch('http://localhost:3036/uploads', { method: 'POST', body: formData, // headers: { // 'Content-Type': 'multipart/form-data' // } }) .then(res => { if (res.ok) { console.log('success'); return res.json(); } else { console.log('error'); } }) .then(res => { console.log('res is', res); }); } });
⚠️ 注意:当使用 FormData 上传时,fetch 的 header 中就不能再包含 'Content-Type': 'multipart/form-data'
了。这里涉及到了上面所说到的 boundary,FormData 默认已经包含了 multipart/form-data,因此在执行 formData.append()时已经设置好了 boundary,所以当你在 fetch 里重复声明了 'Content-Type': 'multipart/form-data'
,就会造成错误而不能正常上传了。
至于为什么会发生错误,而不是覆盖,再种个草。
同理用原生 xhr,也是不能加'Content-Type': 'multipart/form-data'
的,有兴趣的可以试试。在 jQuery 中,也要手动将 contentType 设为 false.
但是在用 axios 的时候是没问题的,遂翻了一下 axios 的源码,原来它会预检请求体是否为 FormData,如果是,且又在请求头设置了 'Content-Type': 'multipart/form-data'
,就把它删除掉!
FormData 方法一览
apend()
apend()应该是最核心的方法了,用于向 FormData 中追加文件,接受三个参数 name, value, filename(可选),其中 name 是字段名,value 一般来讲就是文件实体了,第三个参数可以为文件设置一个文件名,如果为空则是上传文件的原文件名。
formData.append('file', File, '小黄图') ;
delete()
不多说,用于删除指定 name 的文件,接受一个参数即为 name
formData.delete(name);
keys()、values()、entries()
返回一个 iterator 对象 ,entries()可以遍历访问 FormData 中的键值对,values()可以遍历访问 FormData 中的值,keys()可以遍历访问 FormData 中的键,用途的话我觉得可以做文件上传信息预览的功能。
for (const item of formData.entries()){ console.log(item) }
get()、getAll()
用于返回 FormData 对象中和指定的键关联的第一个值,如果想要返回和指定键关联的全部值,那么可以使用 getAll()方法,接受一个参数 name.
formData.geAll('file') ;
⚠️ 注意:console.log(formData)是打印不出 formData 的信息的,必须使用 get 或 getAll 方法。
has()
用于查询 FormData 对象是否存在某个 name, 接受一个参数 name, 返回 Boolean 值。
set()
它和 append()用法一致,不同的是,set()会覆盖原有的文件某个 name 的全部文件,而 append()不会破坏既有的,只会追加。
FileReader
FileReader 直接用做上传并不常见,它最常见的场景文件预览。直接看代码:
<input type='file' multiple> <ul id='image_list'></ul> <script> const fileTag = document.querySelector('input[type="file"]'); const ulTag = document.querySelector('#image_list'); fileTag.addEventListener('change', function () { const fileList = this.files; Object.values(fileList).map(file => { const reader = new FileReader(); reader.addEventListener('load', function (e) { const li = `<li><img src='${e.target.result}' alt='${file.name}'></li>` ulTag.insertAdjacentHTML('beforeend', li); }); reader.readAsDataURL(file); }) }); </script>
在触发 onChange 事件后,创建一个 FileReader 的实例。当 FileReader 读取文件的方式为 readAsArrayBuffer, readAsBinaryString, readAsDataURL 或者 readAsText 的时候,会触发一个 load 事件,从而可以使用 FileReader.onload 属性对该事件进行处理。
例子中使用readAsDataURL
方法,也就是将上传的图片转变为 base64 格式,然后将 base64 文件指向 img 标签的 src 属性,最后追加到 ul 标签中作为预览。
FileReader 有 5 个方法,分别是
- abort()
- readAsArrayBuffer()
- readAsBinaryString()
- readAsDataURL()
- readAsText()
当传图片时,可以使用 readAsDataURL()转变为 base64 做预览,其他格式考虑其他方法,但个人来讲除了图片预览其他基本没用过。
总结
其实还有一些方式,比如 iframe、flash、websocket 等方式,但用得最多的肯定还是传统表单和 FormData 这两种形式,当然很多 UI 组件已经封装的很好了(不知道 Eelment UI 为啥这么变态...)。
以上、よろしく。
参考
PREVIOUS POST
Git 学习笔记
NEXT POST
React 配置全局 Sass 和 CSS Module