断点续传
断点续传需要为每个分块加md5值,如果用户取消上传,可以知道那些分块已经上传了
切块上传
只要校验整个文件的完整性就好
前端代码示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>大文件上传</title>
<style>
body {
padding: 24px;
}
button {
padding: 8px 16px;
cursor: pointer;
border: 1px solid #409eff;
border-radius: 4px;
background-color: #409eff;
color: #fff;
margin-right: 16px;
}
.progress {
width: 100%;
height: 24px;
background-color: #ebeef5;
border-radius: 12px;
margin-bottom: 24px;
overflow: hidden;
}
.progress-inner {
width: 0%;
height: 100%;
background-color: #67c23a;
border-radius: 12px;
text-align: right;
transition: width 0.6s ease;
}
.progress-text {
color: #fff;
margin: 0 5px;
display: inline-block;
font-size: 12px;
}
</style>
</head>
<body>
<!-- 进度条 -->
<div class="progress">
<div class="progress-inner">
<div class="progress-text"></div>
</div>
</div>
<!-- 上传完显示预览地址 -->
<p>
<a id="filelink"></a>
</p>
<!-- 操作按钮 -->
<button id="upload">
<input type="file" id="file" style="display: none" />
上传文件
</button>
<button id="download">下载文件</button>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://unpkg.com/spark-md5@3.0.2/spark-md5.js"></script>
<script>
// 上传
document.querySelector("#upload").addEventListener("click", () => {
document.querySelector("#file").click();
});
// 选中文件直接上传
document.querySelector("#file").addEventListener("change", async (e) => {
const file = e.target.files[0];
// 获取文件的所有分块
const blobList = getFileSlices(file);
const uploadRequests = [];
// 所有分块的md5值
// 后端根据md5值存的块,请求合并根据md5值进行合并
const md5ChunkList = [];
let totalUploaded = 0;
for (let i = 0; i < blobList.length; i++) {
const blob = blobList[i];
const md5 = await calculateMD5(blob);
md5ChunkList.push(md5);
const formData = new FormData();
formData.append("file", blob);
formData.append("md5", md5);
const request = axios({
method: "post",
url: "http://localhost:3000/upload",
data: formData,
onUploadProgress: (progressEvent) => {
totalUploaded += progressEvent.loaded;
let percentCompleted = Math.floor(
(totalUploaded / file.size) * 100
);
// 为合并留1%进度条
if (percentCompleted >= 100) {
percentCompleted = 99;
}
updateProgressBar(percentCompleted);
},
});
uploadRequests.push(request);
}
try {
// 等待所有分块上传完成,再请求合并
await Promise.all(uploadRequests);
const { data } = await axios({
method: "post",
url: "http://localhost:3000/merge",
data: { md5: md5ChunkList, fileName: file.name },
});
updateProgressBar(100);
const url = `http://localhost:3000${data.data}`;
console.log(url);
const a = document.querySelector("#filelink");
a.href = url;
a.target = "_blank";
a.innerText = url;
} catch (e) {
console.error(e);
}
});
// 获取文件分块
// 默认分块大小5m
function getFileSlices(file, segmentSize = 5 * 1024 * 1024) {
const fileSlices = [];
let offset = 0;
while (offset < file.size) {
const segment = file.slice(offset, offset + segmentSize);
fileSlices.push(segment);
offset += segmentSize;
}
return fileSlices;
}
// 计算分块的md5值
function calculateMD5(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
let spark = new SparkMD5.ArrayBuffer();
fileReader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};
fileReader.onerror = () => {
reject("blob读取失败");
};
fileReader.readAsArrayBuffer(blob);
});
}
function updateProgressBar(percent) {
const width = percent + "%";
document.querySelector(".progress-inner").style.width = width;
document.querySelector(".progress-text").innerText = width;
}
// 下载
document.querySelector("#download").addEventListener("click", () => {
const url = document.querySelector("#filelink").innerText;
const fileName = url.substring(url.lastIndexOf("/") + 1);
const download = document.createElement("a");
download.href = `http://localhost:3000/download/${fileName}`;
download.download = fileName;
download.target = "_blank";
download.click();
});
</script>
</body>
</html>
后端代码示例
import express from "express";
import cors from "cors";
import multer from "multer";
import fs from "fs";
import path from "path";
import mime from "mime-types";
const app = express();
const port = 3000;
const upload = multer({ dest: "uploads/" });
// 处理跨域请求
app.use(cors());
// 解析客户端请求中的JSON数据,解析后的对象将被添加到req.body
app.use(express.json());
// 启用对静态文件的服务
app.use("/uploads", express.static("uploads"));
app.get("/", (req, res) => {
res.send("Hello World!");
});
// 上传
app.post("/upload", upload.single("file"), (req, res) => {
const file = req.file;
const md5 = req.body.md5;
const newPath = path.join(path.dirname(file.path), md5);
fs.renameSync(file.path, newPath);
res.send({ code: 200 });
});
// 合并
app.post("/merge", async (req, res) => {
const { md5, fileName } = req.body;
await concatFiles(md5, fileName);
res.send({ code: 200, msg: "合并成功", data: `/uploads/${fileName}` });
});
// 下载文件流
app.get("/download/:fileName", (req, res) => {
const { fileName } = req.params;
const file = path.join("./uploads", fileName);
const type = mime.lookup(file);
res.setHeader(
"Content-disposition",
"attachment; filename=" + encodeURIComponent(path.basename(file))
);
res.setHeader("Content-type", type);
const filestream = fs.createReadStream(file);
filestream.pipe(res);
});
app.listen(port, () => {
console.log(`listening on port ${port}`);
});
// 合并切片的文件
async function concatFiles(fileChunks, name) {
const filePath = path.join("./uploads", name);
const writeStream = fs.createWriteStream(filePath);
for (let i = 0; i < fileChunks.length; i++) {
const chunkPath = path.join("./uploads", fileChunks[i]);
const file = fs.createReadStream(chunkPath);
file.pipe(writeStream, { end: false });
// 等待当前文件读完再进行下一个
await new Promise((resolve) => file.on("end", resolve));
fs.unlink(chunkPath, (e) => {
if (e) {
console.error("删除文件切片失败", e);
}
});
}
writeStream.end();
}
参考
面试官:如何实现大文件上传
大文件分块上传