业务场景
当我们上传一个文件,当文件比较大的时候,需要上传的时间比较长,如果出现网络中断的场景,那么就需要重新的上传整个文件,这对于用户来说,是非常不好的体验,也非常浪费资源。 所以在大文件上传时,往往对 大文件 进行切片操作,前端负责将文件进行切片,然后单文件上传给后端,等后端拿到所有的切片,进行一个合并。
- 用户选择文件后点击上传按钮。
- 前端计算文件的哈希值。
- 检查文件是否已存在(秒传检查)。
- 如果文件不存在,将文件切片。
- 获取已上传的切片,生成待上传的切片队列。
- 控制并发数量上传切片。
- 所有切片上传完成后,合并切片。
整体流程
切片上传是如何实现的:
1.选择文件:用户在前端页面选择要上传的文件,将选择的文件进行保存
2.利用sparkmd5计算文件的哈希值:用于后续的秒传判断。
3.利用文件的slice方法,把一个大文件切片成很多个小文件push到一个数组里
4.利用form data,循环把每个切片都传递给后端(文件切片,文件名,索引值,总的切片数量)
5.判断已经上传的切片数量和总的切片数量是否相等,如相等,向后端发送合并文件的请求;
利用索引值来确定切片的顺序,又利用哈希值来验证文件唯一性,以提高文件上传系统的可靠性和安全性。
前端最核心的操作就是:如何对文件进行切片?
1.选择文件:用户在前端页面选择要上传的文件,将选择的文件进行保存。拿到的是一个文件对象。切片是一个blob 对象
利用文件的slice方法,把一个大文件切片成很多个小文件push到一个数组里
切片方法:传入的是文件 和每一个切片的大小。创建一个数组,用来保存每个切片。
秒传场景:刷新一下页面,问服务器,当前文件上传没有,哪些切片还没有上传
文件hash值的计算 异步封装
重点在于:怎么知道是这个文件,用一个东西去描述这个文件,
不能用文件名:文件名容易重复,路径会移动
找到唯一能代表这个文件的东西:文件hash,hash算法:将任何数据转化为一个固定长度的字符串,也是不可逆的。而且,里面任意数据的修改,都会导致产生不同的哈希值。
Md5算法 :如何在客户端去计算这个哈希值,使用的是第三方库 spark-md5
重点:不能一次性计算整个文件的哈希值,因为计算文件的哈希值,要拿到文件的整个数据,一个高清电影可能就100G了,内存拿到数据再去计算,吃不消。
要分块去算,增量算法,先拿一块数据计算,先计算这块,这块计算完了就不要了,下一块来了,和之前的结果计算一个新的结果。这块数据也不要了。
注意点:将文件hash值的计算,封装成异步的,需要花费一点时间去计算哈希值
尽管这样,还是可能卡顿:优化方案,使用webworker 去单独开一个线程
断点续传的实现
若不能秒传,向后端发送包含文件哈希值的请求,获取已上传的切片索引数组。前端根据这个数组确定哪些切片还需要上传。遍历切片的数组,如果当前切片的索引包含在已上传的数组中,就会跳过这个切片,进行下一个切片的判断。通过索引记录已上传的切片,重新上传时跳过已上传的部分来实现断点续传功能
秒传如何实现
上传前先发送文件 hash,检查是否存在相同哈希值且已完成上传的文件,服务端存在相同文件则直接返回,使用 spark-md5 计算文件 hash。
并发上传是通过以下方式实现的
并发上传指同时上传文件的多个切片以提升效率,实现方式多样:
-
队列方式 : - 原理 :利用队列存储待上传切片,结合计数器和并发限制值控制并发数量。上传任务完成后,从队列取新切片上传。 - 优点:逻辑清晰,能灵活控制并发数量。
-
Promise.all
或Promise.allSettled
: - 原理 :将切片封装成Promise
对象,使用上述方法同时发起多个请求,需提前控制并发数量。 - 优点 :代码简洁,适合批量处理请求。 3. 第三方库(如p - limit
) : - 原理 :借助库提供的并发控制功能,轻松管理并发数量。 - 优点:使用方便,减少手动控制复杂度。 -
分片队列:
- 使用
chunkQueue
数组存储所有分片的索引。 - 每次从队列中取出一个分片进行上传。
javascript
const chunkQueue = Array.from({ length: totalChunks }, (_, i) => i);
- 上传任务函数:
- 定义了
uploadNextChunk
函数,用于从队列中取出一个分片并上传。 - 每次上传完成后,递归调用自身以继续处理下一个分片。
javascript
const uploadNextChunk = async () => {
if (chunkQueue.length === 0) return; // 队列为空时结束
const chunkIndex = chunkQueue.shift(); // 从队列中取出一个分片索引
// 上传逻辑...
await uploadNextChunk(); // 继续上传下一个分片
};
- 并发控制:
- 使用
maxConcurrentUploads
控制并发数量。 - 创建多个并发任务,每个任务调用
uploadNextChunk
。 - 使用
Promise.all
等待所有并发任务完成。
javascript
const uploaders = Array.from({ length: maxConcurrentUploads }, () =>
uploadNextChunk()
);
await Promise.all(uploaders); // 等待所有并发任务完成
通过这种方式,代码能够同时启动多个上传任务,并在任务完成后继续处理队列中的分片,从而实现了并发上传的功能。
前端向后端发送请求的方式
主要借助 axios
库
3.1 检查文件是否已存在(秒传检查)
csharp
const checkFileExists = async (hash) => {
try {
// 使用 axios 的 get 方法发送请求,传递文件哈希值作为参数
const response = await axios.get('/checkFile', { params: { hash } });
// 返回后端响应中的 exists 字段
return response.data.exists;
} catch (error) {
// 若请求出错,打印错误信息并返回 false
console.error('检查文件存在性时出错:', error);
return false;
}
};
此函数会向后端 /checkFile
接口发送一个 GET
请求,携带文件的哈希值,后端依据该哈希值判断文件是否已存在,随后返回结果。
3.2 获取已上传的切片
csharp
const getUploadedChunks = async (hash) => {
try {
// 使用 axios 的 get 方法发送请求,传递文件哈希值作为参数
const response = await axios.get('/getUploadedChunks', { params: { hash } });
// 返回后端响应中的 chunks 字段
return response.data.chunks;
} catch (error) {
// 若请求出错,打印错误信息并返回空数组
console.error('获取已上传切片时出错:', error);
return [];
}
};
该函数会向后端 /getUploadedChunks
接口发送一个 GET
请求,携带文件的哈希值,后端返回已上传的切片索引。
3.3 上传单个切片
javascript
const uploadChunk = async (chunk, index) => {
uploadingCount.value++;
try {
// 创建一个 FormData 对象,用于存储要上传的数据
const formData = new FormData();
// 向 FormData 中添加切片文件
formData.append('file', chunk);
// 向 FormData 中添加文件哈希值
formData.append('hash', fileHash.value);
// 向 FormData 中添加切片索引
formData.append('index', index);
// 使用 axios 的 post 方法发送请求,传递 FormData 对象
await axios.post('/uploadChunk', formData, {
onUploadProgress: (progressEvent) => {
// 计算上传进度百分比
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
// 更新上传进度
uploadProgress.value = percentCompleted;
},
});
} catch (error) {
// 若请求出错,打印错误信息
console.error('上传切片时出错:', error);
} finally {
// 上传完成后,正在上传的任务数量减 1
uploadingCount.value--;
if (uploadQueue.value.length > 0) {
// 若队列中还有待上传的切片,继续上传
startUploadQueue();
} else if (uploadingCount.value === 0) {
// 若所有切片上传完成,合并切片
mergeChunks();
}
}
};
这个函数会向后端 /uploadChunk
接口发送一个 POST
请求,使用 FormData
携带切片文件、文件哈希值和切片索引。同时,利用 onUploadProgress
事件监听上传进度。
3.4 合并切片
javascript
const mergeChunks = async () => {
try {
// 使用 axios 的 post 方法发送请求,传递文件哈希值
await axios.post('/mergeChunks', { hash: fileHash.value });
// 若合并成功,弹出提示框
alert('文件上传完成');
} catch (error) {
// 若请求出错,打印错误信息
console.error('合并切片时出错:', error);
}
};
该函数会向后端 /mergeChunks
接口发送一个 POST
请求,携带文件的哈希值,通知后端合并所有切片。
4. 总结
前端向后端发送请求的步骤如下:
- 引入
axios
库。 - 依据不同的业务需求,使用
axios
的get
或post
方法发送请求。 - 处理请求的响应和错误。 在实际应用中,根据后端接口的具体要求调整请求参数和请求方式。
大文件上传可以优化的地方
使用webworker计算文件的哈希值
webworker知识点
Web Worker 是一种浏览器提供的 API,允许你在一个独立的线程中执行 JavaScript 代码,与主线程(UI 线程)分离。Web Worker 可以处理计算密集型任务,如数据处理、文件解析等,这些任务通常会阻塞主线程,导致 UI 卡顿。通过 Web Worker,你可以将这些耗时操作移到后台线程,确保主线程始终保持响应状态。
工作原理:
- 独立线程:Web Worker 在一个与主线程(UI 线程)分离的线程中运行,主线程和 Worker 线程之间通过消息传递(postMessage)进行通信。
- 主线程与 Worker 通信 :主线程可以通过
postMessage()
方法向 Worker 发送数据,Worker 完成计算后,通过postMessage()
将结果返回给主线程。 - 异步操作:由于 Worker 在后台线程中运行,因此它的执行不会阻塞主线程,所有的计算任务都是异步执行的。
- 线程间通信 :Worker 无法直接访问主线程的 DOM、
window
或者document
等对象,它只能通过postMessage()
与主线程进行数据交换。返回的数据是通过事件机制传递的,使用onmessage
监听数据的返回。
Web Worker 的优势:
- 性能提升:Web Worker 可以让长时间的计算任务在后台线程中执行,避免 UI 阻塞,提升用户体验。
- 非阻塞性:主线程可以继续处理用户交互和渲染,而不被复杂计算所阻塞。
- 多线程处理:对于 CPU 密集型任务,Web Worker 可以将工作分配给多个 Worker,实现并行计算,提高性能。
Web Worker 的应用场景:
- 大数据处理:例如,处理大量的数组计算、排序、数据筛选等任务。
- 图像处理:例如,进行图像的处理和转换,而不影响 UI 渲染。
- 音视频处理:例如,音视频的编码、解码等计算密集型操作。
- 异步任务:一些需要后台执行的异步任务,可以通过 Worker 来处理。
Web Worker 的局限性:
- 无法操作 DOM :Web Worker 在独立线程中运行,不能直接访问 DOM 和
window
,只能通过消息传递来与主线程交换数据。 - 数据传递 :数据通过
postMessage()
传递时会发生深拷贝,因此传递大数据时可能会有性能开销。 - 浏览器支持:大多数现代浏览器支持 Web Worker,但在旧版浏览器中可能不被支持。
-
创建一个 Web Worker:
javascript// main.js (主线程) const worker = new Worker('worker.js') // 创建 Worker 实例 worker.postMessage('Hello, Worker!') // 向 Worker 发送消息 worker.onmessage = function (event) { console.log('Worker says: ', event.data) // 接收 Worker 的响应 }
-
Worker 文件(worker.js):
javascript
javascript// worker.js (Worker 线程) onmessage = function (event) { console.log('Main thread says: ', event.data) postMessage('Hello, Main Thread!') // 发送响应到主线程 }
使用webworker计算文件哈希值
js
// 计算文件哈希值
const calculateFileHash = (file) => {
return new Promise((resolve) => {
const worker = new Worker(new URL('./hashWorker.js', import.meta.url));
worker.postMessage(file);
worker.onmessage = (e) => {
resolve(e.data);
};
});
};
js
importScripts('https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js');
self.onmessage = (e) => {
const file = e.data;
const chunkSize = 1024 * 1024; // 1MB
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new self.SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
const hash = spark.end();
self.postMessage(hash);
}
};
fileReader.onerror = () => {
console.error('读取文件时出错');
};
const loadNext = () => {
const start = currentChunk * chunkSize;
const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(file.slice(start, end));
};
loadNext();
};