功能:大文件上传
文章目录
业务背景
- 媒体处理:视频编辑平台、音频处理软件、图像库等需要上传大量媒体文件。
- 数据备份与迁移:企业需要备份或迁移大量数据,包括数据库文件、系统镜像等。
- 内容分发网络:在CDN中上传大文件以便更快地在全球范围内分发。
- 科学与研究:上传大型数据集,例如基因组序列、气象模型数据等。
- 教育和在线学习:上传高质量的教学视频和教材。
- 法律和财务:共享大量的法律文档或财务报表。
面临挑战
- 性能问题:大文件上传可能导致客户端(浏览器)性能下降,特别是在资源有限的设备上。
- 网络不稳定:大文件更有可能在上传过程中遇到网络问题,如断线、超时等。
- 服务器负载:大文件上传会给服务器带来更大的负载,特别是在处理大量此类请求时。
- 用户体验:长时间的上传过程可能导致用户感到不耐烦,影响用户体验。
- 文件完整性和安全性:确保文件在传输过程中不被破坏或篡改,同时保证数据的隐私和安全。
- 断点续传:支持在网络中断后能够继续上传,而不是重新开始。
- 数据处理:大文件需要更复杂的处理流程,例如切片、压缩和解压缩。
- 兼容性和标准化:确保各种浏览器和设备都能顺利完成上传过程。
拖拽文件功能
拖拽API
事件
- dragenter :当拖动的元素或选中的文本进入有效拖放目标时触发。在这里,它用于初始化拖拽进入目标区域的行为。
- dragover :当元素或选中的文本在有效拖放目标上方移动时触发。通常用于阻止默认的处理方式,从而允许放置。
- drop :当拖动的元素或选中的文本在有效拖放目标上被放置时触发。这是处理文件放置逻辑的关键点。
- dragleave :当拖动的元素或选中的文本离开有效拖放目标时触发。可以用于处理元素拖离目标区域的行为。
API
event.preventDefault():阻止事件的默认行为。在拖放事件中,这通常用于阻止浏览器默认的文件打开行为。event.stopPropagation():阻止事件冒泡到父元素。这可以防止嵌套元素的拖放事件影响到外层元素。event.dataTransfer:一个包含拖放操作数据的对象。在drop事件中,它可以用来获取被拖放的文件。files:event.dataTransfer.files是一个包含了所有拖放的文件的FileList对象。可以通过这个对象来访问和处理拖放的文件。
URL.createObjectURL
URL.createObjectURL 是一个非常实用的 Web API,它允许你创建一个指向特定文件对象或 Blob(Binary Large Object)的 URL。这个 URL 可以用于存储用户本地的文件数据,而无需实际上传文件到服务器。
代码实践
拖拽文件
首先搭建一个模板:
js
<template>
<div class="box">
<div class="upload-container" ref="uploadRef">
<el-icon><Upload /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const uploadRef = ref(null);
</script>
<style scoped lang="scss">
.box {
display: flex;
height: 100vh;
flex-direction: column;
justify-content: center;
align-items: center;
}
.upload-container {
width: 10%;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
border: 4px dashed #d3cbcb;
background-color: #c9e7ff;
&:hover {
border-color: #40a9ff;
}
& span {
font-size: 60px;
}
}
</style>
在./useDrag中编写hooks:
js
import { onMounted, onUnmounted, ref, watch } from "vue";
function useDrag(uploadContainerRef: any) {
const prohibitEvent = (e: any) => {
e.preventDefault(); // 阻止浏览器默认行为
e.stopPropagation(); // 阻止事件冒泡
}
// 拖拽事件处理
const handleDrag = (e: any) => {
prohibitEvent(e)
}
// 拖拽文件释放
const handleDrop = (e: any) => {
prohibitEvent(e)
const { files } = e.dataTransfer // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
console.log("🚀 ~ handleDrop ~ files:", files)
}
onMounted(() => {
const uploadContainer = uploadContainerRef.value;
if (uploadContainer) {
// 进入拖放目标
uploadContainer.addEventListener("dragenter", handleDrag);
// 拖放目标上方移动
uploadContainer.addEventListener("dragover", handleDrag);
// 拖放目标上被放置时
uploadContainer.addEventListener("drop", handleDrop);
// 离开有效拖放目标
uploadContainer.addEventListener("dragleave", handleDrag);
}
})
onUnmounted(() => {
const uploadContainer = uploadContainerRef.value;
if (uploadContainer) {
uploadContainer.removeEventListener("dragenter", handleDrag);
uploadContainer.removeEventListener("dragover", handleDrag);
uploadContainer.removeEventListener("drop", handleDrop);
uploadContainer.removeEventListener("dragleave", handleDrag);
}
})
}
export default useDrag;
此时我们尝试拖入文件,可以看到控制台打印出内容:

文件预览
添加存储选择文件变量和存储预览文件:
js
// 存储选择文件
const selectFile = ref<File | null>(null);
// 存储预览文件
const previewFiles = ref<Type_PreviewFile | null>(null);
添加检查文件方法:
js
// 检查文件方法
const checkFile = (files: Array<File>) => {
const file = files[0];
// 判断非空
if (!file) {
ElMessage.error('没有选择任何文件')
return
}
// 限制大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error('文件过大,请选择小于2G的文件')
return
}
// 判断类型
if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
ElMessage.error('文件类型必须是图片或者视频')
return
}
selectFile.value = file
}
监听文件选择:
js
// 监听文件选择
watch(selectFile, (newFile, oldFile) => {
// 清理旧URL
if (oldFile && previewFiles.value?.url) {
URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
}
if (!newFile) return;
// 创建临时 URL
const url = URL.createObjectURL(newFile)
previewFiles.value = { url: url, type: newFile.type }
}, { immediate: false })
在拖拽文件释放方法handleDrop中使用:
js
// 拖拽文件释放
const handleDrop = (e: any) => {
prohibitEvent(e)
const { files } = e.dataTransfer // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
⭕ checkFile(files)
}
最后返回selectFile, previewFiles:
js
return { selectFile, previewFiles }
新增代码:
js
// 存储选择文件
const selectFile = ref<File | null>(null);
// 存储预览文件
const previewFiles = ref<Type_PreviewFile | null>(null);
// 检查文件方法
const checkFile = (files: Array<File>) => {
const file = files[0];
// 判断非空
if (!file) {
ElMessage.error('没有选择任何文件')
return
}
// 限制大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error('文件过大,请选择小于2G的文件')
return
}
// 判断类型
if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
ElMessage.error('文件类型必须是图片或者视频')
return
}
selectFile.value = file
}
// 监听文件选择
watch(selectFile, (newFile, oldFile) => {
// 清理旧URL
if (oldFile && previewFiles.value?.url) {
URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
}
if (!newFile) return;
// 创建临时 URL
const url = URL.createObjectURL(newFile)
previewFiles.value = { url: url, type: newFile.type }
}, { immediate: false })
完整代码:
js
import { onMounted, onUnmounted, ref, watch } from "vue";
import { ElMessage } from 'element-plus'
export const MAX_FILE_SIZE = 1024 * 1024 * 1024 * 2; // 2G
export type Type_PreviewFile = { url: string, type: any }
// 阻止事件方法
const prohibitEvent = (e: any) => {
e.preventDefault(); // 阻止浏览器默认行为
e.stopPropagation(); // 阻止事件冒泡
}
function useDrag(uploadContainerRef: any) {
// 存储选择文件
const selectFile = ref<File | null>(null);
// 存储预览文件
const previewFiles = ref<Type_PreviewFile | null>(null);
// 检查文件方法
const checkFile = (files: Array<File>) => {
const file = files[0];
// 判断非空
if (!file) {
ElMessage.error('没有选择任何文件')
return
}
// 限制大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error('文件过大,请选择小于2G的文件')
return
}
// 判断类型
if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
ElMessage.error('文件类型必须是图片或者视频')
return
}
selectFile.value = file
}
// 监听文件选择
watch(selectFile, (newFile, oldFile) => {
// 清理旧URL
if (oldFile && previewFiles.value?.url) {
URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
}
if (!newFile) return;
// 创建临时 URL
const url = URL.createObjectURL(newFile)
previewFiles.value = { url: url, type: newFile.type }
}, { immediate: false })
// 拖拽事件处理
const handleDrag = (e: any) => {
prohibitEvent(e)
}
// 拖拽文件释放
const handleDrop = (e: any) => {
prohibitEvent(e)
const { files } = e.dataTransfer // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
checkFile(files)
}
onMounted(() => {
const uploadContainer = uploadContainerRef.value;
if (uploadContainer) {
// 进入拖放目标
uploadContainer.addEventListener("dragenter", handleDrag);
// 拖放目标上方移动
uploadContainer.addEventListener("dragover", handleDrag);
// 拖放目标上被放置时
uploadContainer.addEventListener("drop", handleDrop);
// 离开有效拖放目标
uploadContainer.addEventListener("dragleave", handleDrag);
}
})
onUnmounted(() => {
const uploadContainer = uploadContainerRef.value;
if (!uploadContainer) return;
uploadContainer.removeEventListener("dragenter", handleDrag);
uploadContainer.removeEventListener("dragover", handleDrag);
uploadContainer.removeEventListener("drop", handleDrop);
uploadContainer.removeEventListener("dragleave", handleDrag);
})
return { selectFile, previewFiles }
}
export default useDrag;
模板 中使用:
js
<template>
<div class="box">
<template v-if="previewFiles?.url">
<div style="max-width: 600px">
<img v-if="fileInfo?.isImage" :src="previewFiles.url" alt="预览图片" />
<video
v-else-if="fileInfo?.isVideo"
:src="previewFiles.url"
controls
alt="预览视频" />
<audio
v-else-if="fileInfo?.isAudio"
:src="previewFiles.url"
controls
alt="预览音频" />
<iframe
v-else-if="fileInfo?.isPDF"
:src="previewFiles.url"
class="pdf-viewer"
alt="预览PDF" />
<div v-else class="unsupported-file">
暂不支持该文件类型: {{ previewFiles.type }}
</div>
</div>
</template>
<div v-else class="upload-container" ref="uploadRef">
<el-icon><Upload /></el-icon>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import useDrag from "./useDrag";
const uploadRef = ref(null);
const { selectFile, previewFiles } = useDrag(uploadRef);
// 文件预览
const fileInfo = computed(() => {
if (!previewFiles.value?.url) return null;
const { type } = previewFiles.value;
return {
isImage: type?.startsWith("image/"),
isVideo: type?.startsWith("video/"),
isAudio: type?.startsWith("audio/"),
isPDF: type === "application/pdf",
};
});
</script>
分片上传
处理文件的上传,为了提升性能,在上传大文件的时候,可以把一个大文件切成多个小文件,然后并行上传。另外为了以后在实现类似妙传的功能,所有需要对文件进行唯一标识,所以我们需要根据文件的内容生成一个hash值来唯一的这一个文件,文件内容如果一样,就产生的文件名是一样的。
接下来我们直接开始🤗
新增一个上传文件按钮:
js
<div class="box">
<template v-if="previewFiles?.url"></template>
<div v-else class="upload-container" ref="uploadRef"></div>
<el-button
style="margin-top: 20px"
type="primary"
size="default"
@click="handleUpload">
上传文件
</el-button>
</div>
实现上传文件方法handleUpload:
js
const handleUpload = async () => {
if (!selectFile.value) {
ElMessage.error("请选择文件");
return;
}
const filename = await getFileName(selectFile.value);
console.log("🚀 ~ handleUpload ~ filename:", filename);
};
/**
* 获取文件名
* @param file
*/
const getFileName = async (file: any) => {
// 计算此文件的hash值
const fileHash = await calculateFileHash(file);
// 获取文件扩展名
const ext = file.name.split(".").pop();
return `${fileHash}.${ext}`;
};
/**
* 计算文件hash字符串
* @param file
*/
const calculateFileHash = async (file: any) => {
const arrayBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
return bufferToHex(hashBuffer);
};
/**
* 把一个ArrayBuffer转成一个16进制字符串
* @param buffer
*/
const bufferToHex = (buffer: any) => {
return Array.from(new Uint8Array(buffer))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
uploadFile上传文件:
js
const handleUpload = async () => {
//...
const filename = await getFileName(selectFile.value);
await uploadFile(selectFile.value, filename);
};
//...
/**
* 上传文件
* @param file
* @param filename
*/
const uploadFile = async (file, filename) => {
// 将大文件进行切片
const chunks = createFileChunks(file, filename);
console.log("🚀 ~ uploadFile ~ chunks:", chunks);
};
/**
* 创建文件切片
* @param file
* @param filename
*/
const createFileChunks = (file, filename) => {
// 创建切片
let chunks = [];
// 计算一共要切成多少片
let count = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < count; i++) {
let chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
chunks.push({
chunk,
chunkFileName: `${filename}-${i}`,
});
}
return chunks;
};
./useDrag中定义的常量:
js
// 切片分块大小:10M
export const CHUNK_SIZE = 1024 * 1024 * 10;
接下来我们测试一下:

效果刚刚好🤗。
接下来处理并行上传:
js
/**
* 上传文件
* @param file
* @param filename
*/
const uploadFile = async (file, filename) => {
// 将大文件进行切片
const chunks = createFileChunks(file, filename);
// 并行上传
const requestArray = chunks.map(({ chunk, chunkFileName }) => {
return createRequest(filename, chunkFileName, chunk);
});
try {
// 并行上传每个分片
await Promise.all(requestArray);
// 等全部的分片上传完了,会向服务器发送一个合并请求
await axios.get(`/merge/${filename}`);
ElMessage.success("上传成功");
} catch (error) {
ElMessage.error("上传失败");
console.log("上传失败", error);
}
};
/**
* 创建上传请求
* @param filename
* @param chunkFileName
* @param chunk
*/
const createRequest = (filename, chunkFileName, chunk) => {
return axios.post(`/upload/${filename}`, chunk, {
headers: {
// 设置请求头,告诉服务器上传的是二进制字节流数据
"Content-Type": "application/octet-stream",
},
params: {
chunkFileName,
},
});
};
在同级目录下创建一个axios实例,方便后续发送请求:
js
import axios from 'axios';
// 创建 axios 实例
const api = axios.create({
baseURL: 'http://localhost:8080'
});
// 添加响应拦截器
api.interceptors.response.use(
(response) => {
// response响应对象 data, headers
// response.data.success为true表示成功,为false表示失败了
if (response.data && response.data.success) {
return response.data; // 返回响应体,这样的话可以在代码直接获取响应体
} else {
throw new Error(response.data.message || '服务器端错误');
}
},
(error) => {
console.error('错误', error);
throw error;
}
);
export default api;
搭建后端
初始化文件夹:
js
npm init -y
安装依赖:
js
npm install express morgan http-status-codes http-errors cors fs-extra
安装nodemon:
js
npm install --save-dev nodemon
改用nodemon启动:
js
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start":"nodemon index.js"
},
编写index.js:
js
const express = require("express");
const logger = require("morgan");
const { StatusCodes } = require("http-status-codes");
const cors = require("cors");
const fs = require("fs-extra");
const path = require("path");
// 存放上传并合并好的文件
fs.ensureDirSync(path.resolve(__dirname, "public"));
// 存放分片文件的目录
fs.ensureDirSync(path.resolve(__dirname, "temp"));
const app = express();
app.use(logger("dev"));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.resolve(__dirname, "public")));
// 接口
app.post("/upload/:filename", async (req, res) => {
res.json({ success: true });
});
app.get("/merage/:filename", async (req, res, next) => {
res.json({ success: true });
});
// 启动服务器
app.listen(8080, () => {
console.log("服务器启动成功");
});
写入分片文件:
js
const PUBLIC_DIR = path.resolve(__dirname, "public");
const TEMP_DIR = path.resolve(__dirname, "temp");
// 接口
app.post("/upload/:filename", async (req, res) => {
// 通过查询参数获取文件名
const { filename } = req.params;
// 通过查询参数获取分片名
const { chunkFileName } = req.query;
// 创建用户保存此文件的分片的目录
const chunkDir = path.resolve(TEMP_DIR, filename);
// 分片的文件路径
const chunkFilePath = path.resolve(chunkDir, chunkFileName);
// 确保分片目录存在
await fs.ensureDirSync(chunkDir);
// 创建文件可写流
const ws = fs.createWriteStream(chunkFilePath, { flags: "a" });
// 后面会实现暂停操作,如果客户端点击了暂停按钮,会取消上传操作,取消后会在服务器触发请求
req.on("aborted", () => {
ws.close();
});
// 使用管道方式把请求中的请求体流数据写入到文件中
try {
await pipeStream(req, ws);
} catch (error) {
next(error);
}
res.json({ success: true });
});
// 创建管道
const pipeStream = (rs, ws) => {
return new Promise((resolve, reject) => {
// 把可读流中的数据写入可写流
rs.pipe(ws).on("finish", resolve).on("error", reject);
});
};
此时上传文件,temp目录下已经保存了分片文件:

合并分片文件:
js
app.get("/merge/:filename", async (req, res, next) => {
// 通过查询参数获取文件名
const { filename } = req.params;
try {
await mergeChunks(filename);
} catch (error) {
next(error);
}
res.json({ success: true });
});
// 合并分片
const mergeChunks = async (filename) => {
const mergedFilePath = path.resolve(PUBLIC_DIR, filename);
// 获取分片文件的目录
const chunkDir = path.resolve(TEMP_DIR, filename);
// 获取分片文件的列表
const chunkFiles = await fs.readdir(chunkDir);
// 对分片文件进行排序
chunkFiles.sort((a, b) => Number(a.split("-"[1]) - Number(b.split("-")[1])));
try {
// 为了提高性能,可以写一个并行写入
const pipes = chunkFiles.map((chunkFile, index) => {
return pipeStream(
fs.createReadStream(path.resolve(chunkDir, chunkFile), {
autoClose: true,
}),
fs.createWriteStream(mergedFilePath, { start: index * CHUNK_SIZE }),
);
});
// 并发把每一个分片的数据写入到目标文件中
await Promise.all(pipes);
// 删除分片文件
await fs.rmdir(chunkDir, { recursive: true });
} catch (error) {
next(error);
}
};
在useDrag.ts文件中新增重置文件状态方法并暴露出去:
js
// 重置文件状态
const resetFileStatus = () => {
selectFile.value = null
previewFiles.value = null
}
return { selectFile, previewFiles, resetFileStatus }
上传进度
首先定义一个变量:
js
// 上传进度
const uploadProgress = ref({});
在创建上传请求方法中使用axios的onUploadProgress参数:
js
/**
* 创建上传请求
* @param filename
* @param chunkFileName
* @param chunk
*/
const createRequest = (filename, chunkFileName, chunk) => {
// 提取分片索引(假设 chunkFileName 结尾是 -index)
const chunkIndex = parseInt(chunkFileName.split("-").pop());
return api.post(`/upload/${filename}`, chunk, {
headers: {
// 设置请求头,告诉服务器上传的是二进制字节流数据
"Content-Type": "application/octet-stream",
},
params: {
chunkFileName,
},
// 计算上传进度
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
uploadProgress.value[chunkIndex] = progress;
},
});
};
渲染到模板中:
js
</el-button>
<div>
<div v-for="(progress, index) in uploadProgress" :key="index">
{{ "分片 " + index + " : " + progress + "%" }}
</div>
</div>
重置状态:
js
const { selectFile, previewFiles, resetFileStatus } = useDrag(uploadRef);
/**
* 重置所有状态
*/
const resetAllStatus = () => {
resetFileStatus();
uploadProgress.value = {};
};
在上传文件uploadFile 方法中添加:
js
/**
* 上传文件
* @param file
* @param filename
*/
const uploadFile = async (file, filename) => {
//...
try {
//...
resetAllStatus();
} catch (error) {
//...
}
};
总进度:
js
<div>
<div v-if="totalProgredd" style="font-weight: 600; color: red">
{{ "总进度 " + totalProgredd + "%" }}
</div>
<div v-for="(progress, index) in uploadProgress" :key="index">
{{ "分片 " + index + " : " + progress + "%" }}
</div>
</div>
// 计算总进度
const totalProgredd = computed(() => {
// 获取所有进度
const percents = Object.values(uploadProgress.value);
// 计算总进度
const total = percents.reduce((acc, cur) => acc + cur, 0) / percents.length;
return Math.round(total);
});
秒传
了解决网络发生错误的情况,或者用户惦记了暂停又继续的情况,或者此文件服务器上已经存在了,这不需要重复上传。
要想实现妙传的功能,需要服务器提供一个接口,返回已经上传的分片和大小。
加一个verify验证接口:
js
app.get("/verify/:filename", async (req, res, next) => {
const { filename } = req.params;
// 先获取文件在服务器的路径
const filePath = path.resolve(PUBLIC_DIR, filename);
// 判断文件是否在服务器中
const isExist = await fs.pathExists(filePath);
if (isExist) {
res.json({ success: true, needUpload: false });
}
res.json({ success: true, needUpload: true });
});
在uploadFile上传文件方法中添加请求接口校验:
js
const uploadFile = async (file, filename) => {
// 上传前校验
const {needUpload} = await api.get(`/verify/${filename}`);
if(!needUpload){
ElMessage.warning("文件已存在");
return
//...
}
暂停上传
核心功能 :
axios.CancelToken是 Axios 提供的一个机制,允许在请求发出后,根据需求取消该请求。应用场景:特别适用于处理那些"不再需要"的请求,例如:
- 用户导航离开了当前页面。
- 应用程序的逻辑判断认为该请求的结果已无意义。
先来改造一下我们的模板代码:
js
<div>
<el-button
v-if="fileUploadStatus === 'NOT_STARTED'"
style="margin-top: 20px"
type="primary"
size="default"
@click="handleUpload">
上传文件
</el-button>
<el-button
v-if="fileUploadStatus === 'UPLOADING'"
style="margin-top: 20px"
type="warning"
size="default"
@click="handlePause">
暂停上传
</el-button>
<el-button
v-if="fileUploadStatus === 'PAUSED'"
style="margin-top: 20px"
type="success"
size="default"
@click="handleUpload">
恢复上传
</el-button>
</div>
<div class="progressBox" v-if="fileUploadStatus === 'UPLOADING'">
<div v-if="totalProgredd" style="font-weight: 600; color: red">
{{ "总进度 " + totalProgredd + "%" }}
</div>
<div v-for="(progress, index) in uploadProgress" :key="index">
{{ "分片 " + index + " : " + progress + "%" }}
</div>
</div>
.progressBox {
height: 500px;
overflow-y: scroll;
width: auto;
padding: 2dvh;
border-radius: 10px;
position: fixed;
top: 20px;
right: 20px;
background-color: yellow;
}
定义上传文件状态:
js
// 上传状态
const uploadStatus = {
NOT_STARTED: "NOT_STARTED", // 未开始
UPLOADING: "UPLOADING", // 上传中
PAUSED: "PAUSED", // 暂停上传
};
// 文件上传状态
const fileUploadStatus = ref(uploadStatus.NOT_STARTED);
当点击上传的时候,fileUploadStatus上传状态设置为上传中,新增暂停上传方法handlePause:
js
const handleUpload = async () => {
if (!selectFile.value) {
ElMessage.error("请选择文件");
return;
}
const filename = await getFileName(selectFile.value);
fileUploadStatus.value = uploadStatus.UPLOADING;
await uploadFile(selectFile.value, filename);
};
const handlePause = () => {
fileUploadStatus.value = uploadStatus.PAUSED;
};
重置状态时顺带重置文件上传状态:
js
/**
* 重置所有状态
*/
const resetAllStatus = () => {
resetFileStatus();
uploadProgress.value = {};
fileUploadStatus.value = uploadStatus.NOT_STARTED;
};
断点续传
断点续传:已经上传了一部分,我们可以是把已经上传的分片名,以及分片的大小给客户端,客户端可以只要对剩下部分上传即可
改造后台:
js
app.get("/verify/:filename", async (req, res, next) => {
const { filename } = req.params;
// 先获取文件在服务器的路径
const filePath = path.resolve(PUBLIC_DIR, filename);
// 判断文件是否在服务器中
const isExist = await fs.pathExists(filePath);
if (isExist) {
return res.json({ success: true, needUpload: false });
}
const chunksDir = path.resolve(TEMP_DIR, filename);
const existDir = await fs.pathExists(chunksDir);
let uploadChunkList = []; // 存放已经上传分片的对象数组
if (existDir) {
// 读取临时目录里面的所有分片对应的文件
const chunkFileNames = await fs.readdir(chunksDir);
// 读取每个分片文件的文件信息,主要是是它的文件大小,表示已经上传的文件大小
uploadChunkList = await Promise.all(
chunkFileNames.map(async (chunkFileName) => {
const { size } = await fs.stat(path.resolve(chunksDir, chunkFileName));
return { chunkFileName, size };
}),
);
}
res.json({ success: true, needUpload: true, uploadChunkList });
});
百分比进度条调整:
js
// 计算上传进度
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded + start * 100) / progressEvent.total,
);
uploadProgress.value[chunkIndex] = progress;
},
// 如果存在说明分片已经上传过一部分了,或者说已经完全上传完成
if (existingChunk) {
const uploadSize = existingChunk.size;
const remainChunk = chunk.slice(uploadSize);
if (remainChunk.size === 0) {
⭕ uploadProgress.value[chunkIndex] = 100;
return Promise.resolve();
}
//...
}
前端完整代码index.vue:
js
<template>
<div class="box">
<template v-if="previewFiles?.url">
<div style="max-width: 600px">
<img v-if="fileInfo?.isImage" :src="previewFiles.url" alt="预览图片" />
<video
v-else-if="fileInfo?.isVideo"
:src="previewFiles.url"
controls
alt="预览视频" />
<audio
v-else-if="fileInfo?.isAudio"
:src="previewFiles.url"
controls
alt="预览音频" />
<iframe
v-else-if="fileInfo?.isPDF"
:src="previewFiles.url"
class="pdf-viewer"
alt="预览PDF" />
<div v-else class="unsupported-file">
暂不支持该文件类型: {{ previewFiles.type }}
</div>
</div>
</template>
<div v-else class="upload-container" ref="uploadRef">
<el-icon><Upload /></el-icon>
</div>
<div>
<el-button
v-if="fileUploadStatus === 'NOT_STARTED'"
style="margin-top: 20px"
type="primary"
size="default"
@click="handleUpload">
上传文件
</el-button>
<el-button
v-if="fileUploadStatus === 'UPLOADING'"
style="margin-top: 20px"
type="warning"
size="default"
@click="handlePause">
暂停上传
</el-button>
<el-button
v-if="fileUploadStatus === 'PAUSED'"
style="margin-top: 20px"
type="success"
size="default"
@click="handleUpload">
恢复上传
</el-button>
</div>
<div class="progressBox" v-if="fileUploadStatus === 'UPLOADING'">
<div v-if="totalProgredd" style="font-weight: 600; color: red">
{{ "总进度 " + totalProgredd + "%" }}
</div>
<div v-for="(progress, index) in uploadProgress" :key="index">
{{ "分片 " + index + " : " + progress + "%" }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import useDrag, { CHUNK_SIZE } from "./useDrag";
import type { Type_PreviewFile } from "./useDrag";
import { ElMessage } from "element-plus";
import api from "./axios";
import axios from "axios";
const uploadRef = ref(null);
const { selectFile, previewFiles, resetFileStatus } = useDrag(uploadRef);
// 上传进度
const uploadProgress = ref({});
// 上传状态
const uploadStatus = {
NOT_STARTED: "NOT_STARTED", // 未开始
UPLOADING: "UPLOADING", // 上传中
PAUSED: "PAUSED", // 暂停上传
};
// 文件上传状态
const fileUploadStatus = ref(uploadStatus.NOT_STARTED);
// 存放所有上传请求的取消token
const cancelToken = ref();
// 文件预览
const fileInfo = computed(() => {
if (!previewFiles.value?.url) return null;
const { type } = previewFiles.value;
return {
isImage: type?.startsWith("image/"),
isVideo: type?.startsWith("video/"),
isAudio: type?.startsWith("audio/"),
isPDF: type === "application/pdf",
};
});
const handleUpload = async () => {
if (!selectFile.value) {
ElMessage.error("请选择文件");
return;
}
const filename = await getFileName(selectFile.value);
fileUploadStatus.value = uploadStatus.UPLOADING;
await uploadFile(selectFile.value, filename);
};
const handlePause = () => {
fileUploadStatus.value = uploadStatus.PAUSED;
};
/**
* 获取文件名
* @param file
*/
const getFileName = async (file: any) => {
// 计算此文件的hash值
const fileHash = await calculateFileHash(file);
// 获取文件扩展名
const ext = file.name.split(".").pop();
return `${fileHash}.${ext}`;
};
/**
* 计算文件hash字符串
* @param file
*/
const calculateFileHash = async (file: any) => {
const arrayBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
return bufferToHex(hashBuffer);
};
/**
* 把一个ArrayBuffer转成一个16进制字符串
* @param buffer
*/
const bufferToHex = (buffer: any) => {
return Array.from(new Uint8Array(buffer))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
/**
* 上传文件
* @param file
* @param filename
*/
const uploadFile = async (file, filename) => {
// 上传前校验
const { needUpload, uploadChunkList } = await api.get(`/verify/${filename}`);
if (!needUpload) {
ElMessage.warning("文件已存在");
return;
}
// 将大文件进行切片
const chunks = createFileChunks(file, filename);
const newCancelTokens = [];
// 并行上传
const requestArray = chunks.map(({ chunk, chunkFileName }) => {
const cancelToken = axios.CancelToken.source();
newCancelTokens.push(cancelToken);
// 以后往服务器发送的数据就可能是不再是完整的分片数据了
// 判断当前的分片是不是已经上传服务器了
const existingChunk = uploadChunkList.find((uploadChunk) => {
return uploadChunk.chunkFileName == chunkFileName;
});
// 如果存在说明分片已经上传过一部分了,或者说已经完全上传完成
if (existingChunk) {
const uploadSize = existingChunk.size;
const remainChunk = chunk.slice(uploadSize);
if (remainChunk.size === 0) {
uploadProgress.value[chunkIndex] = 100;
return Promise.resolve();
}
// 设置为默认值
uploadProgress.value[chunkIndex] = (uploadSize * 100) / chunk.size;
return createRequest(
filename,
chunkFileName,
remainChunk,
cancelToken,
uploadSize,
);
} else {
return createRequest(filename, chunkFileName, chunk, cancelToken);
}
});
cancelToken.value = newCancelTokens;
try {
// 并行上传每个分片
await Promise.all(requestArray);
// 等全部的分片上传完了,会向服务器发送一个合并请求
await api.get(`/merge/${filename}`);
ElMessage.success("上传成功");
resetAllStatus();
} catch (error) {
// 用户主动暂停上传
if (axios.isCancel(error)) {
ElMessage.warning("上传暂停");
} else {
ElMessage.error("上传失败");
console.log("上传失败", error);
}
}
};
/**
* 创建文件切片
* @param file
* @param filename
*/
const createFileChunks = (file, filename) => {
// 创建切片
let chunks = [];
// 计算一共要切成多少片
let count = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < count; i++) {
let chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
chunks.push({
chunk,
chunkFileName: `${filename}-${i}`,
});
}
return chunks;
};
/**
* 创建上传请求
* @param filename
* @param chunkFileName
* @param chunk
*/
const createRequest = (
filename,
chunkFileName,
chunk,
cancelToken,
start = 0,
) => {
// 提取分片索引(假设 chunkFileName 结尾是 -index)
const chunkIndex = parseInt(chunkFileName.split("-").pop());
return api.post(`/upload/${filename}`, chunk, {
headers: {
// 设置请求头,告诉服务器上传的是二进制字节流数据
"Content-Type": "application/octet-stream",
},
params: {
chunkFileName,
start, // 告诉服务器从哪个位置开始上传
},
// 计算上传进度
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded + start * 100) / progressEvent.total,
);
uploadProgress.value[chunkIndex] = progress;
},
cancelToken: cancelToken.token, // 添加取消token
});
};
// 计算总进度
const totalProgredd = computed(() => {
// 获取所有进度
const percents = Object.values(uploadProgress.value);
// 计算总进度
const total = percents.reduce((acc, cur) => acc + cur, 0) / percents.length;
return Math.round(total);
});
/**
* 重置所有状态
*/
const resetAllStatus = () => {
resetFileStatus();
uploadProgress.value = {};
fileUploadStatus.value = uploadStatus.NOT_STARTED;
};
</script>
<style scoped lang="scss">
.box {
display: flex;
width: 100%;
height: 100vh;
flex-direction: column;
justify-content: center;
align-items: center;
}
.upload-container {
width: 10%;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
border: 4px dashed #d3cbcb;
background-color: #c9e7ff;
&:hover {
border-color: #40a9ff;
}
& span {
font-size: 60px;
}
}
.progressBox {
height: 500px;
overflow-y: scroll;
width: auto;
padding: 2dvh;
border-radius: 10px;
position: fixed;
top: 20px;
right: 20px;
background-color: yellow;
}
</style>
后端完整代码:
js
const express = require("express");
const logger = require("morgan");
const { StatusCodes } = require("http-status-codes");
const cors = require("cors");
const fs = require("fs-extra");
const path = require("path");
const PUBLIC_DIR = path.resolve(__dirname, "public");
const TEMP_DIR = path.resolve(__dirname, "temp");
const CHUNK_SIZE = 1024 * 1024 * 10;
// 存放上传并合并好的文件
fs.ensureDirSync(PUBLIC_DIR);
// 存放分片文件的目录
fs.ensureDirSync(TEMP_DIR);
const app = express();
app.use(logger("dev"));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.resolve(__dirname, "public")));
//------接口-------------
app.post("/upload/:filename", async (req, res, next) => {
// 通过查询参数获取文件名
const { filename } = req.params;
// 通过查询参数获取分片名
const { chunkFileName, start } = req.query;
// 写入文件起始位置
const chunkStart = isNaN(start) ? 0 : parseInt(start, 10);
// 创建用户保存此文件的分片的目录
const chunkDir = path.resolve(TEMP_DIR, filename);
// 分片的文件路径
const chunkFilePath = path.resolve(chunkDir, chunkFileName);
// 确保分片目录存在
await fs.ensureDir(chunkDir);
// 创建文件可写流
const ws = fs.createWriteStream(chunkFilePath, { chunkStart, flags: "a" });
// 后面会实现暂停操作,如果客户端点击了暂停按钮,会取消上传操作,取消后会在服务器触发请求
req.on("aborted", () => {
ws.close();
});
// 使用管道方式把请求中的请求体流数据写入到文件中
try {
await pipeStream(req, ws);
} catch (error) {
return next(error);
}
res.json({ success: true });
});
app.get("/merge/:filename", async (req, res, next) => {
// 通过查询参数获取文件名
const { filename } = req.params;
try {
// 等待合并操作完成
await mergeChunks(filename);
// 合并成功后发送响应
res.json({ success: true });
} catch (error) {
next(error);
}
});
app.get("/verify/:filename", async (req, res, next) => {
const { filename } = req.params;
// 先获取文件在服务器的路径
const filePath = path.resolve(PUBLIC_DIR, filename);
// 判断文件是否在服务器中
const isExist = await fs.pathExists(filePath);
if (isExist) {
return res.json({ success: true, needUpload: false });
}
const chunksDir = path.resolve(TEMP_DIR, filename);
const existDir = await fs.pathExists(chunksDir);
let uploadChunkList = []; // 存放已经上传分片的对象数组
if (existDir) {
// 读取临时目录里面的所有分片对应的文件
const chunkFileNames = await fs.readdir(chunksDir);
// 读取每个分片文件的文件信息,主要是是它的文件大小,表示已经上传的文件大小
uploadChunkList = await Promise.all(
chunkFileNames.map(async (chunkFileName) => {
const { size } = await fs.stat(path.resolve(chunksDir, chunkFileName));
return { chunkFileName, size };
}),
);
}
res.json({ success: true, needUpload: true, uploadChunkList });
});
// 启动服务器
app.listen(8080, () => {
console.log("服务器启动成功");
});
//------方法-------------
// 创建管道
const pipeStream = (rs, ws) => {
return new Promise((resolve, reject) => {
// 把可读流中的数据写入可写流
rs.pipe(ws).on("finish", resolve).on("error", reject);
});
};
// 合并分片
const mergeChunks = async (filename) => {
const mergedFilePath = path.resolve(PUBLIC_DIR, filename);
// 获取分片文件的目录
const chunkDir = path.resolve(TEMP_DIR, filename);
// 获取分片文件的列表
const chunkFiles = await fs.readdir(chunkDir);
// 对分片文件进行排序
chunkFiles.sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
try {
// 为了提高性能,可以写一个并行写入
const pipes = chunkFiles.map((chunkFile, index) => {
return pipeStream(
fs.createReadStream(path.resolve(chunkDir, chunkFile), {
autoClose: true,
}),
fs.createWriteStream(mergedFilePath, { start: index * CHUNK_SIZE }),
);
});
// 并发把每一个分片的数据写入到目标文件中
await Promise.all(pipes);
// 删除分片文件
await fs.rm(chunkDir, { recursive: true, force: true });
} catch (error) {
throw error;
}
};
web workers
要优化大文件上传并利用web worker的优势,你可以讲耗时操作的逻辑一道web worker中,这样就可以防止耗时文件操作阻塞UI线程的响应式:
web workers提供了一种在web应用程序中执行脚本的操作方式,这些操作运行在与主执行线程(通常是UI线程)分离的后台线程中,这意味着web worker允许进行并行计算,而不会阻塞浏览器的用户界面。
基础概念:
- 多线程执行:web workers允许你再浏览器中创建一个独立的线程执行js代码,这有助于处理高计算量或耗时任务,而不会影响页面性能和响应式
- 与主线程分离:worker线程与主线程是完全隔离的,他们有自己的全局上下文,不能直接访问DOM、window对象。所有的数据交换都能通过消息传递进行。
使用方法:
-
创建worker文件(注意要放到静态文件
public/worker.js根目录下):其中包含讲worker线程中运行的代码:jsself.addEventListener("message", async (e) => { }); -
在主线程中使用worker,然后在主线程中创建worker实例,并与之通信:
jsconst fileWorker = ref(new Worker("./worker.ts")); const isCalculatingFileName = ref(false); const handleUpload = async () => { if (!selectFile.value) { ElMessage.error("请选择文件"); return; } fileUploadStatus.value = uploadStatus.UPLOADING; // const filename = await getFileName(selectFile.value); // 改用web worker处理文件名👇 // 向worker发送文件信息,让他帮助计算文件对应的文件名 fileWorker.value.postMessage(selectFile.value); isCalculatingFileName.value = true; // 监听worker消息,接受计算好的文件名 fileWorker.value.onmessage = async (e) => { if (isCalculatingFileName.value) { isCalculatingFileName.value = false; await uploadFile(selectFile.value, e.data); } }; }; -
将原来
index.vue中的计算文件名方法拷贝到worker.js中:jsself.addEventListener("message", async (e) => { // 获取主进程发送的文件 const file = e.data; // 单独开一个进程来计算hash并得到新的文件名 const fileName = await getFileName(file); // 发送文件名给主进程 self.postMessage(fileName); }); /** * 获取文件名 * @param file */ const getFileName = async (file) => { // 计算此文件的hash值 const fileHash = await calculateFileHash(file); // 获取文件扩展名 const ext = file.name.split(".").pop(); return `${fileHash}.${ext}`; }; /** * 计算文件hash字符串 * @param file */ const calculateFileHash = async (file) => { const arrayBuffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); return bufferToHex(hashBuffer); }; /** * 把一个ArrayBuffer转成一个16进制字符串 * @param buffer */ const bufferToHex = (buffer) => { return Array.from(new Uint8Array(buffer)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); }; -
给
上传文件按钮添加一个loading效果:js<span v-if="isCalculatingFileName" style="margin-top: 4px; color: darkgrey; font-size: 12px" >文件解析中... </span>
效果:

重试机制
如果上传失败了,可以自动重试三次:
useDrag.ts中设置重置次数常量:
js
export const MAX_RETRYS = 3;
在uploadFile中使用:
js
/**
* 上传文件
* @param file
* @param filename
*/
const uploadFile = async (file, filename, retry = 0) => {
//...
try {
// 并行上传每个分片
await Promise.all(requestArray);
// 等全部的分片上传完了,会向服务器发送一个合并请求
await api.get(`/merge/${filename}`);
ElMessage.success("上传成功");
resetAllStatus();
} catch (error) {
// 用户主动暂停上传
if (axios.isCancel(error)) {
ElMessage.warning("上传暂停");
} else {
if (retry < MAX_RETRYS) {
ElMessage.error("上传失败,重试中...");
uploadFile(file, filename, retry + 1);
} else {
ElMessage.error("上传失败");
console.log("上传失败", error);
}
}
}
};
实现点击上传
js
<div v-else class="upload-container" ref="uploadRef" @click="clickUpload">
<el-icon><Upload /></el-icon>
</div>
实现点击效果useDrag.ts:
js
// 实现点击上传
const clickUpload = () => {
const uploadContainer = uploadContainerRef.value
uploadContainer.addEventListener("click", () => {
const fileInput = document.createElement("input")
fileInput.type = "file"
fileInput.style.display = "none"
fileInput.addEventListener("change", (e: any) => {
checkFile(e.target.files)
})
document.body.appendChild(fileInput)
fileInput.click()
})
}
return { selectFile, previewFiles, resetFileStatus, clickUpload }
文件校验
为了确保上传过程中文件的传输没有被篡改,可以增加一些校验机制。在本项目中,文件名就是通过hash算法得到的。然后在后端服务器中完文件之后,可以重新计算合并后的文件hash值,和文件中是的hash值进行对比,如果值是一样的,说明肯定内容是正确的。
文件加密
如果使用ssh或者hhtps协议上传,就不需要加密了。
如果使用的是http协议,可以在客户端对分片数据进行加密,在服务器加密,也被称为对称加密。
在浏览器端进行加密。在public/aes.html:
js
<script>
//在浏览器端如何加密
async function encryptChunk(chunk, key){
//iv是中密过程中一个随机值,用于确保就是使用相同的密钥对相同的数据进行加密,每次加密的结果也不一样
//iv是一个12个字节的随机数组为初始化的向量
const algorithm= {
name: 'AES-GCM',
iv:window.crypto.getRandomValues(new Uint8Array(12))
}
const encryptedChunk = await window.subtle.encrypt(algorithm,key,chunk);
return {encryptedChunk,iv:algorithm.iv}
}
async funciton generateKey(){
const key = await window.crypto.subtle.generateKey({
name:'ARS_GCM',
length:256
},true,['encrypt','decrypt'])
return key;
}
const key = generteKey()
const {encryptedChunk,iv} = await encryptChunk(chunk,key)
</script>
完整代码
前端部分
index.vue:
js
<template>
<div class="box">
<template v-if="previewFiles?.url">
<div style="max-width: 600px">
<img v-if="fileInfo?.isImage" :src="previewFiles.url" alt="预览图片" />
<video
v-else-if="fileInfo?.isVideo"
:src="previewFiles.url"
controls
alt="预览视频" />
<audio
v-else-if="fileInfo?.isAudio"
:src="previewFiles.url"
controls
alt="预览音频" />
<iframe
v-else-if="fileInfo?.isPDF"
:src="previewFiles.url"
class="pdf-viewer"
alt="预览PDF" />
<div v-else class="unsupported-file">
暂不支持该文件类型: {{ previewFiles.type }}
</div>
</div>
</template>
<div v-else class="upload-container" ref="uploadRef" @click="clickUpload">
<el-icon><Upload /></el-icon>
</div>
<div>
<el-button
v-if="fileUploadStatus === 'NOT_STARTED'"
style="margin-top: 20px"
type="primary"
size="default"
@click="handleUpload">
上传文件
</el-button>
<el-button
v-if="fileUploadStatus === 'UPLOADING'"
style="margin-top: 20px"
type="warning"
size="default"
@click="handlePause">
暂停上传
</el-button>
<el-button
v-if="fileUploadStatus === 'PAUSED'"
style="margin-top: 20px"
type="success"
size="default"
@click="handleUpload">
恢复上传
</el-button>
</div>
<span
v-if="isCalculatingFileName"
style="margin-top: 4px; color: darkgrey; font-size: 12px"
>文件解析中...
</span>
<div class="progressBox" v-if="fileUploadStatus === 'UPLOADING'">
<div v-if="totalProgredd" style="font-weight: 600; color: red">
{{ "总进度 " + totalProgredd + "%" }}
</div>
<div v-for="(progress, index) in uploadProgress" :key="index">
{{ "分片 " + index + " : " + progress + "%" }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import useDrag, { CHUNK_SIZE, MAX_RETRYS } from "./useDrag";
import type { Type_PreviewFile } from "./useDrag";
import { ElMessage } from "element-plus";
import api from "./axios";
import axios from "axios";
const uploadRef = ref(null);
const { selectFile, previewFiles, resetFileStatus, clickUpload } =
useDrag(uploadRef);
// 上传进度
const uploadProgress = ref({});
// 上传状态
const uploadStatus = {
NOT_STARTED: "NOT_STARTED", // 未开始
UPLOADING: "UPLOADING", // 上传中
PAUSED: "PAUSED", // 暂停上传
};
// 文件上传状态
const fileUploadStatus = ref(uploadStatus.NOT_STARTED);
// 存放所有上传请求的取消token
const cancelToken = ref();
const fileWorker = ref(new Worker("./worker.js"));
const isCalculatingFileName = ref(false);
// 文件预览
const fileInfo = computed(() => {
if (!previewFiles.value?.url) return null;
const { type } = previewFiles.value;
return {
isImage: type?.startsWith("image/"),
isVideo: type?.startsWith("video/"),
isAudio: type?.startsWith("audio/"),
isPDF: type === "application/pdf",
};
});
const handleUpload = async () => {
if (!selectFile.value) {
ElMessage.error("请选择文件");
return;
}
fileUploadStatus.value = uploadStatus.UPLOADING;
// const filename = await getFileName(selectFile.value);
// 改用web worker处理文件名👇
// 向worker发送文件信息,让他帮助计算文件对应的文件名
fileWorker.value.postMessage(selectFile.value);
isCalculatingFileName.value = true;
// 监听worker消息,接受计算好的文件名
fileWorker.value.onmessage = async (e) => {
if (isCalculatingFileName.value) {
isCalculatingFileName.value = false;
await uploadFile(selectFile.value, e.data);
}
};
};
const handlePause = () => {
fileUploadStatus.value = uploadStatus.PAUSED;
};
/**
* 上传文件
* @param file
* @param filename
*/
const uploadFile = async (file, filename, retry = 0) => {
// 上传前校验
const { needUpload, uploadChunkList } = await api.get(`/verify/${filename}`);
if (!needUpload) {
ElMessage.warning("文件已存在");
return;
}
// 将大文件进行切片
const chunks = createFileChunks(file, filename);
const newCancelTokens = [];
// 并行上传
const requestArray = chunks.map(({ chunk, chunkFileName }) => {
const cancelToken = axios.CancelToken.source();
newCancelTokens.push(cancelToken);
// 以后往服务器发送的数据就可能是不再是完整的分片数据了
// 判断当前的分片是不是已经上传服务器了
const existingChunk = uploadChunkList.find((uploadChunk) => {
return uploadChunk.chunkFileName == chunkFileName;
});
// 如果存在说明分片已经上传过一部分了,或者说已经完全上传完成
if (existingChunk) {
const uploadSize = existingChunk.size;
const remainChunk = chunk.slice(uploadSize);
if (remainChunk.size === 0) {
uploadProgress.value[chunkIndex] = 100;
return Promise.resolve();
}
// 设置为默认值
uploadProgress.value[chunkIndex] = (uploadSize * 100) / chunk.size;
return createRequest(
filename,
chunkFileName,
remainChunk,
cancelToken,
uploadSize,
);
} else {
return createRequest(filename, chunkFileName, chunk, cancelToken);
}
});
cancelToken.value = newCancelTokens;
try {
// 并行上传每个分片
await Promise.all(requestArray);
// 等全部的分片上传完了,会向服务器发送一个合并请求
await api.get(`/merge/${filename}`);
ElMessage.success("上传成功");
resetAllStatus();
} catch (error) {
// 用户主动暂停上传
if (axios.isCancel(error)) {
ElMessage.warning("上传暂停");
} else {
if (retry < MAX_RETRYS) {
ElMessage.error("上传失败,重试中...");
uploadFile(file, filename, retry + 1);
} else {
ElMessage.error("上传失败");
console.log("上传失败", error);
}
}
}
};
/**
* 创建文件切片
* @param file
* @param filename
*/
const createFileChunks = (file, filename) => {
// 创建切片
let chunks = [];
// 计算一共要切成多少片
let count = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < count; i++) {
let chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
chunks.push({
chunk,
chunkFileName: `${filename}-${i}`,
});
}
return chunks;
};
/**
* 创建上传请求
* @param filename
* @param chunkFileName
* @param chunk
*/
const createRequest = (
filename,
chunkFileName,
chunk,
cancelToken,
start = 0,
) => {
// 提取分片索引(假设 chunkFileName 结尾是 -index)
const chunkIndex = parseInt(chunkFileName.split("-").pop());
return api.post(`/upload/${filename}`, chunk, {
headers: {
// 设置请求头,告诉服务器上传的是二进制字节流数据
"Content-Type": "application/octet-stream",
},
params: {
chunkFileName,
start, // 告诉服务器从哪个位置开始上传
},
// 计算上传进度
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded + start * 100) / progressEvent.total,
);
uploadProgress.value[chunkIndex] = progress;
},
cancelToken: cancelToken.token, // 添加取消token
});
};
// 计算总进度
const totalProgredd = computed(() => {
// 获取所有进度
const percents = Object.values(uploadProgress.value);
// 计算总进度
const total = percents.reduce((acc, cur) => acc + cur, 0) / percents.length;
return Math.round(total);
});
/**
* 重置所有状态
*/
const resetAllStatus = () => {
resetFileStatus();
uploadProgress.value = {};
fileUploadStatus.value = uploadStatus.NOT_STARTED;
};
</script>
<style scoped lang="scss">
.box {
display: flex;
width: 100%;
height: 100vh;
flex-direction: column;
justify-content: center;
align-items: center;
}
.upload-container {
width: 10%;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
border: 4px dashed #d3cbcb;
background-color: #c9e7ff;
cursor: pointer;
&:hover {
border-color: #40a9ff;
}
& span {
font-size: 60px;
}
}
.progressBox {
height: 500px;
overflow-y: scroll;
width: auto;
padding: 2dvh;
border-radius: 10px;
position: fixed;
top: 20px;
right: 20px;
background-color: yellow;
}
</style>
useDrag.ts:
js
import { onMounted, onUnmounted, ref, watch } from "vue";
import { ElMessage } from 'element-plus'
// 限制最大上传:2G
export const MAX_FILE_SIZE = 1024 * 1024 * 1024 * 2;
// 切片分块大小:10M
export const CHUNK_SIZE = 1024 * 1024 * 10;
export type Type_PreviewFile = { url: string, type: any }
export const MAX_RETRYS = 3;
// 阻止事件方法
const prohibitEvent = (e: any) => {
e.preventDefault(); // 阻止浏览器默认行为
e.stopPropagation(); // 阻止事件冒泡
}
function useDrag(uploadContainerRef: any) {
// 存储选择文件
const selectFile = ref<File | null>(null);
// 存储预览文件
const previewFiles = ref<Type_PreviewFile | null>(null);
// 检查文件方法
const checkFile = (files: Array<File>) => {
const file = files[0];
// 判断非空
if (!file) {
ElMessage.error('没有选择任何文件')
return
}
// 限制大小
if (file.size > MAX_FILE_SIZE) {
ElMessage.error('文件过大,请选择小于2G的文件')
return
}
// 判断类型
if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
ElMessage.error('文件类型必须是图片或者视频')
return
}
selectFile.value = file
}
// 监听文件选择
watch(selectFile, (newFile, oldFile) => {
// 清理旧URL
if (oldFile && previewFiles.value?.url) {
URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
}
if (!newFile) return;
// 创建临时 URL
const url = URL.createObjectURL(newFile)
previewFiles.value = { url: url, type: newFile.type }
}, { immediate: false })
// 拖拽事件处理
const handleDrag = (e: any) => {
prohibitEvent(e)
}
// 拖拽文件释放
const handleDrop = (e: any) => {
prohibitEvent(e)
const { files } = e.dataTransfer // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
checkFile(files)
}
onMounted(() => {
const uploadContainer = uploadContainerRef.value;
if (uploadContainer) {
// 进入拖放目标
uploadContainer.addEventListener("dragenter", handleDrag);
// 拖放目标上方移动
uploadContainer.addEventListener("dragover", handleDrag);
// 拖放目标上被放置时
uploadContainer.addEventListener("drop", handleDrop);
// 离开有效拖放目标
uploadContainer.addEventListener("dragleave", handleDrag);
}
})
onUnmounted(() => {
const uploadContainer = uploadContainerRef.value;
if (!uploadContainer) return;
uploadContainer.removeEventListener("dragenter", handleDrag);
uploadContainer.removeEventListener("dragover", handleDrag);
uploadContainer.removeEventListener("drop", handleDrop);
uploadContainer.removeEventListener("dragleave", handleDrag);
})
// 重置文件状态
const resetFileStatus = () => {
selectFile.value = null
previewFiles.value = null
}
// 实现点击上传
const clickUpload = () => {
const uploadContainer = uploadContainerRef.value
uploadContainer.addEventListener("click", () => {
const fileInput = document.createElement("input")
fileInput.type = "file"
fileInput.style.display = "none"
fileInput.addEventListener("change", (e: any) => {
checkFile(e.target.files)
})
document.body.appendChild(fileInput)
fileInput.click()
})
}
return { selectFile, previewFiles, resetFileStatus, clickUpload }
}
export default useDrag;
axios.ts:
js
import axios from 'axios';
// 创建 axios 实例
const api = axios.create({
baseURL: 'http://localhost:8080'
});
// 添加响应拦截器
api.interceptors.response.use(
(response) => {
// response响应对象 data, headers
// response.data.success为true表示成功,为false表示失败了
if (response.data && response.data.success) {
return response.data; // 返回响应体,这样的话可以在代码直接获取响应体
} else {
throw new Error(response.data.message || '服务器端错误');
}
},
(error) => {
console.error('错误', error);
throw error;
}
);
export default api;
worker.js:
js
self.addEventListener("message", async (e) => {
// 获取主进程发送的文件
const file = e.data;
// 单独开一个进程来计算hash并得到新的文件名
const fileName = await getFileName(file);
// 发送文件名给主进程
self.postMessage(fileName);
});
/**
* 获取文件名
* @param file
*/
const getFileName = async (file) => {
// 计算此文件的hash值
const fileHash = await calculateFileHash(file);
// 获取文件扩展名
const ext = file.name.split(".").pop();
return `${fileHash}.${ext}`;
};
/**
* 计算文件hash字符串
* @param file
*/
const calculateFileHash = async (file) => {
const arrayBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
return bufferToHex(hashBuffer);
};
/**
* 把一个ArrayBuffer转成一个16进制字符串
* @param buffer
*/
const bufferToHex = (buffer) => {
return Array.from(new Uint8Array(buffer))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
后端部分:
js
const express = require("express");
const logger = require("morgan");
const { StatusCodes } = require("http-status-codes");
const cors = require("cors");
const fs = require("fs-extra");
const path = require("path");
const PUBLIC_DIR = path.resolve(__dirname, "public");
const TEMP_DIR = path.resolve(__dirname, "temp");
const CHUNK_SIZE = 1024 * 1024 * 10;
// 存放上传并合并好的文件
fs.ensureDirSync(PUBLIC_DIR);
// 存放分片文件的目录
fs.ensureDirSync(TEMP_DIR);
const app = express();
app.use(logger("dev"));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.resolve(__dirname, "public")));
//------接口-------------
app.post("/upload/:filename", async (req, res, next) => {
// 通过查询参数获取文件名
const { filename } = req.params;
// 通过查询参数获取分片名
const { chunkFileName, start } = req.query;
// 写入文件起始位置
const chunkStart = isNaN(start) ? 0 : parseInt(start, 10);
// 创建用户保存此文件的分片的目录
const chunkDir = path.resolve(TEMP_DIR, filename);
// 分片的文件路径
const chunkFilePath = path.resolve(chunkDir, chunkFileName);
// 确保分片目录存在
await fs.ensureDir(chunkDir);
// 创建文件可写流
const ws = fs.createWriteStream(chunkFilePath, { chunkStart, flags: "a" });
// 后面会实现暂停操作,如果客户端点击了暂停按钮,会取消上传操作,取消后会在服务器触发请求
req.on("aborted", () => {
ws.close();
});
// 使用管道方式把请求中的请求体流数据写入到文件中
try {
await pipeStream(req, ws);
} catch (error) {
return next(error);
}
res.json({ success: true });
});
app.get("/merge/:filename", async (req, res, next) => {
// 通过查询参数获取文件名
const { filename } = req.params;
try {
// 等待合并操作完成
await mergeChunks(filename);
// 合并成功后发送响应
res.json({ success: true });
} catch (error) {
next(error);
}
});
app.get("/verify/:filename", async (req, res, next) => {
const { filename } = req.params;
// 先获取文件在服务器的路径
const filePath = path.resolve(PUBLIC_DIR, filename);
// 判断文件是否在服务器中
const isExist = await fs.pathExists(filePath);
if (isExist) {
return res.json({ success: true, needUpload: false });
}
const chunksDir = path.resolve(TEMP_DIR, filename);
const existDir = await fs.pathExists(chunksDir);
let uploadChunkList = []; // 存放已经上传分片的对象数组
if (existDir) {
// 读取临时目录里面的所有分片对应的文件
const chunkFileNames = await fs.readdir(chunksDir);
// 读取每个分片文件的文件信息,主要是是它的文件大小,表示已经上传的文件大小
uploadChunkList = await Promise.all(
chunkFileNames.map(async (chunkFileName) => {
const { size } = await fs.stat(path.resolve(chunksDir, chunkFileName));
return { chunkFileName, size };
}),
);
}
res.json({ success: true, needUpload: true, uploadChunkList });
});
// 启动服务器
app.listen(8080, () => {
console.log("服务器启动成功");
});
//------方法-------------
// 创建管道
const pipeStream = (rs, ws) => {
return new Promise((resolve, reject) => {
// 把可读流中的数据写入可写流
rs.pipe(ws).on("finish", resolve).on("error", reject);
});
};
// 合并分片
const mergeChunks = async (filename) => {
const mergedFilePath = path.resolve(PUBLIC_DIR, filename);
// 获取分片文件的目录
const chunkDir = path.resolve(TEMP_DIR, filename);
// 获取分片文件的列表
const chunkFiles = await fs.readdir(chunkDir);
// 对分片文件进行排序
chunkFiles.sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
try {
// 为了提高性能,可以写一个并行写入
const pipes = chunkFiles.map((chunkFile, index) => {
return pipeStream(
fs.createReadStream(path.resolve(chunkDir, chunkFile), {
autoClose: true,
}),
fs.createWriteStream(mergedFilePath, { start: index * CHUNK_SIZE }),
);
});
// 并发把每一个分片的数据写入到目标文件中
await Promise.all(pipes);
// 删除分片文件
await fs.rm(chunkDir, { recursive: true, force: true });
} catch (error) {
throw error;
}
};