vue大文件上传
大文件上传
问题分析
如果我们将这个文件拆分,将一次性上传大文件拆分成多个上传小文件的请求,因为请求是可以并发的,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始,这样不就可以解决大文件上传的问题了!
大文件上传需要实现下面几个需求:
1.文件切片 2.断点续传 3.支持显示上传进度和暂停上传
一、文件切片
在 JavaScript
中,文件 File
对象是 Blob
对象的子类,Blob
对象包含一个重要的方法 slice
,通过这个方法,我们就可以对二进制文件进行拆分。
createFileChunk(file, size = chunkSize) {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({
file: file.slice(cur, cur + size),
});
cur += size;
}
return fileChunkList;
},
// 两个参数 源文件 切片大小 用slice进行切片
// cur是切片初始化大小0 cur += size 和 file.slice(cur,cur+size)
// 进行切片,最后把所有切片放到一个数组里面
二、计算文件hash值
这里使用 spark-md5.min.js和web-worker在worker线程计算hash值,主线程进行上传切片的操作。
由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5。
(1)web-worker计算hash值
self.importScripts("./spark-md5.min.js");
// 利用importScripts引进spark-md5.min.js
self.onmessage = (e) => {
// 当web-work接受消息时,执行这个函数
const { fileChunkList } = e.data; // 解构
// 创建一个名为spark的新SparkMD5.ArrayBuffer对象。
// ArrayBuffer是JavaScript的一个对象,用于表示一组字节。
const spark = new self.SparkMD5.ArrayBuffer();
let percentage = 0;
let count = 0; // 切片数量
const loadNext = (index) => {
// 创建了一个FileReader对象,读取文件
const reader = new FileReader();
// 将每一个切片文件读成ArrayBuffer对象
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(),
}); // 将进度条和总hash值发送给主线程
self.close(); // 关闭web-worker线程
} else {
// 如果不相等,继续处理下一个模块
percentage += 100 / fileChunkList.length;
self.postMessage({
percentage,
});
loadNext(count);
}
};
};
loadNext(count);
};
(2)requestIdleCallback方式
借鉴react的fiber架构(利用浏览器帧与帧之间的空闲时间)计算hash
但是这块可能会阻塞到其他函数内部回调函数的执行,打乱执行顺序
import sparkMD5 from 'spark-md5'
async calculateHashIdle(){
const chunks = this.chunks
return new Promise(resolve => {
const spark = new sparkMD5.ArrayBuffer()
let count = 0
const appendToSpark = async file=>{
return new Promise(resolve=>{
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = e=>{
spark.append(e.target.result)
resolve()
}
})
}
const workLoop = async deadline=>{
//timeRemaining获取当前帧的剩余时间
while(count<chunks.length && deadline.timeRemaining()>1){
//空闲时间,且有任务
await appendToSpark(chunks[count].file)
count++
if(count<chunks.length){
this.hashProgress = Number(
((100*count)/chunks.length).toFixed(2)
)
}else{
this.hashProgress = 100
resolve(spark.end())
}
}
window.requestIdleCallback(workLoop)
}
window.requestIdleCallback(workLoop)
})
}
(3) 抽样Hash
首尾全要,中间取部分去计算hash。这儿遇到的问题是有可能不同文件hash相同,比如有些图片仅有几像素内容不同
import sparkMD5 from 'spark-md5'
async calculateHashSample(){
return new Promise(resolve=>{
const spark = new sparkMD5.ArrayBuffer()
const reader = new FileReader()
const file = this.file
const size = file.size
const offset = 2*1024*1024
//第一个2M,最后一个区数据块全要
const chunks = [file.slice(0,offset)]
let cur = offset
while(cur<size){
if(cur+offset>=size){
//最后一个区块
chunks.push(file.slice(cur,cur+offset))
}else{
//中间区块,取前中后各2个字节
const mid = cur+offset/2
const end = cur+offset
chunks.push(file.slice(cur, cur+2))
chunks.push(file.slice(mid, mid+2))
chunks.push(file.slice(end-2,end))
}
cur+=offset
}
reader.readAsArrayBuffer(new Blob(chunks))
reader.onload = e=>{
spark.append(e.target.result)
this.hashProgress = 100
resolve(spark.end())
}
})
}
(4)传递接受函数
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.toFixed(0);
if (hash) {
resolve(hash);
}
};
});
},
spark-md5
需要根据所有切片才能算出一个 hash
值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash
三、切片上传
(1)验证文件是否已经存在,存在相当于秒传
/**
* 返回值说明
* shouldUpload:标识这个文件是否还需要上传
* uploadedList: 服务端存在该文件的切片List
*/
const { shouldUpload, uploadedList } = await verifyUpload(
container.file.name,
container.hash
)
如果 shouldUpload
为 false
,则表明这个文件不需要上传,提示:秒传成功。
(2)上传除了 uploadedList
之外的文件切片,断点续传
/**
* 上传切片,同时过滤已上传的切片
* uploadedList:已经上传了的切片,这次不用上传了
*/
async function uploadChunks(uploadedList = []) {
console.log(uploadedList, 'uploadedList')
const requestList = data.value
.filter(({ hash }) => !uploadedList.includes(hash))
.map(({ chunk, hash, index }) => {
const formData = new FormData()
// 切片文件
formData.append('chunk', chunk)
// 切片文件hash
formData.append('hash', hash)
// 大文件的文件名
formData.append('filename', container.file.name)
// 大文件hash
formData.append('fileHash', container.hash)
return { formData, index }
})
.map(async ({ formData, index }) =>
request({
url: 'http://localhost:9999',
data: formData,
onProgress: createProgressHandler(index, data.value[index]),
requestList: requestListArr.value,
})
)
// 并发切片
await Promise.all(requestList)
// 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时
// 切片并发上传完以后,发个请求告诉后端:合并切片
if (uploadedList.length + requestList.length === data.value.length) {
// ok,都上传完了,请求合并文件
mergeRequest()
}
}
四、文件合并(3种方案)
1、前端发送切片完成后,发送一个合并请求,后端收到请求后,将之前上传的切片文件合并。
2、后台记录切片文件上传数据,当后台检测到切片上传完成后,自动完成合并。
3、创建一个和源文件大小相同的文件,根据切片文件的起止位置直接将切片写入对应位置。
五、异步任务并发控制
上面切片上传采用promise.all的方式将所有异步任务同时触发,请求过多可能会造成页面假死、阻塞流程、报错等问题。
这儿限定每次请求只能进行固定次数的异步任务,每请求成功一个则新增一个,确保并发的数量固定。此外,针对可能的报错失败重试三次的操作,若重试三次都报错,则流程终止。
//上传切片
async uploadChunks(){
const requests = this.chunks
.map((chunk,index)=>{
const form = new FormData()
form.append('chunk',chunk.chunk)
form.append('hash',chunk.hash)
form.append('name',chunk.name)
return {form,index:chunk.index,error:0}
})
//发起批量请求
await sendRequest(requests)
//所有切片上传完毕,通知后台进行切片内容合并,生成完整图片
await this.mergeRequest()
}
async sendRequest(chunks){
return new Promise((resolve,reject)=>{
const len = chunks.length
//限制每次最多只能同时发起4次请求
let limit = len > 4 ? 4 : len
let counter = 0
let isStop = false
const start = async () => {
if(isStop) return
const task = chunks.shift()
if(task){
const {form,index} = task
try{
await http.post('/uploadFile',form,{
onUploadProgress:progress=>{
this.chunks[index].progress = (((progress.loaded/progress.total)*100).toFixed(2))
}
})
if(counter==len-1){
resolve()
}else{
counter++
//启动下一个任务
start()
}
}catch(e){
this.chunks[index].progress = -1
if(task.error<3){
task.error++
chunks.unshift(task)
start()
}else{
//错误三次
isStop = true
reject()
}
}
}
}
while(limit>0){
//启动limit个任务
//模拟下延迟任务
setTimeout(()=>{
start()
},Math.random()*2000)
limit-=1
}
})
}
六、FileReader讲解
const reader = new FileReader();
方法 (参数:File或Blob对象)
reader.readAsArrayBuffer(file) 异步按字节读取文件内容,结果用ArrayBuffer对象表示
reader.readAsBinaryString(file) 异步按字节读取文件内容,结果为文件的二进制串
reader.readAsDataURL(file) 异步读取文件内容,结果用data:url(即Base64格式)的字符串形式表示
reader.readAsText(file, encoding) 异步按字符读取文件内容,结果用字符串形式表示
reader.abort() 终止文件读取操作
属性
reader.error 一个DOMException,表示在读取文件时发生的错误
reader.readyState 表示FileReader状态的数字(0: 还没有加载数据、1:正在加载、 2:已完成全部的读取请求)
reader.result 文件的内容。该属性仅在读取操作完成后才有效,数据的格式取决于使用哪个方法来启动读取操作。
事件名称
onabort 当读取操作被中止时调用
onerror 当读取操作发生错误时调用
onload 当读取操作成功完成时调用
onloadend 当读取操作完成时调用,不管是成功还是失败
onloadstart 当读取操作将要开始之前调用
onprogress 在读取数据过程中周期性调用
七、FormData讲解
FormData 接口提供了一种表示表单数据的键值对 key/value 的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send() 方法发送出去,本接口和此方法都相当简单直接。如果送出时的编码类型被设为 “multipart/form-data”,它会使用和表单一样的格式。
FormData的最大优点就是,比起普通的ajax, 使用FormData我们可以异步上传一个二进制文件,而这个二进制文件,就是我们上面讲的Blob对象。
const formData = new FormData()
方法
formData.append() 向 FormData 中添加新的属性值,FormData 对应的属性值存在也不会覆盖原值,而是新增一个值,如果属性不存在则新增一项属性值。
formData.delete() 从 FormData 对象里面删除一个键值对。
formData.entries() 返回一个包含所有键值对的iterator对象。
formData.get() 返回在 FormData 对象中与给定键关联的第一个值。
八、类型转换
1、File文件转换为base64
const fileToBase64 = (file, callback) => {
const reader = new FileReader();
reader.onload = function(e) {
if(typeof callback === 'function'){
callback(e.target.result);
}else{
console.log(e.target.result);
}
}
reader.readAsDataURL(file);
};
2、File转Blob
const fileToBlob = (file, callback) => {
const type = file.type;
const reader = new FileReader();
reader.onload = function(evt) {
const blob = new Blob([evt.target.result], {type});
if(typeof callback === 'function') {
callback(blob)
} else {
console.log("我是 blob:", blob);
}
};
reader.readAsDataURL(file);
};
3、Base64 转 File
const base64ToFile = (base64, fileName) => {
let arr = base64.split(","),
type = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], fileName, { type });
};
4、Base64 转 Blob
const base64ToBlob = (base64) => {
let arr = base64.split(","),
type = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type });
};
5、Blob 转 File
// 文件类型转Blob
const fileToBlob = (file, callback) => {
const type = file.type;
const reader = new FileReader();
reader.onload = function(evt) {
const blob = new Blob([evt.target.result], {type});
if(typeof callback === 'function') {
callback(blob)
} else {
console.log("我是 blob:", blob);
}
};
reader.readAsDataURL(file);
};
// Blob 转 File
const blobToFile = (blob, fileName) => {
const file = new File([blob], fileName, {type: blob.type});
return file;
}
更多推荐
所有评论(0)