获取文件对象
<script setup lang="ts">
function uploadFile(e: Event) {
const file = (e.target as HTMLInputElement).files;
if (!file) return;
console.log(file[0]);
}
</script>
<template>
<div>
<input type="file" @change="uploadFile"></input>
</div>
</template>
<style lang="scss" scoped></style>
文件分片
文件分片的核心是使用Blob对象的slice方法。在上一步中,我们获取到的文件是一个File对象,它继承自Blob,因此我们可以使用slice方法对文件进行分片。slice方法的用法如下:
let blob = instanceOfBlob.slice([start [, end [, contentType]]]);
其中,start 和 end 参数代表Blob中的下标,表示被拷贝进新Blob的字节的起始位置和结束位置。contentType 参数用于给新的Blob赋予一个新的文档类型,但在这个场景中我们不需要使用它。
接下来,我们可以使用slice方法来实现文件的分片。以下是一个示例代码:
const CHUNK_SIZE = 1024 * 1024; // 分片大小1M
const createFileChunks = (file: File) => {
const fileChunkList = []; // 分片数组
let cur = 0;
while (cur < file.size) {
fileChunkList.push({
file: file.slice(cur, cur + CHUNK_SIZE),
});
cur += CHUNK_SIZE; // CHUNK_SIZE为分片的大小
}
return fileChunkList;
}
在这个代码中,createFileChunks 函数接收一个File对象作为参数,并将其分割成多个大小为CHUNK_SIZE的片段。这些片段被存储在fileChunkList数组中并返回。
计算文件hash
在文件上传过程中,区分不同文件的一个有效方法是通过文件内容生成唯一的哈希值。文件名可能会被用户随意更改,因此不能作为区分文件的可靠依据。通过文件内容生成的哈希值可以确保每个文件都有唯一的标识符,即使文件名相同,只要内容不同,哈希值也会不同。
哈希值的应用
- 区分文件:通过哈希值可以准确区分不同的文件内容。
- 实现秒传:如果服务器上已经存在相同哈希值的文件,用户再次上传相同文件时,服务器可以直接跳过上传过程,实现“秒传”功能。
计算文件哈希值
可以使用 spark-md5 这样的工具来计算文件的哈希值。为了优化计算时间,特别是对于大文件,可以采用以下策略:
- 第一个和最后一个切片:这两个切片的所有内容都参与哈希计算。
- 中间切片:对于中间的切片,可以分别在前面、后面和中间取2个字节参与计算。
这种方法既能保证所有切片都参与了哈希计算,又能减少计算时间。
// 定义一个函数 `calculateFileHash`,用于计算文件块的哈希值
// 参数 `fileChunks` 是一个包含文件块的数组,每个文件块是一个对象,包含 `file` 属性(类型为 `Blob`)
// 返回一个 `Promise`,解析为计算出的哈希值(类型为 `string`)
const calculateFileHash = (fileChunks: { file: Blob }[]): Promise<string> => {
return new Promise((resolve) => {
// 创建一个 `SparkMD5` 实例,用于计算 MD5 哈希值
const spark = new SparkMD5.ArrayBuffer();
// 用于存储处理后的文件块
const chunks: Blob[] = [];
// 遍历 `fileChunks` 数组
fileChunks.forEach((chunk, index) => {
// 如果是第一个或最后一个文件块,直接将其添加到 `chunks` 数组中
if (index === 0 || index === fileChunks.length - 1) {
chunks.push(chunk.file);
} else {
// 对于中间的文件块,只取文件块的开头 2 字节、中间 2 字节和结尾 2 字节
chunks.push(chunk.file.slice(0, 2)); // 开头 2 字节
chunks.push(chunk.file.slice(chunk.file.size / 2 - 1, chunk.file.size / 2 + 1)); // 中间 2 字节
chunks.push(chunk.file.slice(chunk.file.size - 2, chunk.file.size)); // 结尾 2 字节
}
});
// 创建一个 `FileReader` 实例,用于读取文件块内容
const reader = new FileReader();
// 将处理后的文件块合并为一个新的 `Blob`,并读取为 `ArrayBuffer`
reader.readAsArrayBuffer(new Blob(chunks));
// 当文件读取完成时触发 `onload` 事件
reader.onload = (e: Event) => {
// 将读取到的 `ArrayBuffer` 数据添加到 `SparkMD5` 实例中
spark.append(e?.target?.result as ArrayBuffer);
// 计算并返回最终的 MD5 哈希值
resolve(spark.end());
};
});
};
文件上传
思路
- 并发控制:为了避免浏览器同时创建过多请求(例如1024个分片),需要限制并发请求的数量。通常,浏览器的默认并发请求数为6,因此建议将并发请求数限制在这个范围内。
- 请求管理:通过管理请求队列,确保在任一时刻只有一定数量的请求被发送。当一个请求完成后,再发起新的请求,直到所有分片上传完毕。
- 使用
FormData:在上传文件时,通常使用FormData对象来封装文件分片和额外的元数据信息,以便通过HTTP请求发送。
代码实现
// 向服务端发起合并切片的请求
const mergeRequest = () => {
fetch("http://localhost:3000/merge", {
method: "POST",
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value,
size: CHUNK_SIZE
}),
headers: {
"Content-Type": "application/json"
}
}).then(res => {
console.log('res', res.json());
if (res.status === 200) {
alert("请求成功");
}
})
}
// 上传文件分片
const uploadChunks = async (chunks: { file: Blob }[]) => {
// 将每个文件分片转换为包含分片、文件哈希和分片哈希的对象
const data = chunks.map((chunk, index) => {
return {
chunk, // 当前分片
fileHash: fileHash.value, // 文件的哈希值
chunkHash: fileHash.value + "-" + index // 分片的哈希值,由文件哈希和分片索引组成
}
})
// 将每个分片对象转换为FormData对象,用于上传
const formDatas = data.map(item => {
const formData = new FormData();
formData.append("file", item.chunk.file); // 添加分片文件
formData.append("fileHash", item.fileHash); // 添加文件哈希
formData.append("chunkHash", item.chunkHash); // 添加分片哈希
return formData;
})
const max = 6; // 最大并发上传数
let index = 0; // 当前上传的分片索引
const taskPool: any = []; // 用于存储正在进行的上传任务
// 循环上传所有分片
while (index < formDatas.length) {
// 创建一个上传任务
const task = fetch("/upload", {
method: "POST",
body: formDatas[index]
})
// 将任务添加到任务池中
taskPool.splice(taskPool.findIndex(item => item === task));
taskPool.push(task);
// 如果任务池中的任务数量达到最大并发数,等待其中一个任务完成
if (taskPool.length === max) {
await Promise.race(taskPool);
}
index++; // 处理下一个分片
}
// 等待所有任务完成
await Promise.all(taskPool);
// 向服务端发起merge请求
mergeRequest();
}
秒传
如果内容相同的文件进行哈希计算时,对应的哈希值应该是一样的。而且我们在服务器上给上传的文件命名时,就是使用对应的哈希值来命名的。因此,在上传之前,是否可以加一个判断:如果服务器上已经存在对应的文件,就不需要再重复上传了,直接告诉用户上传成功。这样给用户的感觉就像是实现了秒传。接下来,我们来看一下如何实现这一点。
前端实现
前端在上传之前,需要将对应文件的哈希值告诉服务器,检查服务器上是否已经存在该文件。如果存在,就直接返回,不再执行上传分片的操作。
// 校验hash值是否存在
const verify = () => {
return fetch("http://localhost:3000/verify", {
method: "POST",
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value,
}),
headers: {
"Content-Type": "application/json"
}
})
.then(res => res.json())
.then(res => res)
}
断点续传
上传之前先获取已经上传的分片列表,然后过滤掉这些已经上传的分片
已上传的分片列表由后端传递给前端
在文件上传的函数中添加一个过滤项 具体请看完整代码
// 将每个分片对象转换为FormData对象,用于上传
const formDatas = data
.filter(item => !existChunks.includes(item.chunkHash)) // 过滤服务器已存在的分片
.map(item => {
const formData = new FormData();
formData.append("file", item.chunk.file); // 添加分片文件
formData.append("fileHash", item.fileHash); // 添加文件哈希
formData.append("chunkHash", item.chunkHash); // 添加分片哈希
return formData;
})
完整代码(可运行)
前端代码
创建项目npm create vite
将下面代码粘贴到App.vue文件
<script setup lang="ts">
// 导入 spark-md5 库,用于计算文件的哈希值
import SparkMD5 from "spark-md5";
import { ref } from "vue";
const CHUNK_SIZE = 1024 * 1024; // 设置每分片的大小
const fileName = ref(); // 文件名称
const fileHash = ref(); // 文件hash值
// 创建分片
const createFileChunks = (file: File) => {
const fileChunkList = [];
let cur = 0;
while (cur < file.size) {
fileChunkList.push({
file: file.slice(cur, cur + CHUNK_SIZE),
});
cur += CHUNK_SIZE;
}
return fileChunkList;
};
/**
* 计算文件的哈希值
* @param fileChunks 文件分片数组,每个分片是一个包含 file 属性的对象
*/
const calculateFileHash = (fileChunks: { file: Blob }[]): Promise<string> => {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer();
const chunks: Blob[] = [];
fileChunks.forEach((chunk, index) => {
if (index === 0 || index === fileChunks.length - 1) {
chunks.push(chunk.file);
} else {
chunks.push(chunk.file.slice(0, 2));
chunks.push(chunk.file.slice(chunk.file.size / 2 - 1, chunk.file.size / 2 + 1));
chunks.push(chunk.file.slice(chunk.file.size - 2, chunk.file.size));
}
});
const reader = new FileReader();
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = (e: ProgressEvent<FileReader>) => {
spark.append(e?.target?.result as ArrayBuffer);
resolve(spark.end());
};
})
}
// 向服务端发起合并切片的请求
const mergeRequest = () => {
fetch("http://localhost:3000/merge", {
method: "POST",
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value,
size: CHUNK_SIZE
}),
headers: {
"Content-Type": "application/json"
}
}).then(res => {
console.log('res', res.json());
if (res.status === 200) {
alert("请求成功");
}
})
}
// 上传文件分片
const uploadChunks = async (chunks: { file: Blob }[], existChunks: string[]) => {
// 将每个文件分片转换为包含分片、文件哈希和分片哈希的对象
const data = chunks.map((chunk, index) => {
return {
chunk, // 当前分片
fileHash: fileHash.value, // 文件的哈希值
chunkHash: fileHash.value + "-" + index // 分片的哈希值,由文件哈希和分片索引组成
}
})
// 将每个分片对象转换为FormData对象,用于上传
const formDatas = data
.filter(item => !existChunks.includes(item.chunkHash)) // 过滤服务器已存在的分片
.map(item => {
const formData = new FormData();
formData.append("file", item.chunk.file); // 添加分片文件
formData.append("fileHash", item.fileHash); // 添加文件哈希
formData.append("chunkHash", item.chunkHash); // 添加分片哈希
return formData;
})
const max = 6; // 最大并发上传数
let index = 0; // 当前上传的分片索引
const taskPool: any = []; // 用于存储正在进行的上传任务
// 循环上传所有分片
while (index < formDatas.length) {
// 创建一个上传任务
const task = fetch("http://localhost:3000/upload", {
method: "POST",
body: formDatas[index]
})
// 将任务添加到任务池中
taskPool.splice(taskPool.findIndex((item: Promise<Response>) => item === task));
taskPool.push(task);
// 如果任务池中的任务数量达到最大并发数,等待其中一个任务完成
if (taskPool.length === max) {
await Promise.race(taskPool);
}
index++; // 处理下一个分片
}
// 等待所有任务完成
await Promise.all(taskPool);
// 向服务端发起merge请求
mergeRequest();
}
// 校验hash值是否存在
const verify = () => {
return fetch("http://localhost:3000/verify", {
method: "POST",
body: JSON.stringify({
fileHash: fileHash.value,
fileName: fileName.value,
}),
headers: {
"Content-Type": "application/json"
}
})
.then(res => res.json())
.then(res => res)
}
async function uploadFile(e: Event) {
const file = (e.target as HTMLInputElement).files;
if (!file) return;
fileName.value = file[0].name;
// 文件切片
const fileChunks = createFileChunks(file[0]);
// 计算文件哈希值
const hash = await calculateFileHash(fileChunks);
fileHash.value = hash;
console.log("File hash:", hash);
// 校验hash值是否存在
const res = await verify();
if (res.code === 10000) {
alert("秒传:文件已存在");
return;
}
// 上传文件分片 res.data是服务器已存在的分片哈希数组
uploadChunks(fileChunks, res.data);
}
</script>
<template>
<div>
<input type="file" @change="uploadFile" />
</div>
</template>
<style lang="scss" scoped></style>
后端代码
npm init
npm i express multiparty fs-extra cors body-parser
创建存放资源的文件夹uplaods和文件index.js
文件夹目录结构
const express = require("express");
const path = require("path");
const multiparty = require("multiparty");
const fse = require("fs-extra");
const cors = require("cors");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());
app.use(cors());
// 文件上传处理配置
const UPLOAD_DIR = path.resolve(__dirname, "uploads");
// 处理文件分片上传路由
app.post("/upload", async (req, res) => {
const form = new multiparty.Form();
form.parse(req, async (err, fields, files) => {
console.log(err, fields, files);
if (err) {
return res.status(401).json({
code: 10001,
msg: "上传失败",
});
}
// 获取文件名和哈希值
const chunkHash = fields["chunkHash"][0];
const fileHash = fields["fileHash"][0];
// const fileName = fields["fileName"][0];
// 临时分片存储目录
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
// 创建目录(如果不存在)
if (!fse.existsSync(chunkDir)) {
await fse.mkdirSync(chunkDir);
}
// 移动分片文件到临时目录
const oldPath = files.file[0].path;
await fse.move(oldPath, path.resolve(chunkDir, chunkHash));
res.status(200).json({
code: 10000,
msg: "分片接收成功",
});
});
});
// 提取文件后缀名
const extractExt = (filename) => {
return filename.slice(filename.lastIndexOf("."), filename.length);
};
// 处理合并请求
app.post("/merge", async (req, res) => {
// 从请求体中获取文件哈希、文件名和文件大小
const { fileHash, fileName, size } = req.body;
// 构建文件的完整路径
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`);
// 如果文件已经存在,直接返回成功响应
if (fse.existsSync(filePath)) {
return res.status(200).json({
code: 10000,
msg: "合并成功(文件已存在)",
});
}
// 构建存储文件块的目录路径
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
// 如果文件块目录不存在,返回失败响应
if (!fse.existsSync(chunkDir)) {
return res.status(401).json({
code: 10001,
msg: "合并失败(文件不存在)",
});
}
// 读取文件块目录中的所有文件块
const chunkPaths = await fse.readdir(chunkDir);
// 按文件块的顺序进行排序(假设文件块名称格式为 "chunkName-index")
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
// 创建一个 Promise 数组,用于处理每个文件块的合并操作
const list = chunkPaths.map((chunkName, index) => {
return new Promise((resolve) => {
// 构建当前文件块的路径
const chunkPath = path.resolve(chunkDir, chunkName);
// 创建可读流,用于读取文件块
const readStream = fse.createReadStream(chunkPath);
// 创建可写流,用于将文件块写入最终文件
const writeStream = fse.createWriteStream(filePath, {
start: index * size, // 写入的起始位置
end: (index + 1) * size, // 写入的结束位置
});
// 当文件块读取完成时,删除该文件块并 resolve Promise
readStream.on("end", async () => {
await fse.unlink(chunkPath);
resolve();
});
// 将读取流通过管道传输到写入流
readStream.pipe(writeStream);
});
});
// 等待所有文件块合并完成
await Promise.all(list);
// 删除文件块目录
await fse.remove(chunkDir);
// 返回合并成功的响应
res.status(200).json({
code: 10000,
msg: "合并成功",
});
});
// 验证文件是否存在
app.post("/verify", async (req, res) => {
const { fileHash, fileName } = req.body;
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`);
// 返回服务器已经成功上传的切片
const chunkDir = path.join(UPLOAD_DIR, fileHash);
let chunkPaths = [];
// 如果存在对应的临时文件夹才去读取
if (fse.existsSync(chunkDir)) {
chunkPaths = await fse.readdir(chunkDir);
}
if (fse.existsSync(filePath)) {
// 文件存在
res.status(200).json({
code: 10000,
msg: "文件已存在",
data: filePath,
});
} else {
// 文件不存在
res.status(200).json({
code: 10001,
msg: "文件不存在",
data: chunkPaths,
});
}
});
app.listen(3000, () => {
console.log("http://localhost:3000");
});