### 前言

本文将带你基于 ES6 的面向对象，脱离框架使用原生 JS，从设计到代码实现一个 Uploader 基础类，再到实际投入使用。

### 需求描述

相信很多人都用过/写过上传的逻辑，无非就是创建`input[type=file]`标签，监听`onchange`事件，添加到`FormData`发起请求。

但是，想引入开源的工具时觉得**增加了许多体积且定制性不满足**，每次写上传逻辑又会写很多**冗余性代码**。在不同的 toC 业务上，还要重新编写自己的上传组件样式。

此时编写一个 Uploader 基础类，供于业务组件二次封装，就显得很有必要。

下面我们来分析下使用场景与功能：

- **选择文件后可根据配置，自动/手动上传，定制化传参数据，接收返回。**
- **可对选择的文件进行控制，如：文件个数，格式不符，超出大小限制等等。**
- **操作已有文件，如：二次添加、失败重传、删除等等。**
- **提供上传状态反馈，如：上传中的进度、上传成功/失败。**
- **可用于拓展更多功能，如：拖拽上传、图片预览、大文件分片等。**

然后，我们可以根据需求，大概设计出想要的 API 效果，再根据 API 推导出内部实现。

参考：
https://github.com/impeiran/Blog/tree/master/uploader
https://weui.io/#uploader

### 使用

```js
_uploader = new Uploader({
    dir: 'img/mine/', // 图片存储路径
    url: _url, // 上传网址
    el: _.class('uploader'), // 组件容器
    input: _.icon, // 上传成功后的url填入输入框，便于提交
    choose: _.choose, // 点击触发选择文件
    // accept: 'application/pdf', // 选择文件类型
    accept: 'image/jpg,image/jpeg,image/png,image/gif', // 文件类型
    compress: true, // 启动压缩
    quality: 0.8, // 压缩比
    maxSize: 200, // 压缩后最大尺寸单位 KB
    // width: 80,
    // height: 80,
    // resize: 'cover', // 按指定宽高自动裁剪
    aspectRatio: 1, // 宽高比
    multiple: false, // 可否同时选择多个文件
    limit: 1, // 文件数限制 -1 0 不限，1 则限制单个文件，如 头像
    crop: 'img/crop', // 裁剪，需裁剪 page

    // xhr配置
    data: {bucket: 'fin'}, // 腾讯云存储桶
  });
```

#### 状态/事件监听

```javascript
import Uploader from '@wiajs/ui/uploader';

// 链式调用更优雅
uploader
  .on('choose', files => {
    // 用于接受选择的文件，根据业务规则过滤
    let R = files;

    const area = _.area.val();
    const building = _.building.val();
    const room = _.room.val();
    const floor = _.floor.val();
    if ([area, building, room, floor].some(v => !v)) {
      alert('请填完以上信息后再上传合同！');
      R = false;
    } else _uploader.opt.data.pre = `${area}${building}${floor}${room}`;

    return R;
  })
  .on('change', files => {
    // 添加、删除文件时的触发钩子，用于更新视图
    // 发起请求后状态改变也会触发
  })
  .on('progress', e => {
    // 回传上传进度
  })
  .on('success', ret => {
    /*...*/
  })
  .on('error', ret => {
    /*...*/
  });
```

#### 外部调用方法

这里主要暴露一些可能通过交互才触发的功能，如选择文件、手动上传等

- chooseFile()
  选择文件，需在点击交互中触发
- update()
  替换当前文件，裁剪时需要用裁剪后的文件替换当前未裁剪文件
- loadFiles(files);
  独立出添加文件函数，方便拓展
  可传入 slice 大文件后的数组、拖拽添加文件
- removeFile(file)
- remove(id)
- clear()
  清除

#### 加载图片


设置input中，会触发change事件，自动加载 preview，preview根据状态，向容器添加图片显示html代码。
用于数据加载时，加载图片，输入参数格式：

```js
        {
          dir: 'https://img.wia.pub/star/etrip/xhlm',
          file: [
            'c8238fe5ffd169cb83e92eed7a1c2a82.jpg',
            '391c5b4152a51cfba8a3dcad44bce70f.jpg',
          ],
        }
        // 或者
        {
          file: [
            'https://img.wia.pub/star/etrip/xhlm/c8238fe5ffd169cb83e92eed7a1c2a82.jpg',
            'https://img.wia.pub/star/etrip/xhlm/391c5b4152a51cfba8a3dcad44bce70f.jpg',
          ],
        }
        // 或者
          'https://img.wia.pub/star/etrip/xhlm/c8238fe5ffd169cb83e92eed7a1c2a82.jpg',
          'https://img.wia.pub/star/etrip/xhlm/391c5b4152a51cfba8a3dcad44bce70f.jpg'
        // 或者
        'https://img.wia.pub/star/etrip/xhlm/c8238fe5ffd169cb83e92eed7a1c2a82.jpg',

```

// 凡是涉及到动态添加 dom，事件绑定
// 应该提供销毁 API
uploader.destroy();

至此，可以大概设计完我们想要的 uploader 的大致效果，接着根据 API 进行内部实现。

### 内部实现

使用 ES6 的 class 构建 uploader 类，把功能进行内部方法拆分，使用下划线开头标识内部方法。

然后可以给出以下大概的内部接口：

```javascript
class Uploader {
  // 构造器，new的时候，合并默认配置
  constructor(option = {}) {}
  // 根据配置初始化，绑定事件
  _init() {}

  // 绑定钩子与触发
  on(evt) {}
  _callHook(evt) {}

  // 交互方法
  chooseFile() {}
  loadFiles(files) {}
  removeFile(file) {}
  clear() {}

  // 上传处理
  upload(file) {}
  // 核心ajax发起请求
  _post(file) {}
}
```

#### 构造器 - constructor

代码比较简单，这里目标主要是定义默认参数，进行参数合并，然后调用初始化函数

```javascript
class Uploader {
  constructor(option = {}) {
    const defaultOption = {
      url: '',
      // 若无声明wrapper, 默认为body元素
      wrapper: document.body,
      multiple: false,
      limit: -1,
      autoUpload: true,
      accept: '*',

      headers: {},
      data: {},
      withCredentials: false,
    };
    this.setting = Object.assign(defaultOption, option);
    this._init();
  }
}
```

#### 初始化 init

这里初始化做了几件事：维护一个内部文件数组`uploadFiles`，构建`input`标签，绑定`input`标签的事件，挂载 dom。

为什么需要用一个数组去维护文件，因为从需求上看，我们的每个文件需要一个状态去追踪，所以我们选择内部维护一个数组，而不是直接将文件对象交给上层逻辑。

由于逻辑比较混杂，分多了一个函数`_initInputElement`进行初始化`input`的属性。

```javascript
class Uploader {
  // ...

  init () {
    this.uploadFiles = [];
    this.input = this._initInputElement(this.setting);
    // input的onchange事件处理函数
    this.changeHandler = e => {
      // ...
    };
    this.input.addEventListener('change', this.changeHandler);
    this.setting.wrapper.appendChild(this.input);
  }

  initInputElement (setting) {
    const el = document.createElement('input');
    Object.entries({
      type: 'file',
      accept: setting.accept,
      multiple: setting.multiple,
      hidden: true
    }).forEach(([key, value]) => {
      el[key] = value;
    })''
    return el;
  }
}
```

看完上面的实现，有两点需要说明一下：

1. 为了考虑到`destroy()`的实现，我们需要在`this`属性上暂存`input`标签与绑定的事件。后续方便直接取来，解绑事件与去除 dom。
2. 其实把`input`事件函数`changeHandler`单独抽离出去也可以，更方便维护。但是会有 this 指向问题，因为 handler 里我们希望将 this 指向本身实例，若抽离出去就需要使用`bind`绑定一下当前上下文。

上文中的`changeHanler`，来单独分析实现，这里我们要读取文件，响应实例 choose 事件，将文件列表作为参数传递给`loadFiles`。

为了更加贴合业务需求，可以通过事件返回结果来判断是中断，还是进入下一流程。

```javascript
this.changeHandler = e => {
  const files = e.target.files;
  const ret = this._callHook('choose', files);
  if (ret !== false) {
    this.loadFiles(ret || e.target.files);
  }
};
```

通过这样的实现，如果显式返回`false`，我们则不响应下一流程，否则拿返回结果||文件列表。这样我们就将判断**格式不符，超出大小限制**等等这样的逻辑交给上层实现，响应样式控制。如以下例子：

```javascript
uploader.on('choose', files => {
  const overSize = [].some.call(files, item => item.size > 1024 * 1024 * 10);
  if (overSize) {
    setTips('有文件超出大小限制');
    return false;
  }
  return files;
});
```

#### 状态事件绑定与响应

简单实现上文提到的`_callHook`，将事件挂载在实例属性上。因为要涉及到单个 choose 事件结果控制。没有按照标准的发布/订阅模式的事件中心来做，有兴趣的同学可以看看[tiny-emitter](https://github.com/scottcorgan/tiny-emitter)的实现。

```javascript
class Uploader {
  // ...
  on(evt, cb) {
    if (evt && typeof cb === 'function') {
      this['on' + evt] = cb;
    }
    return this;
  }

  _callHook(evt, ...args) {
    if (evt && this['on' + evt]) {
      return this['on' + evt].apply(this, args);
    }
    return;
  }
}
```

#### 装载文件列表 - loadFiles

传进来文件列表参数，判断个数响应事件，其次就是要封装出内部列表的数据格式，方便追踪状态和对应对象，这里我们要用一个外部变量生成 id，再根据`autoUpload`参数选择是否自动上传。

```javascript
let uid = 1;

class Uploader {
  // ...
  loadFiles(files) {
    if (!files) return false;

    if (
      this.limit !== -1 &&
      files.length &&
      files.length + this.uploadFiles.length > this.limit
    ) {
      this._callHook('exceed', files);
      return false;
    }
    // 构建约定的数据格式
    this.uploadFiles = this.uploadFiles.concat(
      [].map.call(files, file => {
        return {
          uid: uid++,
          rawFile: file,
          fileName: file.name,
          size: file.size,
          status: 'ready',
        };
      })
    );

    this._callHook('change', this.uploadFiles);
    this.setting.autoUpload && this.upload();

    return true;
  }
}
```

到这里其实还没完善，因为`loadFiles`可以用于别的场景下添加文件，我们再增加些许类型判断代码。

```diff
class Uploader {
  // ...
  loadFiles (files) {
    if (!files) return false;

+   const type = Object.prototype.toString.call(files)
+   if (type === '[object FileList]') {
+     files = [].slice.call(files)
+   } else if (type === '[object Object]' || type === '[object File]') {
+     files = [files]
+   }

    if (this.limit !== -1 &&
        files.length &&
        files.length + this.uploadFiles.length > this.limit
       ) {
      this._callHook('exceed', files);
      return false;
    }

+    this.uploadFiles = this.uploadFiles.concat(files.map(file => {
+      if (file.uid && file.rawFile) {
+        return file
+      } else {
        return {
          uid: uid++,
          rawFile: file,
          fileName: file.name,
          size: file.size,
          status: 'ready'
        }
      }
    }))

    this._callHook('change', this.uploadFiles);
    this.setting.autoUpload && this.upload()

    return true
  }
}
```

#### 上传文件列表 - upload

这里可根据传进来的参数，判断是上传当前列表，还是单独重传一个，建议是每一个文件单独走一次接口（有助于失败时的文件追踪）。

```javascript
upload (file) {
  if (!this.uploadFiles.length && !file) return;

  if (file) {
    const target = this.uploadFiles.find(
      item => item.uid === file.uid || item.uid === file
    )
    target && target.status !== 'success' && this._post(target)
  } else {
    this.uploadFiles.forEach(file => {
      file.status === 'ready' && this._post(file)
    })
  }
}
```

当中涉及到的`post`函数，我们往下再单独实现。

#### 交互方法

这里都是些供给外部操作的方法，实现比较简单就直接上代码了。

```javascript
class Uploader {
  // ...
  chooseFile() {
    // 每次都需要清空value,否则同一文件不触发change
    this.input.value = '';
    this.input.click();
  }

  removeFile(file) {
    const id = file.id || file;
    const index = this.uploadFiles.findIndex(item => item.id === id);
    if (index > -1) {
      this.uploadFiles.splice(index, 1);
      this._callHook('change', this.uploadFiles);
    }
  }

  clear() {
    this.uploadFiles = [];
    this._callHook('change', this.uploadFiles);
  }

  destroy() {
    this.input.removeEventHandler('change', this.changeHandler);
    this.setting.wrapper.removeChild(this.input);
  }
  // ...
}
```

有一点要注意的是，主动调用`chooseFile`，需要在用户交互之下才会触发选择文件框，就是说要在某个按钮点击事件回调里，进行调用`chooseFile`。否则会出现以下这样的提示：

![](https://user-gold-cdn.xitu.io/2020/3/1/170961f4e3232cce?w=954&h=114&f=png&s=29920)

写到这里，我们可以根据已有代码尝试一下，打印`upload`时的内部`uploadList`，结果正确。

![](https://user-gold-cdn.xitu.io/2020/3/1/170961fbd2df53ee?w=1076&h=452&f=png&s=93871)

#### 发起请求 - post

这个是比较关键的函数，我们用原生`XHR`实现，因为`fetch`并不支持`progress`事件。简单描述下要做的事：

1. 构建`FormData`，将文件与配置中的`data`进行添加。
2. 构建`xhr`，设置配置中的 header、withCredentials，配置相关事件

- onload 事件：处理响应的状态，返回数据并改写文件列表中的状态，响应外部`change`等相关状态事件。
- onerror 事件：处理错误状态，改写文件列表，抛出错误，响应外部`error`事件
- onprogress 事件：根据返回的事件，计算好百分比，响应外部`onprogress`事件

3. 因为 xhr 的返回格式不太友好，我们需要额外编写两个函数处理 http 响应：`parseSuccess`、`parseError`

```javascript
post (file) {
  if (!file.rawFile) return

  const { headers, data, withCredentials } = this.setting
  const xhr = new XMLHttpRequest()
  const formData = new FormData()
  formData.append('file', file.rawFile, file.fileName)

  Object.keys(data).forEach(key => {
    formData.append(key, data[key])
  })
  Object.keys(headers).forEach(key => {
    xhr.setRequestHeader(key, headers[key])
  })

  file.status = 'uploading'

  xhr.withCredentials = !!withCredentials
  xhr.onload = () => {
    /* 处理响应 */
    if (xhr.status < 200 || xhr.status >= 300) {
      file.status = 'error'
      this._callHook('error', parseError(xhr), file, this.uploadFiles)
    } else {
      file.status = 'success'
      this._callHook('success', parseSuccess(xhr), file, this.uploadFiles)
    }
  }

  xhr.onerror = e => {
    /* 处理失败 */
    file.status = 'error'
    this._callHook('error', parseError(xhr), file, this.uploadFiles)
  }

  xhr.upload.onprogress = e => {
    /* 处理上传进度 */
    const { total, loaded } = e
    e.percent = total > 0 ? loaded / total * 100 : 0
    this._callHook('progress', e, file, this.uploadFiles)
  }

  xhr.open('post', this.setting.url, true)
  xhr.send(formData)
}
```

##### parseSuccess

将响应体尝试 JSON 反序列化，失败的话再返回原样文本

```javascript
const parseSuccess = xhr => {
  let response = xhr.responseText;
  if (response) {
    try {
      return JSON.parse(response);
    } catch (error) {}
  }
  return response;
};
```

##### parseError

同样的，JSON 反序列化，此处还要抛出个错误，记录错误信息。

```javascript
const parseError = xhr => {
  let msg = '';
  let {responseText, responseType, status, statusText} = xhr;
  if (!responseText && responseType === 'text') {
    try {
      msg = JSON.parse(responseText);
    } catch (error) {
      msg = responseText;
    }
  } else {
    msg = `${status} ${statusText}`;
  }

  const err = new Error(msg);
  err.status = status;
  return err;
};
```

#### 拓展拖拽上传

拖拽上传注意两个事情就是

1. 监听 drop 事件，获取`e.dataTransfer.files`
2. 监听 dragover 事件，并执行`preventDefault()`，防止浏览器弹窗。

##### 更改客户端代码如下：

![](https://user-gold-cdn.xitu.io/2020/3/1/1709622ef252bcf7?w=974&h=808&f=png&s=98062)

##### 效果图 GIF

![](https://user-gold-cdn.xitu.io/2020/3/1/17096220e064c2ff?w=640&h=451&f=gif&s=2500732)

### 优化与总结

代码当中还存在不少需要的优化项以及争论项，等待各位读者去斟酌改良：

- 文件大小判断是否应该结合到类里面？看需求，因为有时候可能会有根据`.zip`压缩包的文件，可以允许更大的体积。
- 是否应该提供可重写 ajax 函数的配置项？
- 参数是否应该可传入一个函数动态确定？
- ...
