大家好,我是寒草 🌿,一只工作一年半的草系码猿 🐒
如果喜欢我的文章,可以关注 ➕ 点赞,与我一同成长吧~
加我微信:hancao97,邀你进群,一起学习交流,成为更优秀的工程师~
「这是我参与2022首次更文挑战的第18天,活动详情查看:2022首次更文挑战」。
背景介绍
大家好,我是寒草🌿,好久不见了,这时大家可能会问:
分明最近就看你疯狂的发《架构整洁之道》的文章,怎么就好久不见呢?
虽然这么说也没错,但是我确实好久没有写一些符合我自己风格或者设计思想的文章了,而本次文章的主题也是与我上一周开发的大文件上传需求有关,内容是这样的:
文件限制大小 2G,100个,批量上传,最大并发量 5 个,并支持上传的取消,以及显示上传的进度。
这个需求看上去没啥,主要是文件体积与数量比较大,但也算是比较基础的需求,而我却想提出更加强大,更加通用的文件上传能力,即:
- 多数量的
- 大体积的
- 多并发的
- 支持断点续传的
通用文件上传能力~
而整个过程肯定也不是一蹴而就,我也是第一次弄,遇到了很多奇奇怪怪的疑问,有的我现在还需要去啃 W3C 的文档,大家也可以参与讨论,也算是我给读者留下的思考题:
- 本地上传的
File对象中的webkitRelativePath属性为什么没有path或者URL信息
这个可能要从隐私与安全性以及文件规范入手
- 文件上传后的
File对象中有uid属性,然而这个uid在MDN以及W3C的File文档中都没有提及,那么这个uid是怎么来的,在各浏览器的兼容性如何?
如果您对上面的问题有了解,欢迎指导 ☀️,这里也为大家提供一些参考链接:
关于文件上传我至少会出三篇文章,内容依次为:
- 实现上面提到的基础需求
- 断点续传的原理及优化
- 后续能力的实现
这一篇文章就是关于我如何去实现上文提到的基础需求,而为了后续的能力扩展,我需要保证其可扩展性以及通用性。
如果我的实现哪里有问题,也请大佬不吝赐教,一起交流探讨才能更好的进步~
前期设计
此处设计为大的方向,而代码的设计也应属于前期设计,但是为了文章的可读性,我将其拆解到了下面的编码实现章节。如果哪里有问题请大家和平的探讨,毕竟我也是第一次做~
说来惭愧,我最开始就有完成我全部目标的宏图伟愿,就粗略的画下了这个图:
上图就是简单的列了一下我想要去实现的各个能力,以及业务层与逻辑层的通信方式。
Tip: 其实我的描述不够准确,视图指的是上传能力的消费者,属于业务层;而逻辑层指的是文件上传对象的实例。
下面我就从通信方式以及通用能力两个方向来继续谈~
在设计与实现过程中参考:
通信方式
现在我已经实现完成了基础功能,整体的通信方式与上图中的也是大体一致:
- 消费者 -> 能力提供者:消费者通过构造函数的参数对文件上传能力进行实例化
- 能力提供者 -> 消费者:文件上传能力的提供者在关键节点调用消费者注入的方法
大家可能就有疑问了,我把什么定义为关键节点呢?
- 上传进度变化
- 文件上传成功
- 文件上传失败
- 全部文件上传完成
即 PROGRESS,SUCCESS,FAIL,END 四个状态,那消费者注入的方法也是用来处理这些关键节点产生的信息。
能力拆解
-
文件分片
且不说文件分片的原理,我们下次再聊,在这里我想要加入的能力是根据文件大小,并发数,文件数量等因素对大文件进行智能分片。
-
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 了,其实也比较简单,就是初始化了一个 XMLHttpRequest 和 Promise ,只不过这里有个巧思:
Promise的reject和resolve通过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
};
}
并发控制
这里之前的 unUploadFileList 和 xhrMap 就派上了用场:
是否可以发起请求:
还有待上传的文件并且
xhrMap中正在上传的请求数小于最大并发数
unUploadFileList.length > 0 && xhrMap.size < concurrency
是否结束上传:
没有待上传的文件并且
xhrMap中没有正在进行的请求
isUploaded() {
return this.unUploadFileList.length === 0 && this.xhrMap.size === 0;
}
完整代码:
upload() {
const { concurrency, xhrMap, unUploadFileList, xhrOptions, fileState } = this;
return new Promise(resolve => {
const run = async () => {
while (unUploadFileList.length > 0 && xhrMap.size < concurrency) {
const file = unUploadFileList.shift();
const { xhr, request } = this.requestFactory(file, xhrOptions, this.progressFactory(file));
xhrMap.set(file, xhr);
request.finally(()=> {
xhrMap.delete(file);
if (this.isUploaded()) {
resolve();
this.progressEvent('END', {
fileState
});
} else {
run();
}
});
}
};
run();
});
}
取消上传
取消上传分为取消单个文件的上传和取消全部未完全上传的文件,这里用到的就是 abort 这个 api,他的语法为:
xhrInstance.abort();
如果该请求已被发出,XMLHttpRequest.abort() 方法将终止该请求。当一个请求被终止,它的 readyState 将被置为 XMLHttpRequest.UNSENT(0),并且请求的 status 置为 0。
取消上传的逻辑就是如果该请求在 xhrMap 里就 abort 掉,如果在 unUploadFileList 里就直接把它从数组里移除。
abort(file) {
if (this.xhrMap.has(file)) {
this.xhrMap.get(file)?.abort();
} else {
const fileIndex = this.unUploadFileList.indexOf(file);
this.unUploadFileList.splice(fileIndex, 1);
}
}
abortAll() {
this.xhrMap.forEach(xhr => xhr?.abort());
this.unUploadFileList = [];
}
重新上传
当文件上传失败了,我们可以重新上传,这个就很简单了,就是把 file 添加到 unUploadFileList 中,当然,重新上传时有可能已经 upload 结束,此时就重新触发 upload。
redoFile(file) {
this.fileState.failCount--;
this.unUploadFileList.push(file);
if (this.isUploaded()) {
this.upload();
}
}
完整代码
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();
}
}
class BasicZetaUploader extends ZetaUploader {
constructor(progressEvent, fileList, concurrency, xhrOptions) {
super(progressEvent, fileList, concurrency, xhrOptions);
}
progressFactory(file) {
return e => {
this.progressEvent('PROGRESS', {
file,
progress: parseInt(String((e.loaded / e.total) * 100))
});
};
}
/*
* @params {File} [file]
* @params {xhrOptions} [xhrOptions]
* @params {Function} [onProgress]
*/
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
};
}
upload() {
const { concurrency, xhrMap, unUploadFileList, xhrOptions, fileState } = this;
return new Promise(resolve => {
const run = async () => {
while (unUploadFileList.length > 0 && xhrMap.size < concurrency) {
const file = unUploadFileList.shift();
const { xhr, request } = this.requestFactory(file, xhrOptions, this.progressFactory(file));
xhrMap.set(file, xhr);
request.finally(()=> {
xhrMap.delete(file);
if (this.isUploaded()) {
resolve();
this.progressEvent('END', {
fileState
});
} else {
run();
}
});
}
};
run();
});
}
isUploaded() {
return this.unUploadFileList.length === 0 && this.xhrMap.size === 0;
}
abort(file) {
if (this.xhrMap.has(file)) {
this.xhrMap.get(file)?.abort();
} else {
const fileIndex = this.unUploadFileList.indexOf(file);
this.unUploadFileList.splice(fileIndex, 1);
}
}
abortAll() {
this.xhrMap.forEach(xhr => xhr?.abort());
this.unUploadFileList = [];
}
redoFile(file) {
this.fileState.failCount--;
this.unUploadFileList.push(file);
if (this.isUploaded()) {
this.upload();
}
}
}
export {
BasicZetaUploader
};
结束语
本篇文章到这里就结束了,「本系列的」下一篇文章不出意外的话会带着大家了解文件分片与断点续传的原理和实现思路。
最后,下一篇文章见~
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
我生于长空,长于烈日;
我翱翔于风,从未远去。
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
各位的支持「点赞 ➕ 关注」是我源源不断的动力,可以加我微信:hancao97,邀你进群,一起学习交流,成为更优秀的前端工程师~