产品:什么???上传一张不到三十兆的图片要将近一分钟,不知道的还以为系统挂了呢,能不能搞,能不能优化。
前端:这个得问后端
后端:上传接口都给你们前端了,你们就不能做个分片上传啊
工作中偶尔遇到这种扯淡的后端,连文件分片上传和文件普通上传的实现逻辑都没搞清楚,就开始甩锅给前端,这个时候,前端就应该要了解分片上传和文件普通上传的区别,就能让你有理有据的去怼后端

前端:通用的上传接口怎么处理文件分片和文件合并,你不会连分片需要合并都不知道吧?
后端:我们用的是阿里云OSS,合并什么?
前端:不会吧,难道你连阿里云OSS的分片上传方案都不知道吗!!!回去多看看书吧!!!

文件分片上传与合并
xml
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>文件分片上传</title>
<style>
.file-info {
margin: 10px 0;
}
</style>
</head>
<body>
<input type="file" id="fileInput" />
<button id="uploadBtn">上传</button>
<div id="fileInfo" class="file-info"></div>
<script>
const fileInput = document.getElementById("fileInput");
const uploadBtn = document.getElementById("uploadBtn");
const fileInfo = document.getElementById("fileInfo");
let file = null;
let fileHash = "";
let chunkSize = 1 * 1024 * 1024; // 1MB
let chunks = [];
let uploadedChunks = 0;
// 选择文件
fileInput.addEventListener("change", (e) => {
file = e.target.files[0];
if (!file) return;
fileInfo.innerHTML = `
<p>文件名: ${file.name}</p>
<p>文件大小: ${(file.size / 1024 / 1024).toFixed(2)} MB</p>
<p>分片大小: ${(chunkSize / 1024 / 1024).toFixed(2)} MB</p>
<p>总分片数: ${Math.ceil(file.size / chunkSize)}</p>
`;
// 生成文件唯一标识
generateFileHash(file).then((hash) => {
fileHash = hash;
console.log("文件hash:", fileHash);
});
});
// 上传按钮
uploadBtn.addEventListener("click", async () => {
if (!file) {
alert("请先选择文件");
return;
}
// 分割文件
chunks = createChunks(file, chunkSize);
// 上传所有分片
await uploadChunks(chunks);
// 合并请求
await mergeRequest();
alert("上传完成!");
});
// 生成文件hash
async function generateFileHash(file) {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const size = file.size;
const offset = 2 * 1024 * 1024;
// 读取文件前2M,中间2M,最后2M的内容来计算hash
const chunks = [
file.slice(0, offset),
file.slice(size / 2 - offset / 2, size / 2 + offset / 2),
file.slice(size - offset, size),
];
let currentChunk = 0;
reader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks.length) {
reader.readAsArrayBuffer(chunks[currentChunk]);
} else {
resolve(spark.end());
}
};
reader.readAsArrayBuffer(chunks[currentChunk]);
});
}
// 分割文件
function createChunks(file, chunkSize) {
const chunks = [];
let start = 0;
while (start < file.size) {
chunks.push({
index: chunks.length,
file: file.slice(start, start + chunkSize),
});
start += chunkSize;
}
return chunks;
}
// 上传分片
async function uploadChunks(existingChunks = []) {
const uploadedChunkIndexes = existingChunks.map((c) => c.index);
for (let i = 0; i < chunks.length; i++) {
const formData = new FormData();
formData.append("file", chunks[i].file);
const searchParams = new URLSearchParams();
searchParams.append("index", chunks[i].index);
searchParams.append("fileHash", fileHash);
try {
await fetch(
"http://localhost:3000/upload?" + searchParams.toString(),
{
method: "POST",
body: formData,
}
);
} catch (err) {
console.error("上传失败:", err);
break;
}
}
}
// 合并请求
async function mergeRequest() {
try {
await fetch("http://localhost:3000/upload/merge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
fileHash: fileHash,
fileName: file.name,
total: chunks.length,
}),
});
} catch (err) {
console.error("合并失败:", err);
}
}
</script>
<!-- 用于生成文件hash的库 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
</body>
</html>
前端主要逻辑:前端获取一个hash做为单次上传的标识(正常通过服务端获取,以确保唯一性),对文件进行分片并序列化,然后进行所有分片文件的上传,同时传递对应hash以及分片的序列号,最后,所有分片上传成功后调用文件合并接口。
服务端使用node实现:
ini
const express = require("express");
const multer = require("multer");
const fs = require("fs");
const path = require("path");
const cors = require("cors");
const app = express();
app.use(cors());
app.use(express.json());
// 上传目录
const UPLOAD_DIR = path.resolve(__dirname, "upload");
// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR);
}
// 修改multer配置
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const fileHash = req.query.fileHash;
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir);
}
cb(null, chunkDir);
},
filename: (req, file, cb) => {
let query = req.query;
// 使用fileHash和index作为文件名 分片
cb(null, `${query.fileHash}-${query.index}`);
},
});
// 创建一个普通上传中间件来解析字段
const upload = multer({
storage,
});
// 上传分片
app.post("/upload", upload.single("file"), (req, res) => {
res.json({
code: 200,
success: true,
message: "分片上传成功",
});
});
// 合并分片
app.post("/upload/merge", async (req, res) => {
const { fileHash, fileName, total } = req.body;
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}-${fileName}`);
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
try {
// 检查分片是否完整
let chunks = fs.readdirSync(chunkDir);
if (chunks.length !== total) {
return res.status(400).json({
success: false,
message: "分片数量不匹配",
});
}
// 按照索引排序
chunks = chunks.sort((a, b) => {
const aIndex = parseInt(a.split("-").pop());
const bIndex = parseInt(b.split("-").pop());
return aIndex - bIndex;
});
let writeStream = fs.createWriteStream(filePath);
// 合并文件
await Promise.all(
chunks.map((chunk, index) => {
const chunkPath = path.resolve(chunkDir, chunk);
return new Promise((resolve) => {
writeStream.write(fs.readFileSync(chunkPath), () => {
resolve();
});
});
})
);
writeStream.end();
// 删除临时目录
// fs.rmdirSync(chunkDir);
res.json({
code: 200,
success: true,
message: "合并成功",
});
} catch (err) {
res.status(500).json({
code: 500,
success: false,
message: "合并失败",
});
}
});
// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
console.log(`服务器已启动,访问 http://localhost:${PORT}`);
});
服务端主要逻辑:
- 上传分片接口:通过前端传递的参数和文件流,multer 插件将每一段分片存储在同一个hash目录下,并且每一段分片命名上通过 "-" 拼接序列号
- 合并文件接口:通过前端参数,对相应hash目录下的分片进行排序,创建一个文件流,并将分片按顺序写入到这个文件流中,最后返回文件的访问链接
简单分析上述逻辑
1、获取一个唯一hash,单次文件上传对应一个hash(通常后端接口获取)
2、通过slice 方法对File 对象进行分割(分片),设置一个size 大小,每段最多size大小
3、上传所有分片文件,并传递相应参数,如:hash、index(文件顺序)......
4、当所有分片上传成功,合并文件
实际场景
工作当中一般不会这样实现服务端,一般文件存储都会用流行的OSS 存储服务,并不会自己手动去实现这些功能,服务端只需要按要求调用OSS 服务提供的接口即可,即使是分片上传功能,OSS服务也是有提供对应的接口方案,但是,我们应该要了解分片是怎样进行存储,又是怎么合并成一个文件的。
最后
分片上传虽然能提升上传的速度,但也是受限于带宽的,分片处理方案只会更充分利用服务器的带宽,但如果服务器带宽就有限,那么即使是分片上传也提升不了太大上传速度。