一、前言
前几天面试时面试官问我封装的图片上传组件有没有做大文件上传处理,我说我知道大文件切片上传,但是没做,因为文件上传的图片视频不怎么大,几M十几M的,然后对方就抓住我没做大文件上传处理这个点不放,说我缺少深入研究没能做得更全面巴拉巴拉。。。
确实是我研究不够,得,那我就来研究下大文件上传吧
二、大文件上传思路
前端分片上传是将大文件分割成多个较小的片段(分片),分别上传这些片段,最后在服务器端将这些分片合并成完整的文件。这样做可以降低网络不稳定带来的风险,实现断点续传等功能。
- 选择文件:通过input[type="file"]获取用户选择的文件。
- 计算分片:根据设定的chunkSize(比如设为 1MB)计算文件需要分成的总片数totalChunks。
- 循环上传:使用for循环遍历每个分片,通过file.slice方法截取每个分片。
- 构建请求:将每个分片以及分片的序号chunkNumber和总分片数totalChunks一起通过FormData构建请求体。
- 发起请求:使用fetch发送 POST 请求到/upload接口进行上传,并根据响应判断上传是否成功,同时更新上传进度。
- 结果反馈:如果所有分片都上传成功,打印成功信息,发起合并请求;否则,打印错误信息。
- 后端: 获取分片文件并存储,接收到合并请求后合并文件,存储文件或者返回url给前端
三、前端代码 (Vue)
1、创建calculateFileMD5.js
// calculateFileMD5.js
import SparkMD5 from 'spark-md5'; // 安装spark-md5包
export const chunkSize = 1024 * 1024; // 每次读取 1MB
// 计算文件的 MD5
export default function calculateFileMD5(file) {
return new Promise(resolve => {
const spark = new SparkMD5.ArrayBuffer(); // 创建 SparkMD5 对象
const fileSize = file.size; // 文件大小
const chunks = Math.ceil(fileSize / chunkSize); // 总片数
let currentChunk = 0; // 当前片数
const fileReader = new FileReader(); // 创建 FileReader 对象
fileReader.onload = function (e) {
spark.append(e.target.result); // 将读取的块加入 spark-md5
currentChunk++; // 切片数加 1
// 如果还有未处理的块,则继续读取下一个块
if (currentChunk < chunks) {
readNextChunk();
} else {
resolve(spark.end()); // 完成,输出 MD5
}
};
fileReader.onerror = function () {
console.error('文件读取失败');
resolve(null);
};
// 读取下一个块
function readNextChunk() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, fileSize);
fileReader.readAsArrayBuffer(file.slice(start, end));
}
readNextChunk(); // 开始读取第一个 chunk
});
}
2、创建upload.js
import PQueue from 'p-queue'; // 安装p-queue处理请求队列
import { chunkSize } from './calculateFileMD5';
// 检查文件是否存在
export async function checkFileExist(hash) {
const res = await fetch(`http://127.0.0.1/check?hash=${hash}`, {
headers: {
'Content-Type': 'application/json',
},
});
return res.ok && (await res.json()).exist;
}
// 上传分片
export async function uploadChunks(file, hash, onProgress) {
const totalChunks = Math.ceil(file.size / chunkSize); // 片数
const uploadedChunks = []; // 已上传的分片
// 检查已上传的分片
const res = await fetch(`http://127.0.0.1/check-chunks?hash=${hash}`);
const existChunks = res.ok ? await res.json() : [];
// 创建并发队列,控制并发数为6
const uploadQueue = new PQueue({ concurrency: 6 });
// 添加所有分片任务到队列中
for (let i = 0; i < totalChunks; i++) {
// 进度展示
if (existChunks.includes(i)) {
onProgress(i + 1, totalChunks);
continue;
}
const start = i * chunkSize; // 计算当前分片的起始位置和结束位置
const end = Math.min(start + chunkSize, file.size); // 确保分片不超出文件末尾
const chunk = file.slice(start, end); // 创建分片
// 创建 FormData 对象
const formData = new FormData();
formData.append('file', chunk); // 添加分片数据
formData.append('chunkNumber', i); // 添加分片编号
formData.append('totalChunks', totalChunks); // 添加总分片数
formData.append('fileHash', hash); // 添加文件 Hash
// 发送分片上传请求
uploadQueue.add(async () => {
try {
const response = await fetch('http://127.0.0.1/uploadFile', {
method: 'POST',
body: formData, // 使用 FormData
});
// 检查上传结果
if (response.ok) {
uploadedChunks.push(i);
onProgress(uploadedChunks.length, totalChunks);
}
} catch (error) {
console.error(`第 ${i} 个分片上传失败`, error);
}
});
}
// 等待所有分片上传完成
await uploadQueue.onIdle();
return uploadedChunks;
}
// 合并分片
export async function mergeChunks(hash, totalChunks, fileType) {
const res = await fetch('http://127.0.0.1/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ hash, totalChunks, fileType }),
});
return res.ok;
}
三、选择文件,上传分片,发起合并请求
某vue文件:
<template>
<h1>大文件上传</h1>
<div class="card">
<input type="file" @change="handleUpload" />
<div>{{ progress }}</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import calculateFileMD5, { chunkSize } from '@/utils/calculateFileMD5.js';
import { checkFileExist, uploadChunks, mergeChunks } from '@/utils/upload.js';
const progress = ref('');
// 文件上传
async function handleUpload(e) {
const file = e.target?.files?.[0];
if (!file) return;
progress.value = '正在计算文件哈希...';
const fileHash = await calculateFileMD5(file);
progress.value = '检查是否已存在该文件...';
const exist = await checkFileExist(fileHash);
console.log(exist);
if (exist) {
progress.value = '文件已存在,秒传成功!';
return;
}
progress.value = '开始上传分片...';
// 上传进度展示
const onProgress = (uploaded, total) => {
progress.value = `上传进度: ${((uploaded / total) * 100).toFixed(2)}%`;
};
// 调用 uploadChunks 函数,上传当前文件的所有未上传分片,返回值 uploadedChunks 是一个数组,包含本次成功上传的分片编号
const uploadedChunks = await uploadChunks(file, fileHash, onProgress);
// 判断刚刚上传的分片数量是否等于该文件的总分片数。
// file.size / chunkSize 计算出理论上的分片总数,Math.ceil() 是为了向上取整(比如 1.2MB 文件按 1MB 分片,结果为 2 个分片)。
// 如果相等,说明所有分片都已上传完成。
if (uploadedChunks.length === Math.ceil(file.size / chunkSize)) {
const fileType = file.type || 'unknown'; // 获取文件类型,若无则标记为 unknown
const [, typeSub] = fileType.split('/'); // 提取子类型
// 所有分片上传完成后,调用 mergeChunks 接口通知服务器开始合并文件
await mergeChunks(fileHash, uploadedChunks.length, typeSub);
progress.value = '上传并合并完成!';
}
}
</script>
四、node后端代码(express框架)
后端处理:
- 文件存在性检查,若存在用于秒传
- 分片列表检查,用于断点续传
- 接收前端上传得分片
- 合并分片
const express = require('express'); // 引入express,需安装
const path = require('path');
const fs = require('fs');
const fsp = require('fs/promises');
const fse = require('fs-extra'); // 引入fs-extra,需安装
const multiparty = require('multiparty'); // 引入multiparty,需安装
var router = express.Router();
// 大文件分片上传
const CHUNK_DIR = './uploads/chunks/'; // 设定分片目录
const UPLOAD_DIR = './uploads/files/'; // 设定文件目录
// 目录不存在则自动创建
if (!fs.existsSync(CHUNK_DIR)) {
fs.mkdirSync(CHUNK_DIR, { recursive: true });
}
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
// 文件存在性检查
router.get('/check', async (req, res) => {
const { hash } = req.query;
try {
await fsp.access(`${UPLOAD_DIR}${hash}`); // 检查文件是否存在
res.json({ exist: true });
} catch (error) {
res.json({ exist: false });
}
});
// 分片列表检查
router.get('/check-chunks', async (req, res) => {
const { hash } = req.query;
const dir = path.join(CHUNK_DIR, hash);
if (!fs.existsSync(dir)) return res.json([]);
const files = fs.readdirSync(dir);
res.json(files.map(Number));
});
// 上传分片
router.post('/uploadFile', async (req, res) => {
// 创建 multiparty 实例,用于解析请求数据
const form = new multiparty.Form();
form.parse(req, async (err, fields, files) => {
const fileHash = fields.fileHash[0];
const chunkHash = fields.fileHash[0] + '-' + fields.chunkNumber[0];
const dir = path.join(CHUNK_DIR, fileHash);
await fse.ensureDir(dir); // 创建分片目录,如果没有的话
await fse.move(files.file[0].path, path.resolve(dir, chunkHash)); // 移动分片文件到分片目录
res.json({ success: true });
});
});
// 合并分片
router.post('/merge', async (req, res) => {
const { hash, totalChunks, fileType } = req.body;
const chunkDir = path.join(CHUNK_DIR, hash); // 分片目录
const targetPath = path.join(UPLOAD_DIR, `${hash}.${fileType}`); // 存储到UPLOAD_DIR目录下,文件名为hash.fileType
// 创建写入流
const writeStream = fs.createWriteStream(targetPath);
// 读取每个分片并写入写入流
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(chunkDir, `${hash}-${i}`);
const data = fs.readFileSync(chunkPath);
writeStream.write(data);
}
writeStream.end();
// fs.rmSync(chunkDir, { recursive: true }); // 清空分片目录
// 返回合并后的文件URL
const host = req.get('host');
const protocol = req.protocol;
const imageUrl = `${protocol}://${host}/files/${hash}.${fileType}`;
res.json({ merged: true, url: imageUrl });
});
module.exports = router;
四、结果展示
后端文件存储
我选择一个8000KB的图片,按照1M分片,则是8片
结果如下:
浏览器请求
merge结果
五、结语
至此,一个大文件分片上传的前后端流程就完成了,当然还有很多地方可以优化,这个具体应用时再根据实际优化,了解思路后怎么优化你都能得心应手。
渐修顿悟