大文件上传是需要前端和后端同时参与才可以实现的。
思路
大文件上传整体思路:文件切片 和 断点续传
前端思路
- 利用上传控件
input type="file"
绑定一个change
事件,在回调中通过事件对象的e.target.files
拿到这个文件对象,进行文件对象的slice方法,进行切片,一个大文件就转换成多个小文件了。 - 借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,同时传多个小的文件切片,可以减少上传时间
- 断点续传:前端在上传切片时,给对应的切片添加一个唯一标识符,上传到后端,服务器需要记住已上传的切片,这样下次就可以跳过之前已经上传的部分
后端思路
- 服务端负责接受前端传输的切片,并在接收到所有切片后合并所有切片。
- 服务端保存已上传的切片 hash,上传相同文件要可以进行排除。
切片处理
- 通过上传文件的控件
input type="file"
以及他的change 事件拿到当前上传文件的文件对象e.target.files
- 将文件对象 进行切片处理,利用slice方法。
-
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; }
注意:循环截取.slice方法不是数组的slice方法,而是文件对象原型上的slice方法,继承自 Blob的slice
以小文件的形式 向后端发请求 Promise.all
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">upload</el-button>
</div>
</template>
<script>
import axios from "axios";
const SIZE = 10 * 1024 * 1024;
export default {
name: "qqqUploadIndex11",
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() {
if (!this.container.file) return;
const fileChunkList = this.createFileChunk(this.container.file);
this.data = fileChunkList.map(({ file }, index) => ({
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 }) =>
axios({ url: "http://localhost:3000", data: formData })
);
await Promise.all(requestList);
},
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;
}
}
};
</script>
- 后端接收到所有文件进行合并, 思路有两种
- 等所有小文件上传成功, 前端这边 发一个请求 给后端合并请求
- 切片总数据 前端告诉, 后端保存记录切片总数据 后端自己判断也可以
断点续传
断点续传要实现的就是 上传过的切片 不用重复上传,后端一查这个切片上传过了 就是当上传成功了。
断点续传的原理在于 服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分
- 切片必须带一个可以标识切片的唯一字段,我们之前采用的是
file.name + "-" + index
===> 利用spark-MD5
插件,生成和文件内容县官的 hash 值,只要内容不变,hash 值就不变。 - 后端要记住已经上传过的切片, 当前端上传文件切片时 后端可以根据 hash 过滤
- 全部上传后,后端会进行合并
- 文件重新上传 只要文件内容相同,都不需要重新上传 直接秒传成功。
如何根据文件内容生成hash的过程
根据文件内容生成hash
spark-MD5 插件,可以根据文件内容生成hash值,这个值只和文件内容相关。
- 通过const spark = new SparkMD5.ArrayBuffer()得到一个实例 spark。
- spark实例有方法 append,可以追加一个buffer数据流, 知道读取文件内容结束以后,通过 spark的 end(), 生产对应的hash值。
FileReader
是一个文件解析器 const reader = new FileReader()readAsArrayBuffer()
读取文件的内容,读取文件完成时,会触发onload事件,文件内容在e.target.result
- 由于 读取文件时异步的,要等到整个文件读取完,才可以 生成对应的hash值,这个过程会阻塞主线程,所以你使用 web worker解决
// 导入脚本
// import script for encrypted computing
// self代表子线程自身,即子线程的全局对象。
Worker 内部如果要加载其他脚本,有一个专门的方法importScripts()。
self.importScripts("/spark-md5.min.js");
// 生成文件 hash
// create file hash
使用self.onmessage指定监听函数的参数是一个事件对象,它的data属性包含主线程发来的数据。
self.onmessage = e => {
const { fileChunkList } = e.data;
// 生成 spark 实例, SparkMD5.ArrayBuffer
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0;
const loadNext = index => {
const reader = new FileReader();// FileReader 文件解析器
reader.readAsArrayBuffer(fileChunkList[index].file);
// reader.readAsArrayBuffer 可以读取文件的内容
// 当文件读取完成的时候 会触发 onload 把这个文件的buffer数据流
reader.onload = e => {
count++;
spark.append(e.target.result);
if (count === fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end()// 所有的内容读取完整之后 会返回一个hash
});
self.close();
} else {
percentage += 100 / fileChunkList.length;
// self.postMessage()方法用来向主线程发送消息。
self.postMessage({
percentage
});
loadNext(count);
}
};
};
loadNext(0);
};
webwork
难点
使用spark-MD5为切片文件添加唯一标识时,因为该插件会先读取文件内容,才能生成标识,而读取内容是异步操作,会造成线程阻塞,影响后面UI交互,后面的任务只能等待着
因此在项目中采用了webWorker来解决该问题,首先创建一个子线程与主线程进行交互,将读取文件内容生成hash标识的操作交给woker子线程,最终在将结果返回主线程,主线程就可以正常交互,不会被阻塞或拖慢。
具体实现
项目中封装了一个 calculateHash方法,这个方法内部创建一个 new worker开启一个子线程并传入了这个Worker 子线线程所要执行的任务
这个方法接收一个fileChunkList参数,代表切片的文件对象
通过返回一个promise对象,在promise内部去new Woker("hash.js")
通过worker.postMessage({fileChunkList})将通过文件对象.slice划分的切片文件传递给子线程
子线程内部通过self.onmessage去接收主线程传递的数据,最终这个切片文件对象会出现在self.onmessage回调函数的形参的e.data属性中,其中self代表子线程的全局对象,也就是子线程自身
因为要使用 spark-MD5 插件,根据内容生成hash标识,所以要先在子线程js文件中通过self.importScripts()加载我们需要的 spark-MD5 插件,这样当子线程接收到切片对象之后,就
可以 new self.SparkMD5.ArrayBuffer() 生成spark实例
然后在内部封装了一个loadNext方法,该方法内部会去new FileReader(),创建文件解析器
接着调用reader.readAsArrayBuffer(fileChunkList[index].file) index==>count
reader.readAsArrayBuffer 可以读取文件的内容 ,并转换成buffer数据流,当文件读取完成的时候,会触发 onload事件,刚刚读取文件的buffer数据流就存在e.target.result属性中
然后通过 spark.append 通过spark实例调用append(e.target.result)传入对应的buffer数据流,
接下来判断是否等于要读取文件的长度,如果等于,代表内容读取完毕
就直接通过self.postMessage({hash:spark.end()}) 所有的内容读取完整之后 通过 spark的 end()方法, 生产对应的hash值,并发送给主线程,并调用self.close()关闭worker节省系统资源
如果没有读取完毕,则继续调用loadNext(count)方法,传入count,这个count是自增之后的值,以次类推,直到读取完毕
最后主线程中定义了一个 worker.onmessage监听函数去监听子线程返回的值,也就是hash标识,同样也会出现在e.data属性中,最终通过resolve(hash)即可
hash.js
// 导入脚本
// import script for encrypted computing
self.importScripts("/spark-md5.min.js");
// 生成文件 hash
// create file hash
// new worker
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
});
loadNext(count);
}
};
};
loadNext(0);
};
// 生成文件 hash(web-worker)
// use web-worker to calculate hash
calculateHash(fileChunkList) {
return new Promise(resolve => {
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);
}
};
});
}
Web Worker 使用教程 - 阮一峰的网络日志https://www.ruanyifeng.com/blog/2018/07/web-worker.html
FileReader - Web API 接口参考 | MDNhttps://developer.mozilla.org/zh-CN/docs/Web/API/FileReader