过去一个月开发了公司了图片上传和图片选择两个模块,基本明白了图片上传经历的各个阶段和处理方式,现总结如下。

图片上传

1. 基本结构

  1. 参考

  2. 标签

    1
    <input type="file" accept="image/*">

2. 图片文件

  1. 参考

  2. 属性(只读)

    • File.lasterModified

    • File.lastModifiedDate

    • File.name

    • File.webkitRelativePath

    • File.size

    • File.type

3. 图片处理

  1. 获取图片内容

    1. FileReader

      1. 参考

      2. 用法

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        const getBase64 = (img, cb) => {
        const reader = new FileReader();
        reader.addEventListener('load', () => {
        const size = {};
        const image = new Image();
        image.src = reader.result;
        image.addEventListener('load', () => {
        size.width = image.width;
        size.height = image.height;
        cb(reader.result, size, img.name);
        });
        });
        reader.addEventListener('error', e => {
        console.log(e);
        });
        reader.readAsDataURL(img);
        };
    2. URL.createObjectURL

      1. 参考

      2. 用法

        1
        img.src = window.URL.createObjectURL(file);
  2. 图片压缩

    1. 方法

      • 使用canvas重绘
      • 图片输出时降低图片质量
    2. 参考

      1. 压缩:https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
      2. 输出为base64: https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toDataURL
      3. 输出为Blob: https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/toBlob
    3. 用法

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      const MEASURE_SIZE = 200;
      export function previewImage(file: File | Blob): Promise<string> {
      return new Promise(resolve => {
      if (!isImageFileType(file.type)) {
      resolve('');
      return;
      }

      const canvas = document.createElement('canvas');
      canvas.width = MEASURE_SIZE;
      canvas.height = MEASURE_SIZE;
      canvas.style.cssText = `position: fixed; left: 0; top: 0; width: ${MEASURE_SIZE}px; height: ${MEASURE_SIZE}px; z-index: 9999; display: none;`;
      document.body.appendChild(canvas);
      const ctx = canvas.getContext('2d');
      const img = new Image();
      img.onload = function() {
      const { width, height } = img;

      let drawWidth = MEASURE_SIZE;
      let drawHeight = MEASURE_SIZE;
      let offsetX = 0;
      let offsetY = 0;

      if (width < height) {
      drawHeight = height * (MEASURE_SIZE / width);
      offsetY = -(drawHeight - drawWidth) / 2;
      } else {
      drawWidth = width * (MEASURE_SIZE / height);
      offsetX = -(drawWidth - drawHeight) / 2;
      }

      ctx!.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
      const dataURL = canvas.toDataURL();
      document.body.removeChild(canvas);

      resolve(dataURL);
      };
      img.src = window.URL.createObjectURL(file);
      });
      }
  3. 图片输出

    1. base64:字符串
    2. Blob:文件对象
  4. 图片预览

    1. 方法

      • 将base64编码赋值给img.src
      • 直接画到canvas画布上
    2. 用法

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      componentDidUpdate() {
      const { listType, items, previewFile } = this.props;
      if (listType !== 'picture' && listType !== 'picture-card') {
      return;
      }
      (items || []).forEach(file => {
      const isValidateFile =
      file.originFileObj instanceof File || file.originFileObj instanceof Blob;

      if (
      typeof document === 'undefined' ||
      typeof window === 'undefined' ||
      !(window as any).FileReader ||
      !(window as any).File ||
      !isValidateFile ||
      file.thumbUrl !== undefined
      ) {
      return;
      }
      file.thumbUrl = '';
      if (previewFile) {
      previewFile(file.originFileObj as File).then((previewDataUrl: string) => {
      // Need append '' to avoid dead loop
      file.thumbUrl = previewDataUrl || '';
      this.forceUpdate();
      });
      }
      });
      }

4. 图片上传

  1. 上传策略

    • 并发无控制传输

      • http1.1和http2协议下的并发数量限制

      • 实现

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        uploadFiles = (files) => {
        const postFiles = Array.prototype.slice.call(files);
        postFiles
        .map(file => {
        file.uid = getUid();
        return file;
        })
        .forEach(file => {
        this.upload(file, postFiles);
        });
        };
    • 并发控制

      • 实现

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        class Transmition {
        constructor(num, data, func, handleSuccess, handleErr, handleEnd) {
        this.num = num; // 这个属性用来控制transmitter的数量
        this.data = data; // 这个属性是数组类型,保存任务队列
        this.func = func; // 这个函数用来实际处理异步任务
        this.index = -1; // 这个属性用来存储当前的任务指针
        this.handleSuccess = handleSuccess || function() {}; // 所有任务处理完成的回调函数
        this.len = data.length; // 任务的数量
        this.handleErr = handleErr || function() {}; // 任务失败的处理函数
        this.handleEnd = handleEnd || function() {}; // 所有任务执行完一次后的处理函数
        this.currentTask = []; // 这个属性用来保存当前正在执行的三个任务,随index的变化动态替换
        this.currentIndex = -1; // 这个属性用来用来当停止任务时,记录执行到的文件指针,用于后面恢复,此处没有具体用到,仅留出这个字段
        }

        // 这个函数拆出来是十分有必要的
        // 写成这种形式,就把图片的上传过程单独封装了
        // 上传成功也好,失败也好都封装在自己的过程里
        // 对于整个对列的上传过程而言都是经历了上传并且已经结束
        handleTask = async (data, index) => {
        const { func } = this;
        try {
        const response = await func(data, index);
        this.handleSuccess(response);
        } catch (e) {
        this.handleErr(e, data[index], index);
        }
        };

        // 这里保存当前队列执行的上传任务
        // 并启动任务
        // 在上传队列没有文件的时候
        // 还会判断所有上传过程是否完成,并执行最终的回调函数
        executeTask = async (index, data, i) => {
        this.currentTask[i] = this.handleTask(data, index);
        await this.currentTask[i];
        if (index + 1 === this.len) {
        await Promise.all(this.currentTask);
        this.handleEnd();
        }
        };

        // 定义的每个上传队列执行的规则
        // 有任务时,执行任务并等待任务结束
        // 结束后如果文件队列中还有文件就取文件开始上传
        transmit = async i => {
        const { data } = this;
        let currentData = data[++this.index];
        while (currentData) {
        await this.executeTask(this.index, currentData, i);
        currentData = data[++this.index];
        }
        };

        // 这里同步创建上传队列
        // 并启动上传过程
        createTransmitor = () => {
        const { num } = this;
        for (let i = 0; i < num; i++) {
        this.transmit(i);
        }
        };

        // 这里用来等待已经触发的异步过程
        // 当已经触发的异步过程结束
        // 停止队列操作
        stopTransmite = async () => {
        this.currentIndex = this.index;
        this.index = this.len;
        await Promise.all(this.currentTask);
        };

        // 这个方法实现了暂停后继续操作
        // 目前还没有使用
        continueTransmite = () => {
        this.index = this.currentIndex;
        this.createTransmitor();
        };
        }

        export default Transmition;
    • 两种方式的比较

      • 无控制并发不一定快
      • 并发控制可减小后台压力
    • 无并发

      无并发指的是所有图片使用一个请求进行上传。

      使用这种方式有两个不足,详见参考资料23

      1. 一个链接无法最大限度利用网络性能,传输时长会比较长
      2. 错误无法隔离,一个错误会导致整个重传
  1. 传输的数据格式

    传输介质使用FormData,以base64或者Blob的格式都可以,后者方便后端处理。

Antd的图片上传实现

1. 基本结构

  1. 底层处理逻辑
    • 基本结构
    • 图片文件
    • 图片上传
  2. 上层处理样式、交互及使用场景
    • 图片处理
    • 压缩
    • 列表展示
    • 属性添加
    • 定义传输过程中交互

2. 暴露的接口

​ Antd的Upload组件一共暴露了21个接口:

暴露位置 接口
上层 defaultFileList、fileList、listType、previewFile、onPreview、showUploadList、onChange、onRemove、supportServerRender
底层 directory、disabled、multiple、withCredentials、beforeUpload、customRequest、accept、action、data、headers、name、openFileDialogOnClick

3. 传输策略

  1. 默认并发无控制

  2. 给出了改变策略的接口

4. Antd中一个详细ajax传输的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
function getError(option, xhr) {
const msg = `cannot post ${option.action} ${xhr.status}'`;
const err = new Error(msg);
err.status = xhr.status;
err.method = 'post';
err.url = option.action;
return err;
}

function getBody(xhr) {
const text = xhr.responseText || xhr.response;
if (!text) {
return text;
}

try {
return JSON.parse(text);
} catch (e) {
return text;
}
}

// option {
// onProgress: (event: { percent: number }): void,
// onError: (event: Error, body?: Object): void,
// onSuccess: (body: Object): void,
// data: Object,
// filename: String,
// file: File,
// withCredentials: Boolean,
// action: String,
// headers: Object,
// }
export default function upload(option) {
const xhr = new XMLHttpRequest();

if (option.onProgress && xhr.upload) {
xhr.upload.onprogress = function progress(e) {
if (e.total > 0) {
e.percent = e.loaded / e.total * 100;
}
option.onProgress(e);
};
}

const formData = new FormData();

if (option.data) {
Object.keys(option.data).map(key => {
formData.append(key, option.data[key]);
});
}

formData.append(option.filename, option.file);

xhr.onerror = function error(e) {
option.onError(e);
};

xhr.onload = function onload() {
// allow success when 2xx status
// see https://github.com/react-component/upload/issues/34
if (xhr.status < 200 || xhr.status >= 300) {
return option.onError(getError(option, xhr), getBody(xhr));
}

option.onSuccess(getBody(xhr), xhr);
};


xhr.open('post', option.action, true);

// Has to be after `.open()`. See https://github.com/enyo/dropzone/issues/179
if (option.withCredentials && 'withCredentials' in xhr) {
xhr.withCredentials = true;
}

const headers = option.headers || {};

// when set headers['X-Requested-With'] = null , can close default XHR header
// see https://github.com/react-component/upload/issues/33
if (headers['X-Requested-With'] !== null) {
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
}

for (const h in headers) {
if (headers.hasOwnProperty(h) && headers[h] !== null) {
xhr.setRequestHeader(h, headers[h]);
}
}
xhr.send(formData);

return {
abort() {
xhr.abort();
},
};
}

参考:

  1. https://blog.hhking.cn/2018/11/29/html5-img-upload/
  2. https://medium.com/typecode/a-strategy-for-handling-multiple-file-uploads-using-javascript-eb00a77e15f
  3. https://my.oschina.net/kisshua/blog/701606