一.前期准备
断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能
前端使用 localStorage 记录已上传的切片 hash
服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片
第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选后者做大文件上传和下载,是需要一定的知识储备的。其中综合了不少领域的知识点,很综合的一项技能,可以对技能做一次很好的整合。写一篇这样的文章,也很考验文字水平,希望这一篇文章之后,能够提升自己的理解程度,有一个比较深刻的印象。
说到文件,那肯定少不了前端中的文件(File)、二进制(Blob)、文件读取(FileReader)。大文件上传,一次性上传肯定是不现实的,需要对文件进行分片,然后后端获取后进行整合,那么,Blob.prototype.slice或者File.prototype.slice也是切片时必不可少的。
由于前端会将资源分块,然后单独发送请求,也就是说,原来 1 个文件对应 1 个上传请求,现在可能会变成 1 个文件对应 n 个上传请求(HTTP2的多路复用),所以前端可以基于 Promise.all将这多个接口整合,上传完成在发送一个合并的请求,通知服务端进行合并。合并时可通过 nodejs 中的读写流(readStream/writeStream),将所有切片的流通过管道(pipe)输入
最终文件的流中。
而在发送请求资源时,前端会定好每个文件对应的序号(spark-md5),并将当前分块、序号以及文件 hash 等信息一起发送给服务端(由于计算内容的hash需要时间,还需要考虑 WebWorker),服务端在进行合并时,通过序号进行依次合并即可。
而一旦服务端某个上传请求失败,会返回当前分块失败的信息,其中会包含文件名称、文件 hash、分块大小以及分块序号等,前端拿到这些信息后可以进行重传,同时考虑此时是否需要将 Promise.all 替换为 Promise.allSettled 更方便。
二.相关知识点串讲
1.Blob,File以及FileReader
⑴. Blob:
Blob
对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream
来用于数据操作。
Blob
表示的不一定是 JavaScript
原生格式的数据。File
接口基于 Blob
,继承了 blob
的功能并将其扩展以支持用户系统上的文件。
①要从其它非 blob 对象和数据构造一个 Blob,需使用 Blob() 构造函数:
let blob = new Blob( array, options );
②常用实例属性:
属性/方法名称 | 读写 | 描述 |
---|---|---|
Blob.prototype.size | 只读 | 对象中所包含数据的大小(字节) |
Blob.prototype.type | 只读 | 对象所包含数据的 MIME 类型 |
Blob.prototype.arrayBuffer() | —— | 返回一个 Promise 对象,包含 blob 中的数据,并在 ArrayBuffer 中以二进制数据的形式呈现,出现于FileReader.readAsArrayBuffer()返回的对象。 |
Blob.prototype.slice() | —— | 用于创建一个包含源 Blob的指定字节范围内的数据的新 Blob 对象,通常用于文件的切片。 |
⑵. File:
通常情况下, File
对象是来自用户在一个 <input>
元素上选择文件后返回的 FileList
对象,也可以是来自由拖放操作生成的 DataTransfer
对象,或者来自 HTMLCanvasElement
上的 mozGetAsFile() API
。
File
对象是特殊类型的 Blob
,且可以用在任意的 Blob
类型的 context
中, Blob
的方法都能被File
使用。
①File() 构造器创建新的 File 对象实例。
let myFile = new File(bits, name[, options]);
②常用实例属性:
属性/方法名称 | 读写 | 描述 |
---|---|---|
File.name | 只读 | 返回当前 File 对象所引用文件的名字。 |
File.size | 只读 | 返回文件的大小。 |
File.type | 只读 | 返回文件的 多用途互联网邮件扩展类型(MIME Type) |
File.slice([start[, end[, contentType]]]) | —— | 返回一个新的 Blob 对象,它包含有源 Blob 对象中指定范围内的数据。如File.slice(currentIndex, currentIndex + size) |
⑶. FileReader:
FileReader
对象允许 Web
应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File
或 Blob
对象指定要读取的文件或数据。
其中 File
对象可以是来自用户在一个 <input>
元素上选择文件后返回的FileList
对象,也可以来自拖放操作生成的 DataTransfer
对象,还可以是来自在一个 HTMLCanvasElement
上执行 mozGetAsFile()
方法后返回结果。
①使用 FileReader() 构造器去创建一个新的 FileReader。
let reader = new FileReader();
②常用实例属性:
属性/方法名称 | 读写 | 描述 |
---|---|---|
FileReader.readAsArrayBuffer() | —— | 开始读取指定的 Blob中的内容,一旦完成,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象,用于大型数据,几百MB甚至几GB。 |
FileReader.readAsText() | —— | 开始读取指定的Blob中的内容。一旦完成,result属性中将包含一个字符串以表示所读取的文件内容,可用于小型数据,数百KB,因为大文件时会一下子把目标文件加载至内存,导致内存超出上限。 |
FileReader.abort() | —— | 中止读取操作。在返回时,readyState属性为DONE。 |
⑷.URL:
属性/方法名称 | 读写 | 描述 |
---|---|---|
URL.createObjectURL() | —— | 创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的 URL 对象表示指定的 File 对象或 Blob 对象。用于大文件下载。 |
URL.revokeObjectURL() | —— | 用来释放一个之前已经存在的、通过调用 URL.createObjectURL() 创建的 URL 对象。 |
2.HTTP2的多路复用
在 HTTP/2 中,有两个非常重要的概念,分别是帧(frame)和流(stream)。帧代表着最小的数据单位,每个帧会标识出该帧属于哪个流,流也就是多个帧组成的数据流。HTTP2 采用二进制数据帧传输,取代了 HTTP1.x 的文本格式,二进制格式解析更高效。
多路复用代替了 HTTP1.x 的序列和阻塞机制,所有的相同域名请求都通过同一个 TCP 连接并发完成。同一 Tcp 中可以发送多个请求,对端可以通过帧中的标识知道属于哪个请求。通过这个技术,可以避免 HTTP 旧版本中的队头阻塞问题,极大的提高传输性能。
三.大文件上传
实现大文件上传,需要前后端合作,以下主要讲前端方面需要实现的内容,后端简述思路为主,前端使用vue,后端为node。
前端部分理一下大概思路,首先需要解决的一次性上传大文件导致线程挂掉或者太慢的问题,解决方法就是对文件进行分片处理,并发上传。然后再实现断点续传,文件秒传,暂停上传,恢复上传等功能。期间需要考虑对性能的优化。
1.前端部分
先贴上基础的html代码:
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">upload</el-button>
</div>
</template>
<script>
export default {
data: () => ({
container: {
file: null
}
}),
methods: {
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
Object.assign(this.$data, this.$options.data());
this.container.file = file;
},
async handleUpload() {}
}
};
</script>
⑴如何做分片处理再合并发送?
设定好分片大小,有默认值,也可以参数传入。通过input的change事件获取file文件,再通过file.slice切割成固定大小的分片,通过spark-md5每个分片的内容转换为对应hash值,每次发送分片时携带对应hash值+对应分片下标作为唯一标识,使用promise.all发送ajax请求,成功之后再发送合并切片请求给后台,后台则开始处理合并操作。
①切割成固定大小的分片:
// 生成文件切片
+ createFileChunk(file, size = SIZE) {
+ const fileChunkList = [];
+ let cur = 0;
+ while (cur < file.size) {
+ fileChunkList.push({ file: file.slice(cur, cur + size) });
+ cur += size;
+ }
+ return fileChunkList;
+ }
考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互。
由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5。
// /public/hash.js
//web-worker内部监听消息所执行的函数,通过spark-md5每个分片的内容转换为对应hash值
// 导入脚本
self.importScripts("/spark-md5.min.js");
// 生成文件 hash
self.onmessage = e => {
const { fileChunkList } = e.data;
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0;
const loadNext = index => {
const reader = new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload = e => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end()
});
self.close();
} else {
percentage += 100 / fileChunkList.length;
self.postMessage({
percentage
});
// calculate recursively
loadNext(count);
}
};
};
loadNext(0);
};
②calculateHash函数计算hash值:
+ calculateHash(fileChunkList) {
+ return new Promise(resolve => {
+ // 添加 worker 属性
+ this.container.worker = new Worker("/hash.js");
+ this.container.worker.postMessage({ fileChunkList });
+ this.container.worker.onmessage = e => {
+ const { percentage, hash } = e.data;
+ this.hashPercentage = percentage;
+ if (hash) {
+ resolve(hash);
+ }
+ };
+ });
},
③通过上传文件切片实现文件上传:
//上传文件
async handleUpload() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
+ this.container.hash = await this.calculateHash(fileChunkList);
this.data = fileChunkList.map(({ file },index) => ({
+ fileHash: this.container.hash,
chunk: file,
hash: this.container.file.name + "-" + index
}));
await this.uploadChunks();
},
//上传文件切片
async uploadChunks() {
const requestList = this.data
.map(({ chunk,hash }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
return { formData };
})
.map(({ formData }) =>
this.request({
url: "http://localhost:3000",
data: formData
})
);
await Promise.all(requestList);
+ // 合并切片
+ await this.mergeRequest();//合并切片请求
},
④文件切片上传后发起合并切片请求:
//合并切片请求
+ async mergeRequest() {
+ await this.request({
+ url: "http://localhost:3000/merge",
+ headers: {
+ "content-type": "application/json"
+ },
+ data: JSON.stringify({
size: SIZE,//前端在请求的时候提供之前设定好的 size 给服务端,服务端根据 size 指定可读流的起始位置
+ filename: this.container.file.name
+ })
+ });
+ }
⑵如何实现文件秒传?
断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能
- 前端使用 localStorage 记录已上传的切片 hash
- 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片
第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选后者。
其实根据前边的代码,文件会生成每个切片不同的hash,所以只需要在后端改造一下接口。每次上传文件时,先发送文件名hash,后端检测是否已上传,再让后端保存已经存储在服务器上的切片hash以及对应文件名,和是否已经上传完毕的标记。恢复上传请求发送出去后,后端校验对应文件名是否已完成。
若完成,则返回文件已上传信息,前端接收到信息后显示文件已上传。其实这就是实现文件秒传的逻辑,不是真的上传,而是做个样子让你觉得真的上传了。
若未完成,后端检测未上传完成的切片hash,发送给前端。前端接收到之后,发送对应hash的切片文件给后端,后端重复之前的步骤,最后合并切片完成上传。
⑶如何实现断点续传?
断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传。原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来,我们再改造一下 request 方法。
request({
url,
method = "post",
data,
headers = {},
onProgress = e => e,
+ requestList
}) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = onProgress;
xhr.open(method, url);
Object.keys(headers).forEach(key =>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload = e => {
+ // 将请求成功的 xhr 从列表中删除
+ if (requestList) {
+ const xhrIndex = requestList.findIndex(item => item === xhr);
+ requestList.splice(xhrIndex, 1);
+ }
resolve({
data: e.target.response
});
};
+ // 暴露当前 xhr 给外部
+ requestList?.push(xhr);
});
}
每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存正在上传切片的 xhr。之后新建一个暂停按钮,当点击按钮时,调用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上传的切片。
handlePause() {
this.requestList.forEach(xhr => xhr?.abort());
this.requestList = [];
}
如果要恢复上传,那么就必须要知道是否需要上传和已上传的切片,再计算出未上传的切片进行上传,每上传成功一个切片则标记为已上传,若全部切片都已上传,需要调用mergeRequest方法让后台合并切片成为完整文件,再返回给前端文件已上传成功的提示。
四.大文件下载
可以以 手摸手,带你完成大文件分片下载 为参考设计代码,主要的思路一样是文件分片,切片全部下载完成后,通过模拟点击链接(URL.createObjectURL,URL.revokeObjectURL)完成文件的下载合并。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。