一、前言
想象两个场景:
场景一: 假设现在有一个需求,需要上传一个超过 10G 的高清视频,或者是一个很大的压缩包。如果直接上传,在上传过程中可能用户会由于网络问题导致中断。用户网络恢复的时候,又要从头开始上传。这会给用户带来很不好的使用体验。
解决方案: 对文件进行分片,每次上传文件的一小部分,当网络中断以后,文件之前上传的部分已经保存在服务器,只需要上传剩余部分即可,从而实现断点续传。
场景二: 现在有100个用户,在不同的时间,需要上传同一个很大的高清视频,视频内容完全一致,视频的名字随机。服务器需要上传处理所有的上传吗?
解决方案: 秒传,第一个用户上传的同时,根据文件内容计算出文件的hash
,把hash
一并发给服务器。以后每次上传,服务器只需要判断是否存在对应的hash
,存在返回上传成功,复用同一个大文件。
什么是文件 hash?
是一个根据文件内容计算一串唯一的字符串的单向算法,也就是说,文件 ===> hash 是可行的,但是 hash ===> 文件 是不可行的。 我们只需要判断hash是否相同,而不必比较文件的每一个字节,就可以知道文件是否改变,或者是否是同一个文件。一般采用一些第三方库来实现。前端可以采用spark-md5
这个库。
二、大文件上传前端需要解决的两个核心问题
这个过程中,前端需要解决的两个核心问题:
1. 文件如何分片?
使用 <input type='file'>
收集到的files是一个文件数组,每一项都是一个文件对象,该文件对象继承于Blob
对象,而Blob
对象的原型上有一个slice
方法。该方法类似数组原型上的 slice
, 可以用于对文件进行分片。
也就是说:
js
<input type='file' name='bigFile'/>
<script>
input.onChange = (e)=>{
const file = e.target.files[0]
console.log(file instanceOf Blob)
console.log(Blob.prototype)
}
</script>
直接开始分片。。。。
js
// 文件分片算法
const splitChunk = (file,perBlobSize)=>{
const result = []
let j = 0;
for (let i = 0; i < file.size; i += perBlobSize) {
result.push(file.slice(i,i+perBlobSize))
j++;
}
return result;
}
没错就是这么简单,文件分片的结果就是一个由Blob
对象构成的数组。
注意: 分片的时候,并非是把文件分割成真正的小文件,仅仅是获得每一个分片文件的基本信息和存储的位置,速度是很快的,并不会对主线程造成很大的负担,因此无需开辟额外的线程
2. 如何唯一标识每一个分片,唯一标识大文件的分片。
可以使用hash算法
, spark-md5
这个库就能实现。
主要有两个问题需要解决:
- 计算hash值的过程是一个CPU 密集型任务 ,我们知道 js, 是一门单线程的语言,在js 执行期间,页面会被阻塞,具体表现为:动画卡顿、用户交互失效(其实在队列中等待执行)。因此需要单独开启一个线程来进行hash值得计算。 可以采用
Web Worker
实现。 - 不能直接计算整个大文件的
hash
, 因为文件太大会导致内存溢出,因此需要依次计算每一个分片。在spark-md5
这个库中也考虑到了这点,提供了对应的api。
不废话,上代码:
子线程 计算hash:
js
// getHashByChunks.js
// 子进程中不能使用import
importScripts('./spark-md5.js')
// 计算 hash 值,可以用
const getHashByChunks = (chunks)=>{
return new Promise(resolve=>{
const spark = new SparkMD5()
function _read(index){ //递归计算hash
if (index >= chunks.length){
resolve(spark.end()) // 获取最终的结果
return // 完成
}
const blob = chunks[index];
const reader = new FileReader();
reader.onload = e=>{
const bytes = e.target.result // 读取到的字节数组
spark.append(bytes)
_read(index+1) // 递归
}
reader.readAsArrayBuffer(blob);
}
_read(0) // 开始计算
})
}
// 和主线程通信,把计算结果返回
onmessage = async (e)=>{
// 接收主线程传过来的分片数组
const {type,chunks} = e.data
if(type === 'getHashByChunks'){
postMessage({
type:'computedHashByChunks',
//使用WebWork的时候Promise 不能被克隆,因此不能直接返回Promise
hashPromise: await getHashByChunks(chunks)
})
}
}
主线程:也就是浏览器的渲染主线程:
js
// 文件分片上传接口
const input = document.querySelector('input[type="file"]')
// 开辟新的线程用于计算 hash, 如果使用相对路径,这个路径是相对于运行时候的主线程的
const worker = new Worker('./getHashByChunks.js')
let fileHash
input.onchange = async (e)=>{
// 获得文件
let file = input.files[0];
// 分片
const chunks = splitChunk(file,200)
// 可以使用 web worker 单独开辟一个线程去计算 cpu 密集型任务
worker.postMessage({ //线程通信
type:'getHashByChunks',
chunks
})
}
// 计算结果在这
worker.onmessage = async e=>{
const {type,hash} = e.data
if(type === 'computedHashByChunks'){
fileHash = hash;
console.log(fileHash);
}
}
// 文件分片算法
const splitChunk = (file,perBlobSize)=>{
const result = []
let j = 0;
for (let i = 0; i < file.size; i += perBlobSize) {
result.push(file.slice(i,i+perBlobSize))
j++;
}
return result;
}
至此,webWork子线程计算hash, 文件分片已经完成!
==== 下次有空接着完善具体的上传 =====
留下几个我目前需要完善的问题:
1、如何设计接口,使用ajax 发起请求?
使用XMLHttpRequest
2、中途某一个分片上传失败(丢包,网络问题等原因)如何解决?
类似tcp 协议,每次上传后端的响应中,携带 expectIndex,代表期待的下一个分片(代表这个分片以前的分片后端都收到了)。
3、如何显示上传进度?
一种方案是根据已经发送的分片的数量和总分片数量,还有一种方案是根据expectIndex 和 总的分片数量。
4、后端怎么知道文件上传完成?
初步想法: 每次请求携带分片的总数量,文件hash,和当前上传的内容,当前上传的分片编号
有空接着写,睡觉~~~~