聊一聊前端上传那些事

聊一聊前端上传那些事

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-datax-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 下,也需要边界来分隔每个实体,如下图这种形式:

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' ,就把它删除掉!

axios可以写上'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 为啥这么变态...)。

以上、よろしく。

参考

踩坑篇--使用 fetch 上传文件

File 对象,FileList 对象,FileReader 对象

聊聊 Web 上传

Git 学习笔记

PREVIOUS POST

Git 学习笔记

React 配置全局 Sass 和 CSS Module

NEXT POST

React 配置全局 Sass 和 CSS Module