Vue + SpringBoot 手把手实现大文件切片与分片上传


浅风
原创
发布时间: 2026-01-29 18:54:05 | 阅读数 0收藏数 0评论数 0
封面
本文详细介绍了如何基于 Vue + SpringBoot 实现大文件的切片与分片上传功能。通过前端文件切片与 MD5 校验,保证每个分片唯一且完整;后端提供分片接收与合并接口,实现高效存储和整合。文章同时讲解了 并发上传和重试机制,确保上传过程稳定可靠,并演示如何在前端显示上传进度,提高用户体验。适合需要处理大文件上传的开发者,提供从零到实战的完整实现方案,让你快速掌握大文件上传的前端与后端全流程。
1

分片上传接口

如图所示这里就是一个最普通的文件上传接口,代码就不给大家放了因为大家的文件上传方法也都不一样,唯一需要注意的就是可以建立一个temp包用于存放临时文件,然后再这个包下面进行一个文件的hash分包 如图3的路径大家可以看一下 然后文件名可以是hash+index+后缀

2

合并分片接口

大概原理就是获取分片的文件列表依次循环写入到文件中,然后把这个完整的文件改成相关后缀并移动到最终的路径下


以下是service 的代码 controller进行调用即可

/**
* 文件合并
*
* @param fileMergeDTO 文件合并 DTO
* @return 返回文件存储信息
*/
@SneakyThrows
@Override
public FileStorageVo fileMerge(FileMergeDTO fileMergeDTO) {
// 基础文件存储路径
String systemPath = fileProperties.getFileStoragePath();
String filePath = FileUtils.getTypeDatePath(FileTypeConstants.TEMP);
// 文件夹路径 记得和分片路径统一
File chunkDir = new File(systemPath, filePath + "/" + fileMergeDTO.getFileHash());
// 生成临时存放完整文件的路径
File targetFile = new File(chunkDir, fileMergeDTO.getFileName());

// 合并分片文件
try (FileOutputStream fos = new FileOutputStream(targetFile);
FileChannel out = fos.getChannel()) {
// 循环写入到完整文件中
for (int i = 0; i < fileMergeDTO.getChunkTotal(); i++) {
File partFile = new File(chunkDir, fileMergeDTO.getFileHash() + "_" + i + ".part");
try (FileInputStream fis = new FileInputStream(partFile);
FileChannel in = fis.getChannel()) {
in.transferTo(0, in.size(), out);
}
}
} catch (IOException e) {
throw new RuntimeException("文件合并失败", e);
}

// 生成新文件名和最终存储路径
String newFileName = FileUtils.newFileName(fileMergeDTO.getFileName());
String fileStoragePath = fileProperties.getFileStoragePath(targetFile);
File finalFile = new File(filePropertiesPath + fileStoragePath, newFileName);
FileUtils.parentFilExists(finalFile.getCanonicalFile());

// 移动合并后的文件到最终路径
Files.move(targetFile.toPath(), finalFile.toPath());

// 清理分片文件
File[] files = chunkDir.listFiles();
if (files != null) {
for (File file : files) {
try {
Files.deleteIfExists(file.toPath());
} catch (IOException ignored) {}
}
}
Files.deleteIfExists(chunkDir.toPath());

// 6. 返回文件存储信息
return new FileStorageVo();
}



3

前端api接口


在api文件夹下面写入以下代码 如下


/**
* 分片上传
* @param fileChunk 文件分片信息
* @param onProgress
* @param signal 取消功能
*/
export function uploadChunk(fileChunk: FileChunkModel, signal: AbortSignal) {
const formData = new FormData()
formData.append('file', fileChunk.file)
formData.append('fileHash', fileChunk.fileHash)
formData.append('chunkIndex', String(fileChunk.chunkIndex))
return request({
url: '/fileStorage/uploadChunk',
method: 'post',
data: formData,
signal: signal
})
}

/**
* 文件合并
* @param fileMergeModel 文件合并的实体
*/
export function fileMerge(fileMergeModel: FileMergeModel) {
return request({
url: '/fileStorage/fileMerge',
method: 'post',
data: fileMergeModel,
})
}
4

web worker计算hash

首先安装 spark-md5 如果是ts还需要安装 @types/spark-md5

pnpm add spark-md5
pnpm add @types/spark-md5


编写工具类


大家可以建一个文件夹叫workers 然后在里面新建一个文件我这边叫fileHashWorker.ts如图3所示 然后内容入戏

import SparkMD5 from 'spark-md5'


/**
* Worker 接收主线程消息
*/
self.onmessage = async (e: MessageEvent) => {
const {file, full,chunkSize} = e.data
// SparkMD5 实例,使用 ArrayBuffer 模式,适合处理 Blob/File
const spark = new SparkMD5.ArrayBuffer()

// 文件总大小(字节)
const size = file.size

if (full) {
// 全量 hash 计算
const total = Math.ceil(size / chunkSize)
// 按顺序切片,保证 hash 结果稳定
for (let i = 0; i < total; i++) {
const blob = file.slice(
i * chunkSize,
(i + 1) * chunkSize
)

// 读取当前分片为 ArrayBuffer 并追加
spark.append(await blob.arrayBuffer())
}
} else {
// 抽样 hash
const offsets = [
// 文件开头
0,
// 文件中间
Math.floor(size / 2),
// 文件结尾
size - chunkSize
]

for (const offset of offsets) {
// 防止文件小于 chunkSize 时出现负数
if (offset < 0) continue;
spark.append(
await file
.slice(offset, offset + chunkSize)
.arrayBuffer()
)
}
}

// 计算完成后,将最终 32 位 md5 字符串 hash 值发送回主线程
self.postMessage(spark.end())
}


5

文件工具类

在utils/fileUtil.ts 里面写入以下代码 这个代码是用于生成hash以及文件相关的工具类的

new URL('../workers/fileHashWorker.ts', import.meta.url),这个记得和你webworker的工具路径一样


/**
* 获取文件大小
* @param size
*/
export const getSize = (size: number) => {
let resultSize = "";
if (size / GB >= 1) {
//如果当前Byte的值大于等于1GB
resultSize = (size / GB).toFixed(2) + "GB ";
} else if (size / MB >= 1) {
//如果当前Byte的值大于等于1MB
resultSize = (size / MB).toFixed(2) + "MB ";
} else if (size / KB >= 1) {
//如果当前Byte的值大于等于1KB
resultSize = (size / KB).toFixed(2) + "KB ";
} else {
// 如果不大于1024B 就按照B来算
resultSize = size + "B";
}
return resultSize;
}

/**
* 计算文件的 MD5 指纹(唯一标识)
* 采用 Web Worker 在后台线程进行计算
* @returns {Promise<string>} - 返回一个 Promise,解析后得到文件的 MD5 字符串
*/
export const calcMD5 = (file: File, chunkSize: number): Promise<string> => {
return new Promise((resolve, reject) => {
// 初始化 Web Worker,指向 worker 脚本路径
const worker = new Worker(
new URL('../workers/fileHashWorker.ts', import.meta.url),
{type: 'module'}
)

// 向 Worker 发送计算请求:传递文件引用和分片大小
worker.postMessage({file, chunkSize: chunkSize})

// 监听 Worker 返回的结果
worker.onmessage = (e) => {
const hash = e.data
resolve(hash)

// 计算完成后必须及时销毁 Worker,释放系统资源
worker.terminate()
}

// 补充:建议增加错误捕获逻辑,防止计算失败导致 Promise 挂起
worker.onerror = (err) => {
worker.terminate()
reject(new Error("Worker 计算 MD5 出错: " + err.message))
}
})
}


6

异步工具类

在utils/asyncTask.ts 里面写入以下代码 这个代码是相关的异步并发 以及异常重试的


/**
* 并发执行器:直接按数量控制任务
* @param total - 任务总数
* @param maxConcurrent - 最大并发数
* @param workerFn - 执行函数,接收当前的索引 index
*/
export const runConcurrently = async (
total: number,
maxConcurrent: number,
workerFn: (index: number) => Promise<void>
) => {
// 维护一个全局指针,记录当前该处理哪一个索引了
let currentIndex = 0;

const workers = Array.from(
{ length: Math.min(maxConcurrent, total) },
async () => {
// 每个 Worker 只要发现还没处理完,就领任务
while (currentIndex < total) {
// 关键点:取出当前索引,并将指针后移(原子操作模拟)
const index = currentIndex++;

// 执行具体的上传逻辑
await workerFn(index);
}
}
);

return Promise.all(workers);
};


/**
* 带有指数退避逻辑的重试包装器
* @param task 待执行的异步任务
* @param maxRetries 最大重试次数
* @param onRetry 重试时的回调(可选,用于记录重试次数)
*/
export const withRetry = async <T>(
task: () => Promise<T>,
maxRetries: number,
onRetry?: (currentCount: number) => void
): Promise<T> => {
let lastError: any;

for (let i = 0; i < maxRetries; i++) {
try {
return await task();
} catch (err: any) {
// 用户取消则不重试直接抛出
if (err.name === 'AbortError') throw err;

lastError = err;
const currentRetry = i + 1;

if (currentRetry < maxRetries) {
onRetry?.(currentRetry);
}
}
}
throw lastError;
};


7

vue页面以及整体代码


vue的完整代码如下 相当于循环上传然后再合并

<script setup lang="ts">
import {reactive} from 'vue'
import {uploadChunk, fileMerge} from '~/api/fileStorage/fileStorage'
import { withRetry, runConcurrently} from "~/utils/asyncTask"
import {getSize,calcMD5} from "~/utils/FileUtil";

// 每个分片大小
const CHUNK_SIZE = 5 * 1024 * 1024

// 最大并发分片上传数
const MAX_CONCURRENT = 3

// 分片上传失败重试次数
const MAX_RETRIES = 3

interface FileItem {
// 文件名
fileName: string
// 文件总大小
totalValue: number
// 已上传大小
bufferValue: number
// 每个分片的控制器,用于取消
controllers: AbortController[]
// 文件是否上传完成
isFinished: boolean
// 每个分片的上传状态
chunkStatus?: boolean[]
// 上传错误信息
error?: string
// 文件 hash
fileHash?: string
// 分片重试次数
retryCount: Map<number, number>
}

// 当前上传文件列表
const files = reactive<FileItem[]>([])

/**
* 合并分片
* @param item 上传的文件对象
* @param totalChunks 分片总数
*/
const handleMerge = async (item: FileItem, totalChunks: number) => {
// 如果文件已经取消(不在 files 数组中),直接返回
if (!files.includes(item)) return

try {
const res = await fileMerge({
fileHash: item.fileHash!,
fileName: item.fileName,
chunkTotal: totalChunks
})

if (res.code === 200) {
// 合并成功,标记文件完成
item.isFinished = true
item.bufferValue = item.totalValue
} else {
// 合并失败,记录错误
item.error = `合并失败: ${res.message || '未知错误'}`
}
} catch (err: any) {
// 如果请求被取消,不显示错误
if (err.name === 'AbortError') return
item.error = `请求合并接口异常: ${err.message}`
}
}

/**
* 上传单个文件(分片上传)
* @param item 文件对象
* @param file File 对象
*/
const uploadFile = async (item: FileItem, file: File) => {
// 计算文件 MD5,用于秒传或后台分片校验
item.fileHash = await calcMD5(file, CHUNK_SIZE)
// 计算总分片数
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
// 初始化每个分片状态
item.chunkStatus = Array(totalChunks).fill(false)

/**
* 执行单个分片上传任务
* @param index 分片索引
*/
const executeTask = async (index: number) => {
// 如果文件已取消(不在 files 中),直接返回
if (!files.includes(item)) return

const start = index * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)

await withRetry(
async () => {
// 如果文件已取消(不在 files 中),直接返回
if (!files.includes(item)) return
// 创建分片请求控制器,方便取消
const controller = new AbortController()
item.controllers.push(controller)

// 调用上传接口
const res = await uploadChunk(
{
file: file.slice(start, end),
fileHash: item.fileHash!,
chunkIndex: index,
},
controller.signal
)

if (res.code === 200) {
// 上传成功,更新分片状态和进度
item.chunkStatus![index] = true
item.bufferValue += (end - start)
}
},
MAX_RETRIES,
(count) => {
// 更新每个分片的重试次数
item.retryCount.set(index, count)
}
)
}

// 并发上传所有分片
await runConcurrently(totalChunks, MAX_CONCURRENT, executeTask)

// 如果文件已取消,跳过合并
if (!files.includes(item)) return

// 上传完成后触发合并
await handleMerge(item, totalChunks)
}

/**
* 文件选择处理
* @param e 事件对象
*/
const onSelect = (e: Event) => {
const input = e.target as HTMLInputElement
if (!input.files) return

for (const file of Array.from(input.files)) {
// 创建文件对象
const item = reactive<FileItem>({
fileName: file.name,
totalValue: file.size,
bufferValue: 0,
controllers: [],
isFinished: false,
retryCount: new Map()
})

// 加入文件列表
files.push(item)

// 上传文件
uploadFile(item, file)
}

// 清空 input,方便再次选择同一文件
input.value = ''
}

// ==================== 取消 ====================
/**
* 取消上传
* @param item 文件对象
*/
const cancel = (item: FileItem) => {
// 中止所有正在上传的分片请求
item.controllers.forEach(c => c.abort())
item.controllers = []

// 从文件列表中移除,后续任务会自动跳过
const index = files.indexOf(item)
if (index !== -1) files.splice(index, 1)
}
</script>

<template>
<div class="upload-container">
<input type="file" multiple @change="onSelect" class="file-input"/>

<div v-for="f in files" :key="f.fileName + f.totalValue" class="item">
<div class="label">
<span class="filename">{{ f.fileName }}</span>
<span class="size">{{ getSize(f.bufferValue) }} / {{ getSize(f.totalValue) }}</span>
</div>

<div class="bar">
<div
class="fill"
:style="{ width: (f.bufferValue / f.totalValue * 100) + '%' }"
:class="{ error: f.error }"
></div>
</div>

<div class="chunk-grid" v-if="f.chunkStatus && f.chunkStatus.length < 100">
<span
v-for="(done, idx) in f.chunkStatus"
:key="idx"
:class="['chunk', { uploaded: done }]"
:title="`分片 ${idx}`"
></span>
</div>

<div v-if="f.error" class="error-message">{{ f.error }}</div>

<div class="actions">
<span v-if="f.isFinished" class="done">✓ 上传完成</span>
<button v-else @click="cancel(f)" class="btn-cancel">取消</button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.upload-container {
max-width: 600px;
margin: 20px auto;
}

.file-input {
margin-bottom: 20px;
width: 100%;
padding: 20px;
border: 2px dashed #ccc;
border-radius: 8px;
cursor: pointer;
}

.item {
border: 1px solid #eee;
padding: 15px;
margin-bottom: 15px;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}

.label {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
color: #333;
}

.bar {
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}

.fill {
height: 100%;
background: #42b983;
transition: width 0.3s ease;
}

.fill.error {
background: #f56c6c;
}

.chunk-grid {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 10px;
}

.chunk {
width: 8px;
height: 8px;
background: #eee;
border-radius: 1px;
}

.chunk.uploaded {
background: #42b983;
}

.error-message {
color: #f56c6c;
font-size: 12px;
margin-top: 10px;
padding: 8px;
background: #fff5f5;
border-radius: 4px;
}

.btn-cancel {
background: #ff4d4f;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}

.done {
color: #52c41a;
font-weight: bold;
}

</style>


8

效果演示

演示效果看视频 绿色小方块代表每一个分片,后端再判断hash是否存在存在就跳过就是秒传的功能

阅读记录0
点赞0
收藏0
禁止 本文未经作者允许授权,禁止转载
猜你喜欢
评论/提问(已发布 0 条)
评论 评论
收藏 收藏
分享 分享
pdf下载 下载
pdf下载 举报