# 聊一聊前端上传那些事

> Web 上传一直是前端绕不开的话题，同是也是一个难点。关于上传需求多种多样：立即上传，非立即上传、文件预览、大文件上传、断点问题... 虽说大部分遇到的场景直接用 UI 组件都能搞定，但一些特殊需求还是需要手动来写，这里把上传相关总结一下。

## 前提

[文章源码戳这里](https://github.com/YanceyOfficial/Front-End-Upload)

为了能够模拟真实上传，遂起一个 server。这里用 Express + [formidable](https://github.com/felixge/node-formidable)来简单写一个上传接口:

```js
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

直接上代码：

```html
<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`的区别：

```js
// 普通文本的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](//static.yancey.app/Jietu20190111-171803%402x.jpg)

在点击提交按钮时，实际上只会给后端发送一件“包裹”，但包裹里即有`文件`也有`文本`，但显然`文件`和`文本`不可能简单的用`&`符分割，所以需要 boundary 来做分隔。

最后来看[rfc1341](https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html)的第一段：

>  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 标签：

```html
<input type="file" accept="application/java-archive, image/jpeg" multiple />
```

然后在 script 写：

```js
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 的源码](https://github.com/axios/axios/blob/master/dist/axios.js)，原来它会预检请求体是否为 FormData，如果是，且又在请求头设置了 `'Content-Type': 'multipart/form-data'` ，就把它删除掉！

![axios可以写上'Content-Type': 'multipart/form-data'](//static.yancey.app/Jietu20190112-213556%402x.jpg)

### FormData 方法一览

#### apend()

apend()应该是最核心的方法了，用于向 FormData 中追加文件，接受三个参数 name, value, filename(可选)，其中 name 是字段名，value 一般来讲就是文件实体了，第三个参数可以为文件设置一个文件名，如果为空则是上传文件的原文件名。

```js
formData.append('file', File, '小黄图') ;
```

#### delete()

不多说，用于删除指定 name 的文件，接受一个参数即为 name

```js
formData.delete(name);
```

#### keys()、values()、entries()

返回一个 iterator 对象 ，entries()可以遍历访问 FormData 中的键值对，values()可以遍历访问 FormData 中的值，keys()可以遍历访问 FormData 中的键，用途的话我觉得可以做文件上传信息预览的功能。

```js
for (const item of formData.entries()){
  console.log(item)
}
```

#### get()、getAll()

用于返回 FormData 对象中和指定的键关联的第一个值，如果想要返回和指定键关联的全部值，那么可以使用 getAll()方法，接受一个参数 name.

```js
formData.geAll('file') ;
```

⚠️ 注意：console.log(formData)是打印不出 formData 的信息的，必须使用 get 或 getAll 方法。

#### has()

用于查询 FormData 对象是否存在某个 name, 接受一个参数 name, 返回 Boolean 值。

#### set()

它和 append()用法一致，不同的是，set()会覆盖原有的文件某个 name 的全部文件，而 append()不会破坏既有的，只会追加。

## FileReader

FileReader 直接用做上传并不常见，它最常见的场景文件预览。直接看代码：

```html
<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 上传文件](https://zhuanlan.zhihu.com/p/34291688)

[File 对象，FileList 对象，FileReader 对象](https://wangdoc.com/javascript/bom/file.html#filelist-%E5%AF%B9%E8%B1%A1)

[聊聊 Web 上传](http://www.devsai.com/2016/11/08/talk-web-upload/#H5%E2%80%94xhr2%EF%BC%88XmlHttpRequest-level-2%EF%BC%89%E4%B8%8A%E4%BC%A0)


---

Source: https://yanceyleo.com/post/6889a1e3-39d8-4c64-aa74-444d4633c766
Published: 2019-01-28
Tags: 上传