大文件上传的稳定性问题通常比想象中更复杂:请求体过大可能被网关直接拦截,传输过程稍有抖动就会触发超时,而用户看到的进度条完全失去参考价值。解决这个问题的核心思路是把文件拆分成若干固定大小的分片,借助浏览器原生File接口的slice能力逐片发送,服务端记录已接收分片并在全部到达后合并。Vue 3的组合式API很适合将这套流程封装成可复用的上传组件,本文会依次说明分片切割、文件哈希、断点续传和进度聚合的实现细节。

一、文件分片与并发上传的基本实现
分片大小的选择直接影响上传效率和稳定性。单片体积太小会导致请求数量暴增,单片体积太大则失去了分片上传对抗网络波动的意义。实际项目中通常将单片控制在1MB到10MB之间,视频、镜像等大文件可以选择5MB左右,这样既能降低单次请求失败的影响范围,也不会让请求总数过于夸张。下面的代码展示了如何利用File对象的slice方法将文件切成固定大小的块,并给每个分片设置索引,方便后续合并时恢复顺序。
function createChunks(file, chunkSize = 5 * 1024 * 1024) {
const chunks = [];
let offset = 0;
let index = 0;
while (offset < file.size) {
const end = Math.min(offset + chunkSize, file.size);
chunks.push({
index,
blob: file.slice(offset, end)
});
offset = end;
index += 1;
}
return chunks;
}
得到分片数组之后,下一步要考虑并发控制。浏览器对同一域名的并发连接数有限制,如果一次性把所有分片都发出去,不仅会抢占带宽,还容易触发服务端限流。更稳妥的做法是维护一个固定大小的并发池,例如同时只上传3个分片,某个分片完成后再从队列中取下一个。这样做既保证了上传速度,又避免了大文件场景下的内存占用过高。下面这段代码实现了一个简单的并发执行器,poolLimit可以用来调节并发数量。
async function uploadChunksWithPool(chunks, uploadFn, poolLimit = 3) {
const results = new Array(chunks.length);
let cursor = 0;
const workers = Array.from({ length: poolLimit }, async () => {
while (cursor < chunks.length) {
const current = cursor;
cursor += 1;
results[current] = await uploadFn(chunks[current]);
}
});
await Promise.all(workers);
return results;
}
每个分片请求需要携带足够的信息供服务端识别和暂存,至少包括文件哈希、分片索引、总片数和分片数据本身。前端使用FormData把Blob对象和这些字段拼装起来,后端收到后先写临时文件,再更新该文件的已接收分片列表。等所有分片都到达后,后端再按照索引顺序合并成完整文件。这里要特别注意索引必须从0开始连续递增,否则后续合并时很容易出现顺序错乱。
二、文件哈希与断点续传的落地方式
断点续传的前提是服务端能够识别出两次上传的是不是同一个文件。如果只用文件名和大小判断,用户修改了文件名或者更换设备后就会失效。正确做法是对文件内容计算一个唯一哈希值,常见的选择是MD5或者SHA-1。由于整个文件哈希计算可能会阻塞主线程,前端可以按分片增量计算,每次读取一小块数据追加到哈希对象中,计算完成后得到完整哈希。下面使用SparkMD5库演示增量计算的过程,读取粒度设为2MB,避免一次性占用过多内存。
import SparkMD5 from 'spark-md5';
function calculateFileHash(file, chunkSize = 2 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
let offset = 0;
reader.onload = (e) => {
spark.append(e.target.result);
if (offset < file.size) {
readNext();
} else {
resolve(spark.end());
}
};
reader.onerror = reject;
function readNext() {
const end = Math.min(offset + chunkSize, file.size);
const blob = file.slice(offset, end);
offset = end;
reader.readAsArrayBuffer(blob);
}
readNext();
});
}
拿到文件哈希后,前端先调用服务端的检查接口,比如/api/upload/check?hash=xxx,服务端返回该文件已经上传过的分片索引列表。前端根据这个列表过滤掉已经存在的分片,只上传缺失的部分。这样即使上传过程中断网或刷新页面,再次进入时也能接着上次的进度继续。下面代码展示了如何根据已上传索引筛选出待传分片。
async function getMissingChunks(fileHash, chunks) {
const response = await fetch(`/api/upload/check?hash=${fileHash}`);
const data = await response.json();
const uploaded = new Set(data.uploadedIndexes);
return chunks.filter((chunk) => !uploaded.has(chunk.index));
}
如果服务端已经保存过这个文件的完整内容,检查接口可以直接返回一个秒传标记,前端拿到标记后跳过上传流程,直接提示用户上传完成。这种机制在网盘、图床等场景中非常常见,尤其适合重复文件较多的业务。需要注意的是,哈希计算本身也会消耗时间,如果文件非常大,建议把哈希计算放在Web Worker中执行,避免阻塞UI渲染。
三、上传进度、暂停与恢复
进度显示需要同时考虑单分片进度和整体进度。单分片进度可以通过XMLHttpRequest的upload.onprogress事件获取,回调里带有已上传字节数和总字节数。整体进度则要把已完成分片的大小与正在上传分片的已传字节数累加起来,再除以文件总大小。下面的函数负责上传单个分片,并通过回调把实时进度抛给调用方。
function uploadChunk(chunk, fileHash, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', chunk.blob);
formData.append('index', chunk.index);
formData.append('hash', fileHash);
xhr.open('POST', '/api/upload/chunk');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(chunk.index, event.loaded, event.total);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error('上传失败'));
}
};
xhr.onerror = () => reject(new Error('网络错误'));
xhr.send(formData);
});
}
暂停与恢复功能需要借助AbortController。每个分片请求都关联一个AbortController实例,用户点击暂停时调用abort方法即可中断当前请求。暂停后组件内部保留文件哈希、分片列表和已上传索引,恢复时继续从未完成的分片开始。下面是一个简化的可中断上传函数,实际使用时可以把AbortController实例保存在组件状态中,统一管理所有进行中的请求。
let currentController = null;
function pauseCurrentUpload() {
if (currentController) {
currentController.abort();
currentController = null;
}
}
async function uploadChunkAbortable(chunk, fileHash) {
currentController = new AbortController();
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: buildFormData(chunk, fileHash),
signal: currentController.signal
});
currentController = null;
return response.json();
}
失败重试不能简单地在循环里无限重发,否则网络恢复后可能瞬间产生大量请求。比较成熟的方案是采用指数退避策略,比如第一次失败后等待1秒重试,第二次失败等待2秒,第三次等待4秒,最大等待时间可以限制在30秒左右。这样既能保证最终成功,又不会给服务端带来过大压力。同时还要区分网络错误和服务端拒绝错误,像文件哈希校验失败这类业务错误就不应该盲目重试。
四、Vue 3组件封装与整体串联
在Vue 3中,可以用组合式API把上传逻辑收拢到一个useFileUpload函数中,返回响应式状态和操作方法。组件只负责渲染进度条、按钮和文件选择框,具体的分片、哈希、上传细节全部封装在函数内部。下面展示了一个简化版的状态定义,实际项目可以在此基础上扩展错误信息、重试次数、已传分片列表等字段。
import { ref, computed } from 'vue';
export function useFileUpload() {
const file = ref(null);
const hash = ref('');
const chunks = ref([]);
const uploadedIndexes = ref([]);
const progress = ref(0);
const status = ref('idle');
const totalProgress = computed(() => {
if (!chunks.value.length) return 0;
const uploadedSize = uploadedIndexes.value.length * chunks.value[0].blob.size;
return Math.min(100, Math.round((uploadedSize / file.value.size) * 100));
});
function startUpload() {
status.value = 'uploading';
// 这里串联哈希计算、分片过滤、并发上传和合并请求
}
function pauseUpload() {
status.value = 'paused';
}
return {
file,
hash,
chunks,
progress,
status,
totalProgress,
startUpload,
pauseUpload
};
}
模板部分可以利用totalProgress直接绑定到进度条的宽度或者百分比文本上,按钮根据status的值切换显示开始、暂停、继续等文案。用户选择文件后先调用哈希计算,再执行分片检查和上传,所有操作都在组合式函数内部完成,组件模板保持极简。这样当需要复用到多个页面时,只需调用同一个useFileUpload函数即可,不需要复制粘贴一堆请求逻辑。
全部上传完成后,前端还需要调用服务端的合并接口,比如/api/upload/merge,并传入文件哈希和分片数量。服务端会按照索引顺序把临时分片文件拼接成最终文件,同时校验合并后的文件哈希是否与之前记录一致。如果一致则返回文件访问地址,不一致则说明上传过程中出现了数据损坏,需要重新上传出错的分片。临时分片文件应该在合并完成后及时清理,避免服务器磁盘被无用的分片数据占满。对于需要更高可靠性的系统,还可以把分片直接上传到对象存储,并借助对象存储的多段上传能力完成合并,这样后端只需要做调度和记录,不必自行处理文件流。