大文件上传是我们 Web 开发中一个常见且关键的需求。无论是上传高清视频、大型数据库备份还是其他大容量文件,如何高效、稳定地完成上传操作,对用户体验和系统性能都有着重要影响。他也是我们前端面试的常客,所以搞懂他是非常有必要的!
一、Blob 对象:大文件上传的基石
Blob(Binary Large Object)对象在大文件上传中扮演着举足轻重的角色。它表示不可变的、原始数据的类文件对象,数据可以按文本或者二进制的形式进行读取,也能转换成 ReadableStream 来用于数据操作。在前端实现大文件上传时,Blob 对象是实现文件分块、封装和传输的基础。
1.1 Blob 对象的基本特性
- 不可变性:一旦创建,Blob 对象的数据内容就不可更改,这保证了数据在传输和处理过程中的一致性和完整性。
- 类文件特性:Blob 对象具有类似文件的接口,例如可以获取其大小、类型等属性,这使得它在处理文件相关操作时非常方便。
- 灵活的数据读取方式:支持以文本或二进制形式读取数据,开发者可以根据具体需求选择合适的读取方式。例如,使用
text()
方法可以将 Blob 对象的数据读取为文本字符串,而arrayBuffer()
方法则可以读取为二进制数组,为后续的数据处理提供了更多可能性。
1.2 在大文件上传中的应用
在大文件上传流程中,Blob 对象主要用于以下几个关键步骤:
- 文件读取与分块:前端通过
FileReader
对象读取本地文件,得到文件对象。然后利用slice
方法对文件对象进行分块,每个分块都是一个 Blob 类型的文件对象。这种分块操作将大文件分割成多个小的部分,便于后续的上传和管理。 - 数据封装与传输:使用
FormData
对象将 Blob 类型的文件对象转换成 FormData 表单类型的对象,以便通过 HTTP 请求发送给后端。在这个过程中,Blob 对象的数据被正确地封装在 FormData 中,确保数据能够准确无误地传输到服务器。
二、前端大文件上传实现步骤
2.1 读取本地文件
在前端,使用input
元素的type="file"
属性可以让用户选择本地文件。当用户选择文件后,通过change
事件获取选中的文件对象。例如:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<input type="file" id="fileInput">
<script>
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', function () {
const file = this.files[0];
if (file) {
console.log('选中的文件:', file);
// 后续处理文件分块等操作
}
});
</script>
</body>
</html>
2.2 文件分块
使用slice
方法对文件对象进行分块,slice
方法会返回一个新的 Blob 对象,表示文件的一部分。例如,将一个文件分成 1MB 大小的块:
const chunkSize = 1024 * 1024; // 1MB
function sliceFile(file) {
const chunks = [];
let start = 0;
let end = chunkSize;
while (start < file.size) {
const chunk = file.slice(start, end);
chunks.push(chunk);
start = end;
end += chunkSize;
}
return chunks;
}
2.3 使用 FormData 封装
将分块后的 Blob 对象封装到FormData
中,以便通过 HTTP 请求发送。FormData
对象提供了一种简单的方式来构造一个包含表单数据的对象,并且可以直接作为fetch
或axios
请求的body
参数。
function createFormData(chunk, index) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkIndex', index);
formData.append('filename', 'example.txt'); // 假设文件名
return formData;
}
2.4 发送请求
使用axios
发送请求,将切片一个一个发送给后端。可以使用Promise.all
进行并发请求,以提高上传效率,但需要注意并发数量的控制,避免对网络和服务器造成过大压力。
import axios from 'axios';
function uploadChunks(chunks) {
const promises = chunks.map((chunk, index) => {
const formData = createFormData(chunk, index);
return axios.post('/upload', formData, {
headers: {
'Content-Type':'multipart/form-data'
}
});
});
return Promise.all(promises);
}
三、后端大文件上传处理
当前端将文件切片发送到后端时,后端需要解析请求中的表单数据,并将切片保存到临时目录中。
实现步骤:
- 使用
multiparty.Form
解析请求中的表单数据。 - 将切片保存到以文件名为命名空间的临时目录中,通常以
filename-chunks
的形式命名。 - 每个切片按索引顺序保存,例如
chunk-0
、chunk-1
等。
代码实现
if (req.url === '/upload') {
// 后端能不能读懂fromData数据,借助第三方工具 multiparty 来解析
// 安装:npm install multer --save
// 引入:const multiparty = require('multiparty');
const form = new multiparty.Form();
form.parse(req, (err, fields, files) => {
// fields 是普通表单字段,files 是文件字段
console.log(fields); //切片的描述
console.log(files); // 切片的二进制资源被处理成对象
const [file] = files.file;
const [Filename] = fields.fileName
const [chunkName] = fields.chunkName
// 保存切片
const chunkDir = path.resolve(UPLOAD_DIR, `${Filename}-chunks`);
if (!fse.existsSync(chunkDir)) {
fse.mkdirSync(chunkDir);
}
// 存入
fse.moveSync(file.path, `${chunkDir}/${chunkName}`);
res.end(JSON.stringify({
code: 0,
message: '切片上传成功'
}))
})
3.2 处理切片上传
在上述代码中,upload.single('chunk')
表示接收单个名为chunk
的文件切片。在请求处理函数中,可以获取切片的相关信息,如文件名、切片索引等,并进行相应的处理,如保存切片到指定目录、记录上传状态等。
四、秒传实现原理与应用
4.1 秒传的概念与优势
秒传是一种高效的文件上传方式,它能够在极短的时间内完成文件上传操作,大大提升了用户体验。其核心原理是利用文件的唯一标识(如 MD5 值)来判断服务器上是否已经存在相同的文件。如果存在,则无需重新上传文件内容,只需进行一些简单的记录和关联操作,即可完成上传。
4.2 实现步骤
-
计算 MD5 值:在上传文件之前,先使用
Spark - MD5
库计算文件的 MD5 值。Spark - MD5
是一个用于计算 MD5 哈希值的 JavaScript 库,它可以高效地计算大文件的 MD5 值。
import SparkMD5 from'spark-md5';
function calculateMD5(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = function () {
spark.append(this.result);
const md5 = spark.end();
resolve(md5);
};
fileReader.onerror = reject;
});
}
- 检查文件是否存在:将计算得到的 MD5 值发送给服务器,服务器通过查询数据库或文件存储系统,检查是否已经有相同 MD5 值的文件存在。
- 完成秒传:如果服务器已有该文件,则直接完成上传操作,返回上传成功的响应给前端。前端接收到响应后,即可告知用户上传已完成。
五、断点续传:保障上传稳定性
5.1 断点续传的重要性
在文件上传过程中,由于网络不稳定、客户端崩溃或服务器故障等原因,上传中断的情况时有发生。断点续传技术的出现,有效地解决了这一问题。它允许在上传中断后,再次上传时能够从上次中断的位置继续上传,而无需重新开始整个文件的上传过程,大大提高了上传的成功率和效率。
5.2 实现原理
- 切片上传:将大文件分割成多个小的切片,分别上传这些切片。这样在上传中断时,只需要重新上传未完成的切片,而不是整个文件。
- 记录上传状态:前端和后端都需要记录哪些切片已经上传成功。前端通常使用数组来存储已上传切片的索引,而后端可以使用数据库、文件或内存数据结构(如对象)来记录每个文件的已上传切片信息。
- 检查与重传:当上传中断后重新开始上传时,前端会向服务器请求未上传的切片索引,然后只上传这些未上传的切片。
5.3 关键代码实现
前端代码
const uploadedChunks = [];
async function uploadChunk(chunk, index) {
const formData = createFormData(chunk, index);
try {
await fetch('/upload', {
method: 'POST',
body: formData
});
uploadedChunks.push(index);
} catch (error) {
console.error('上传切片失败:', error);
}
}
async function resumeUpload(totalChunks) {
const response = await fetch(`/check?filename=${filename}&totalChunks=${totalChunks}`);
const { missingChunks } = await response.json();
for (let i of missingChunks) {
const chunk = chunks[i];
await uploadChunk(chunk, i);
}
}
后端代码
const uploadedChunks = {};
app.post('/upload', upload.single('chunk'), (req, res) => {
const { filename, chunkIndex } = req.body;
if (!uploadedChunks[filename]) {
uploadedChunks[filename] = [];
}
uploadedChunks[filename].push(chunkIndex);
res.sendStatus(200);
});
app.get('/check', (req, res) => {
const { filename, totalChunks } = req.query;
const uploaded = uploadedChunks[filename] || [];
const missingChunks = [];
for (let i = 0; i < totalChunks; i++) {
if (!uploaded.includes(i.toString())) {
missingChunks.push(i);
}
}
res.json({ missingChunks });
});
六、断开重连重传:应对网络波动
6.1 断开重连重传的原理
断开重连重传本质上是断点续传在网络断开场景下的一种具体应用。当网络断开后又重新连接时,上传任务会继续进行,从上次中断的位置接着上传未完成的文件切片。其实现原理主要包括网络状态监听和断点续传机制复用。
6.2 实现步骤
- 网络状态监听:前端利用
window.addEventListener('online', callback)
监听网络重新连接事件。当检测到网络从断开状态变为连接状态时,触发重新上传的操作。 - 断点续传机制复用:在网络重新连接后,前端调用断点续传的逻辑,向服务器请求未上传的切片索引,然后继续上传这些未上传的切片。
6.3 关键代码实现
window.addEventListener('online', async () => {
// 网络重新连接,执行断点续传逻辑
const response = await fetch(`/check?filename=${filename}&totalChunks=${totalChunks}`);
const { missingChunks } = await response.json();
for (let i of missingChunks) {
const chunk = chunks[i];
await uploadChunk(chunk, i);
}
});
后端代码与断点续传的后端代码相同,主要是记录已上传切片信息和提供检查未上传切片的接口。
七、合并切片:完成文件上传的最后一步
7.1 合并切片的流程
当所有切片都成功上传到服务器后,需要将这些切片合并成一个完整的文件。合并切片的过程涉及到文件读取、数据传输和写入操作。
- 读取切片数据:使用
fs.createReadStream
创建可读流,从对应的切片文件中读取数据。 - 传输数据:通过
pipe
方法将可读流读取到的数据传输给可写流。pipe
方法会自动管理数据的传输过程,包括数据的读取、缓冲和写入,大大简化了数据传输的代码实现。 - 写入合并文件:使用
fs.createWriteStream
创建可写流,将接收到的数据写入到最终合并后的文件中。
7.2 关键代码实现
const express = require('express');
const app = express();
const path = require('path');
const fs = require('fs');
app.post('/merge', (req, res) => {
const { filename, totalChunks } = req.body;
const mergeFilePath = path.join('uploads', filename);
const writeStream = fs.createWriteStream(mergeFilePath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join('uploads', `${filename}-${i}`);
const readStream = fs.createReadStream(chunkPath);
readStream.pipe(writeStream, { end: false });
readStream.on('end', () => {
fs.unlinkSync(chunkPath);
if (i === totalChunks - 1) {
writeStream.end();
res.sendStatus(200);
}
});
}
});
-
路由定义与请求处理函数
app.post('/merge', (req, res) => {
:定义一个处理 HTTP POST 请求的路由,路径为/merge
。当客户端向该路径发送 POST 请求时,会执行后面的回调函数。req
是请求对象,包含了客户端发送的请求信息,如请求头、请求体等;res
是响应对象,用于向客户端返回响应数据。
-
获取请求参数
const { filename, totalChunks } = req.body;
:使用对象解构从请求体req.body
中获取filename
(文件名)和totalChunks
(切片总数)这两个参数。这两个参数是前端在发起合并请求时传递过来的,用于确定要合并的文件以及切片的数量。
-
确定合并文件的路径
const mergeFilePath = path.join('uploads', filename);
:使用path.join
方法拼接合并后文件的存储路径。path.join
会根据不同操作系统的路径分隔符,将uploads
目录和filename
组合成一个合法的文件路径。例如,在 Linux 系统下可能是uploads/filename
,在 Windows 系统下可能是uploads\filename
。
-
创建可写流
const writeStream = fs.createWriteStream(mergeFilePath);
:使用fs.createWriteStream
创建一个可写流对象writeStream
,用于将合并后的文件内容写入到mergeFilePath
指定的文件中。可写流负责将数据写入文件,为后续的切片合并操作做准备。
-
循环读取并合并切片
for (let i = 0; i < totalChunks; i++) {
:开始一个循环,循环次数为切片总数totalChunks
。每次循环处理一个切片。const chunkPath = path.join('uploads',
{i});
:根据当前循环的索引i
,拼接每个切片的文件路径。例如,当i
为 0 时,路径可能是uploads/filename - 0
,以此类推。const readStream = fs.createReadStream(chunkPath);
:针对每个切片路径,使用fs.createReadStream
创建一个可读流对象readStream
,用于读取切片文件的内容。readStream.pipe(writeStream, { end: false });
:使用pipe
方法将可读流readStream
和可写流writeStream
连接起来。pipe
方法会自动处理数据的读取、缓冲和写入操作,将切片文件的内容传输到合并文件中。{ end: false }
选项表示当可读流读取完数据后,不会自动关闭可写流,以便后续继续写入其他切片数据。readStream.on('end', () => {
:为可读流readStream
添加一个end
事件监听器。当可读流读取完当前切片的所有数据时,会触发这个事件。fs.unlinkSync(chunkPath);
:在当前切片数据读取并写入合并文件后,使用fs.unlinkSync
同步删除该切片文件。fs.unlinkSync
会直接删除指定路径的文件,释放磁盘空间。if (i === totalChunks - 1) {
:判断当前处理的切片是否是最后一个切片。如果是最后一个切片,执行以下操作。writeStream.end();
:关闭可写流writeStream
,表示所有切片数据都已写入合并文件,文件写入操作完成。res.sendStatus(200);
:向客户端返回 HTTP 状态码 200,表示合并操作成功。客户端接收到这个状态码后,可以知道文件合并已顺利完成。
END
大文件上传是我们的必修课,大体的实现我可以总结为如下文字,以便帮大家备战春招!!
前端拿到整个文件后利用文件
Blob
原型上的slice
方法进行切割,将得到的切片数组chunkList
添加一些信息,比如文件名和下标,得到uploadChunkList
,但是uploadChunkList
想要传给后端还需要将其转换成表单数据格式,通过Promise.all
并发发给后端,传输完毕后发送一个合并请求,合并请求带上文件名和切片大小信息
后端拿到前端传过来的表单格式数据需要
multiparty
依赖来解析这个表单数据,然后把切片解析出来去存入切片,存入到提前创建好的目录中,最后将切片按照下标进行排序再来合并切片,合并切片的实现比较复杂,需要创建一个可以写入流的文件,将每个片段读成流类型,再写入到可写流中