「从零开始的大文件上传 」都 2022 年了我怎么还在写这个话题...(一)

简介: 「从零开始的大文件上传 」都 2022 年了我怎么还在写这个话题...(一)

背景介绍


大家好,我是寒草🌿,好久不见了,这时大家可能会问:

分明最近就看你疯狂的发《架构整洁之道》的文章,怎么就好久不见呢?


虽然这么说也没错,但是我确实好久没有写一些符合我自己风格或者设计思想的文章了,而本次文章的主题也是与我上一周开发的大文件上传需求有关,内容是这样的:


文件限制大小 2G,100个,批量上传,最大并发量 5 个,并支持上传的取消,以及显示上传的进度。


这个需求看上去没啥,主要是文件体积与数量比较大,但也算是比较基础的需求,而我却想提出更加强大,更加通用的文件上传能力,即:


  • 多数量
  • 大体积
  • 多并发
  • 支持断点续传


通用文件上传能力~

而整个过程肯定也不是一蹴而就,我也是第一次弄,遇到了很多奇奇怪怪的疑问,有的我现在还需要去啃 W3C 的文档,大家也可以参与讨论,也算是我给读者留下的思考题:

  • 本地上传的 File 对象中的 webkitRelativePath 属性为什么没有 path 或者 URL 信息

这个可能要从隐私与安全性以及文件规范入手

  • 文件上传后的 File 对象中有 uid 属性,然而这个 uidMDN 以及 W3CFile 文档中都没有提及,那么这个 uid 是怎么来的,在各浏览器的兼容性如何?

如果您对上面的问题有了解,欢迎指导 ☀️,这里也为大家提供一些参考链接:

关于文件上传我至少会出三篇文章,内容依次为:

  • 实现上面提到的基础需求
  • 断点续传的原理及优化
  • 后续能力的实现


这一篇文章就是关于我如何去实现上文提到的基础需求,而为了后续的能力扩展,我需要保证其可扩展性以及通用性。


如果我的实现哪里有问题,也请大佬不吝赐教,一起交流探讨才能更好的进步~


前期设计


此处设计为大的方向,而代码的设计也应属于前期设计,但是为了文章的可读性,我将其拆解到了下面的编码实现章节。如果哪里有问题请大家和平的探讨,毕竟我也是第一次做~


说来惭愧,我最开始就有完成我全部目标的宏图伟愿,就粗略的画下了这个图:


网络异常,图片无法展示
|


上图就是简单的列了一下我想要去实现的各个能力,以及业务层与逻辑层的通信方式。


Tip: 其实我的描述不够准确,视图指的是上传能力的消费者,属于业务层;而逻辑层指的是文件上传对象的实例。


下面我就从通信方式以及通用能力两个方向来继续谈~

在设计与实现过程中参考:


通信方式


现在我已经实现完成了基础功能,整体的通信方式与上图中的也是大体一致:


  • 消费者 -> 能力提供者:消费者通过构造函数的参数对文件上传能力进行实例化
  • 能力提供者 -> 消费者:文件上传能力的提供者在关键节点调用消费者注入的方法


大家可能就有疑问了,我把什么定义为关键节点呢?


  • 上传进度变化
  • 文件上传成功
  • 文件上传失败
  • 全部文件上传完成

PROGRESSSUCCESSFAILEND 四个状态,那消费者注入的方法也是用来处理这些关键节点产生的信息。


能力拆解


  • 文件分片

且不说文件分片的原理,我们下次再聊,在这里我想要加入的能力是根据文件大小,并发数,文件数量等因素对大文件进行智能分片。

  • hash 计算

根据文件内容生成 hash 值,对于大文件有两种手段:

  • 全量文件内容计算 hash 值,时间过长,可以采用 web-worker 或者 requestIdleCallback 不阻塞UI 线程影响用户操作
  • 对文件内容进行抽样,生成 hash 值,可以大幅提升效率,但是会影响断点续传中文件存在性判断的准确率
  • 上传进度

如果采用了大文件分片,上传的进度需要根据  hash 计算时常,单片上传进度计算单一文件的整体上传进度

  • 并发控制

限制并发量,避免海量请求对接口进行爆破的情况出现,并智能的调度请求

  • 断点续传

支持文件上传的暂停,恢复

  • 肯定还有很多我前期没有想到的...


话不多说,接下来的一章我们将关注代码实现的思考与细节。


编码实现


类的诞生


首先我的想法就是将其与视图分割,使得它的能力可以用在不同“皮囊”之下,如下图:


网络异常,图片无法展示
|


所以,第一步就是新建一个类:


class ZetaUploader {
  constructor() {}
}


之后我们继续想,之前我们前期设计 - 能力拆解章节写了那么多能力,而且在一个通用的模块中进行:


  • 判断消费者文件是否分片
  • 判断消费者是否要开启断点续传
  • ...


支持与不支持文件分片的逻辑是完全不一致的,在一个类里封装如此多判断逻辑和名称一样实现逻辑却不一样的是及其痛苦的,也会极大的增加使用和维护成本,于是我决定对其进行拆解:


网络异常,图片无法展示
|


我们利用龙生九子,各不相同的传统概念,决定以 ZetaUploader 为基类,提供基础能力与内部变量,去产出他各不相同的子类,而这一次,我要与大家一同实现的就是 BasicZetaUploader,提供并发上传大文件的能力:


  • 大文件上传
  • 并发控制
  • 上传状态响应(即之前说的 PROGRESS,SUCCESS,FAIL,END 四个状态的响应)


这时我们就需要思考作为基类,ZetaUploader 要有哪些内容,这些内容一定是各个子类都需要的,这里我简单列举一下我要在基类放置的变量与方法:


变量


  • fileState

存放整个上传过程中上传成功上传失败上传取消的文件数

  • progressEvent

上文提到的,消费者注入的用于在关键节点通知消费者处理上传进度变化的方法

  • concurrency

并发数,即同时上传数

  • xhrMap

正在进行上传的 XMLHttpRequest 实例,可以用来进行 xhr 请求的取消操作「abort」

  • unUploadFileList

未进行上传的文件列表,正在等待上传

  • xhrOptions

发起请求的基础信息,包括:url,method,header,getXhrDataByFile。其中 getXhrDataByFile 为根据 file 生成请求数据的方法


方法


  • isRequestSuccess


用于判断请求是否成功,由于该方法不依托于 ZetaUploader 实例,于是我将其定义为了静态方法


static isRequestSuccess(progressEvent) {
  return String(progressEvent.target.status).startsWith('2');
}


这里为什么参数是 progressEvent 呢,onload 的语法为:


XMLHttpRequest.onload = callback;


其中 callback 是请求成功完成时要执行的函数。它接收一个 ProgressEvent 对象作为它的第一个参数,this 的值(即上下文)与此回调的 XMLHttpRequest 相同。

基类完整代码如下:


class ZetaUploader {
  fileState = {
    failCount: 0,
    successCount: 0,
    abortCount: 0
  }
  /* progressEvent arguments
  * @params {Object} [params]
  * @params {ENUM} [params?.state] FAIL PROGRESS SUCCESS END
  * @params {Info} [params?.info]
  * @params {File} [info?.file]
  * @params {Number} [info?.progress]
  * @params {FileState} [info?.fileState]
  */
  progressEvent = () => { };
  concurrency = 1;
  xhrMap = null;
  unUploadFileList = [];
  /*
  * @params {Object} [xhrOptions]
  * @params {String} [params.url]
  * @params {String} [params.method]
  * @params {Object} [params.headers]
  * @params {Function} [params.getXhrDataByFile]
  */
  xhrOptions = null; 
  static isRequestSuccess(progressEvent) {
    return String(progressEvent.target.status).startsWith('2');
  }
  constructor(progressEvent, fileList, concurrency, xhrOptions) {
    const basicXhrOptions = {
      url: '',
      method: 'post',
      headers: [],
      getXhrDataByFile: file => {
        const formData = new FormData();
        formData.append('file', file);
        return formData;
      }
    };
    // BASIC ATTRS
    this.progressEvent = progressEvent;
    this.concurrency = concurrency;
    this.xhrOptions = Object.assign(basicXhrOptions, xhrOptions);
    // COMPUTED ATTRS
    this.unUploadFileList = fileList;
    this.xhrMap = new Map();
  }
}


工厂方法


这时我们的子类 BasicZetaUploader 就要开始写了,现在他的内容是这样的:


class BasicZetaUploader extends ZetaUploader {
  constructor(progressEvent, fileList, concurrency, xhrOptions) {
    super(progressEvent, fileList, concurrency, xhrOptions);
  }
}


大家先和我进入思考,我们每个文件上传的请求都是独立的,而且要独立的监听其上传进度的变化以及请求结果,所以我们需要一个用来批量的产出方法的方法工厂,参数为 file, xhrOptions, onProgress,即根据:


  • 文件对象
  • 请求的配置信息
  • 进度变化时的处理方法


来制造每个文件的请求方法。因为生成 xhr 请求的工厂依赖于 onProgress,所以我

先来讲一下这部分内容~


onProgress 工厂


onProgress 的用法如下:


XMLHttpRequest.onprogress = function (event) {
  event.loaded;
  event.total;
};


参数为:


  • event.loaded 已传输的数据量
  • event.total 总共的数据量


我写了一个 progressFactory 方法,他也是一个工厂方法,通过 file 来生成 onProgress


progressFactory(file) {
  return e => {
    this.progressEvent('PROGRESS', {
      file,
      progress: parseInt(String((e.loaded / e.total) * 100))
    });
  };
}


request 工厂


有了 onProgress 工厂后我们就可以开写正儿八经的 requestFactory 了,其实也比较简单,就是初始化了一个 XMLHttpRequestPromise ,只不过这里有个巧思:


Promiserejectresolve 通过 xhr 的回调控制


其余就没有什么可以讲解的了,就是一个新建 xhr 并为其添加回调的过程。整体代码如下:


requestFactory(file, xhrOptions, onProgress) {
  const { url, method, headers, getXhrDataByFile } = xhrOptions;
  const xhr = new XMLHttpRequest();
  xhr.open(method, url);
  Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]));
  let _resolve = () => { };
  let _reject = () => { };
  xhr.onprogress = onProgress;
  xhr.onload = e => {
    // 需要加入response的判断
    if (ZetaUploader.isRequestSuccess(e)) {
      this.fileState.successCount++;
      this.progressEvent('SUCCESS', {
        file
      });
      _resolve({
        data: e.target.response
      });
    } else {
      this.fileState.failCount++;
      this.progressEvent('FAIL', {
        file
      });
      _reject();
    }
  };
  xhr.onerror = () => {
    this.fileState.failCount++;
    this.progressEvent('FAIL', {
      file
    });
    _reject();
  };
  xhr.onabort = () => {
    this.fileState.abortCount++;
    _resolve({
      data: null
    });
  };
  const request = new Promise((resolve, reject) => {
    _resolve = resolve;
    _reject = reject;
    xhr.send(getXhrDataByFile(file));
  });
  return {
    xhr,
    request
  };
}


相关文章
|
前端开发
前端学习笔记202305学习笔记第二十四天-文件读取2
前端学习笔记202305学习笔记第二十四天-文件读取2
74 0
|
7月前
奇淫技巧系列第三篇:阅读源码时基于一组快捷键让我们知道身在何方!
奇淫技巧系列第三篇:阅读源码时基于一组快捷键让我们知道身在何方!
|
前端开发
前端学习笔记202305学习笔记第二十四天-文件读取1
前端学习笔记202305学习笔记第二十四天-文件读取1
53 0
|
前端开发
前端学习笔记202305学习笔记第二十四天-文件写入
前端学习笔记202305学习笔记第二十四天-文件写入
74 0
|
uml 开发者 Windows
推荐5款冷门小工具,看一看有没有你喜欢的?
每个人的电脑中都会安装很多软件,可能还保留着很多不为人知的冷门软件。不过虽然冷门,但绝不意味着低能,相反很多冷门软件的功能十分出色。闲话少说,接下来我就给大家推荐5款冷门小工具,看一看有没有你喜欢的。
195 0
推荐5款冷门小工具,看一看有没有你喜欢的?
|
SQL 索引
yyds,SQL基础教程,进阶必须(建议收藏!) 下
yyds,SQL基础教程,进阶必须(建议收藏!) 下
118 0
|
SQL
yyds,SQL基础教程,进阶必须(建议收藏!) 中
yyds,SQL基础教程,进阶必须(建议收藏!) 中
126 0
yyds,SQL基础教程,进阶必须(建议收藏!) 中
|
SQL 存储 Oracle
yyds,SQL基础教程,进阶必须(建议收藏!) 上
yyds,SQL基础教程,进阶必须(建议收藏!) 上
158 0
yyds,SQL基础教程,进阶必须(建议收藏!)   上
|
存储 缓存 算法
写给前端的算法进阶指南,我是如何两个月零基础刷200题
最近国内大厂面试中,出现 LeetCode 真题考察的频率越来越高了。我也观察到有越来越多的前端同学开始关注算法这个话题。
|
运维 监控 Java
【高效编码】JDK自带的命令行工具的使用还用不清楚的地方?快来看看这篇文章吧!!!
您好,我是码农飞哥,感谢您阅读本文!如果此文对您有所帮助,请毫不犹豫的一键三连吧。小伙伴们有啥想看的,想问的,欢迎积极留言告诉我喔。
155 0
【高效编码】JDK自带的命令行工具的使用还用不清楚的地方?快来看看这篇文章吧!!!