☰
Vue3大文件上传实战:分片、断点续传与秒传方案
2026/10/13 22:32:29 网站建设 项目流程

做系统时被大文件上传折腾惨了——单个视频好几个GB,用最普通的表单上传,传一半断网就从头再来。后来我改用Vue3的Composition API做了一整套大文件上传方案,分片、断点续传、秒传、并发控制全部搞定,整个上传流程才算真正能用。这篇文章把方案选型、核心代码和踩过的坑完整梳理一遍,适合已经会用Vue3、但还没系统搞过上传组件的开发者,也适合准备做网盘、课程平台这类功能的团队参考。

1. 大文件上传的核心痛点与方案选型

1.1 直接上传为什么会挂

先说最直观的问题:直接拿一个巨大的文件走普通请求,失败是必然的,只是时间早晚。我最初对接某内部工具时,客户端把几个GB的视频课程包直接POST给后端,上传一分钟内基本都会出问题。一个是网络中断,用户用公司Wi-Fi或者手机热点,信号稍微波动连接就断了,而普通上传没有任何续传能力,断一次就从头再来;一个是网关超时,很多服务前端的代理层默认超时时间是60秒左右,2GB的文件在常见上行带宽下要传十几分钟甚至更久,超时后连接被服务端掐断,前端拿到的是一个莫名其妙的网络错误;还有一个是内存和服务端压力,把整个文件读进内存再交给请求体,超大文件会直接把浏览器拖到崩溃,服务端接收这么大的单体请求也容易出现IO阻塞,整站响应变慢。

表象是“传不上去”,本质是:我们把一个不可控的长耗时长任务,交给了一次性请求去扛。而分片上传的核心思路,就是把这个任务切割成大量独立的小任务,每个小任务体量可控、失败成本低、还能并发执行。

1.2 分片、断点续传、秒传是怎么组合起来的

这三个能力经常被放在一起说,但它们的职责完全不同。分片解决的是“单次传输体量过大”的问题,把一个2GB文件切成几百个5MB的块,每个块的传输时间控制在几秒到十几秒,网络抖动时最多损失一个块,而不是整个半天的心血。断点续传解决的是“失败后重复成本过高”的问题,前端和后端都记录哪些分片已经传成功,重新上传时只传缺失的部分,不会从头再来。秒传解决的是“同一个文件被反复上传”的资源浪费,文件内容一样时,用文件指纹(也就是内容hash)判断,服务端直接复用已有文件,前端瞬间到100%。

打个比方,搬家时找一辆大卡车把所有家具装满,路上遇到堵车,整辆车卡几个小时动弹不得,你只能干等。如果把家当分别装进几百个编号的箱子里,每箱独立发货,丢了一箱只需要补发那一箱,其他箱子正常到家。秒传更像是:如果目的地已经有一套完全一样的箱子,检查内容编号后直接搬进去,连车都不用出。

1.3 为什么用Vue3的组合式API来封装

上传这个场景的状态特别多:等待、计算hash、校验中、上传中、暂停、重试、合并中、完成、失败,各有各的进度和提示。在Vue3里用组合式API,这些状态可以全部收敛到一个useUploader函数中,组件拿到的就是响应式的状态和方法,页面上的进度条、按钮态、提示语全部自动跟着响应式数据走。放在Vue2时代,这类逻辑多半散在组件的data、methods和mixin里,状态一多就很难维护;组合式API写起来更像“把整个上传引擎封装成一个黑盒”,组件侧只关心调用start、pause、cancel,顺手解构出progress显示进度。

另外两个实用原因:一是组件销毁时执行onBeforeUnmount可以自动取消所有未完成的上传请求,避免页面都关了后端还在收分片;二是上传工具函数可以独立单测,不依赖任何页面组件,我甚至在一个纯函数项目中也能复用同一套逻辑。生态上axios等网络库在Vue3里无缝使用,整个方案落地很平滑。

2. 前端分片与文件指纹:两个关键基础能力

2.1 分片大小怎么定

分片大小是个典型的需要平衡的参数。我见过有人为了显得“粒度细”用了256KB一个分片,结果1GB文件切成了4096片,每一片都要走一次完整的网络握手,光是请求往返的开销就让速度直线下降,服务端频繁创建和写入小文件,性能和稳定性都遭殃。反过来分片切成50MB甚至100MB,虽然请求数量少了,但弱网下单块传输依然可能超时,分片的意义就打折扣了。

我一般固定用5MB。给个直观的数据:2GB文件切5MB一个,总共有约410个分片,并发开4个的话大约100轮请求,这个数量级在日志排查时可读性也不错。实际上分片大小落到2MB到5MB之间都可以,具体看业务:弱网场景偏多建议用2MB,能进一步压缩单块失败成本;带宽充足、内网环境下用5MB甚至10MB都没问题。最后一个分片往往是残缺的,计算时用Math.min(start + CHUNK_SIZE, file.size),保证末尾不越界。

function buildChunks(file, chunkSize) { const chunks = []; let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ index, start, end }); start = end; index++; } return chunks; }

这个buildChunks是整个上传流程的地基,后面的hash计算、并发上传、进度计算全部基于这份分片元数据。

2.2 文件hash:秒传和续传的地基

文件hash就是文件内容的指纹。两个文件名不同但内容完全一致的文件,算出来的hash一致;只要有一个字节不同,hash就完全不同。后端拿到这个hash,才知道“这个文件是不是已经传过”、“哪些分片之前已经成功接收”。

计算整文件hash最常用的库是spark-md5。关键是不能把整个文件一次性读进内存再算,2GB文件直接读会卡到浏览器崩溃。正确做法是配合FileReader按分片增量读取,每读一片就把ArrayBuffer追加到spark实例里,读完后调用spark.end()得到结果。

async function computeFileHash(file, chunkSize) { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let offset = 0; while (offset < file.size) { const blob = file.slice(offset, offset + chunkSize); const buffer = await new Promise((resolve, reject) => { reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(blob); }); spark.append(buffer); offset += blob.size; hashProgress.value = offset / file.size; } return spark.end(); }

这个实现有个性能隐患:在主线程上逐片读取并计算,一个2GB视频文件可能要花几十秒甚至更久,用户看到页面卡住会直接以为是死机。我踩过这个坑之后改成了Web Worker方案,把文件读取和spark计算全部放到worker线程里,主线程只接收进度消息。

// hashWorker.js importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js'); self.onmessage = (event) => { const { file, chunkSize } = event.data; const spark = new self.SparkMD5.ArrayBuffer(); let offset = 0; const readNext = () => { if (offset >= file.size) { self.postMessage({ type: 'done', hash: spark.end() }); return; } const blob = file.slice(offset, offset + chunkSize); const reader = new FileReader(); reader.onload = () => { spark.append(reader.result); offset += blob.size; self.postMessage({ type: 'progress', ratio: offset / file.size }); readNext(); }; reader.readAsArrayBuffer(blob); }; readNext(); };

如果不想引入Worker,另一个折中方案是抽样hash:只取第一个分片、文件中间位置的几个分片、最后一个分片来算hash,计算时间在毫秒级,但碰撞概率提高,不适合用做完整性校验。我的建议是:需要做可靠校验的场景坚持全量hash加Worker;只做秒传判断、追求响应速度的场景才考虑抽样hash,而且抽样结果只用于“疑似重复”,最终以服务端完整文件hash比对为准。

2.3 计算hash时的用户交互

这里容易出现体验断层:用户明明选完文件了,上传按钮却没反应,或者状态还在“准备中”但没有任何提示。我在做优化时就把流程状态定义为ready、hashing、checking、uploading、paused、merging、done、error,每一步都对应界面上的明确提示。选完文件立刻进入ready,“开始上传”按钮可用;点击开始后马上切到hashing,按钮禁用,显示“正在计算文件指纹”加百分比进度。这样用户知道系统在做事情,而不是干等着。

3. 核心流程实现:上传、续传、并发与进度计算

3.1 上传前三步:先校验再上传

完整流程其实分三段:算完hash之后,先请求一个校验接口,再决定是秒传、续传还是全新上传。校验接口的返回通常包含两个关键信息:这个文件整体是否已存在、哪些分片已经上传成功。

async function checkUploadStatus() { const res = await http.get('/upload/check', { params: { fileHash: fileHash.value }, }); const { exists, uploaded } = res.data; if (exists) { uploadedSet.value = new Set(chunkMeta.map((_, index) => index)); return { exists: true }; } uploadedSet.value = new Set(uploaded || []); return { exists: false }; }

如果校验接口返回exists为true,说明服务端已经有这个hash对应的完整文件,这时候直接跳到100%,属于秒传成功。如果返回一个已上传分片索引列表,比如[0, 2, 5],就说明用户之前传过一部分,网断了或者刷新页面了,服务端保存了这些成功接收的分片,前端只需要补齐缺失的分片。这个逻辑是断点续传成立的前提,千万不要自己在前端本地记录一下就完事,万一用户清了缓存或者换台设备,完全没有意义。

3.2 并发控制:别把几百个请求一次打出去

切完分片之后,最直接的做法是for循环一个接一个传,但2GB文件400多个分片,串行很慢。反过来全部并发也不可取,同域连接数限制、服务端压力、失败重试的成本都会飙升。需要的是并发池:同时保持固定数量的请求在传输中,比如默认4个。

async function mapLimit(list, limit, asyncMapper) { const results = []; let index = 0; const worker = async () => { while (index < list.length) { const current = index++; results[current] = await asyncMapper(list[current], current); } }; const workers = Array.from({ length: Math.min(limit, list.length) }, () => worker()); await Promise.all(workers); return results; }

这个并发池实现很轻量,不依赖额外库。选择4到6个并发的理由是:家用宽带场景下,4路并行基本能把带宽打满,再开更多请求反而因为互相竞争而速度下降;浏览器对同一域名也有连接数限制,HTTP1.1下一般是6条左右,超过的部分排队等待,并没有真正的“并行加速”。弱网环境下我习惯把并发降到2到3,降低单块失败概率,也方便重试逻辑快速收敛。

3.3 断点续传的完整细节

断点续传不仅需要前端维护“已成功分片”,还需要后端持久化已接收分片。前端的核心是一个Set,每个分片上传成功后立刻把索引加进去。

async function uploadOne(chunk, index) { const formData = new FormData(); formData.append('file', new Blob([chunk], { type: file.value.type })); formData.append('fileHash', fileHash.value); formData.append('chunkIndex', index); formData.append('chunkTotal', totalChunks.value); await http.post('/upload/chunk', formData, { signal: abortController?.signal, onUploadProgress: (event) => { inFlightLoaded[index] = event.loaded; }, }); delete inFlightLoaded[index]; uploadedSet.value.add(index); }

这里有个细节:真实项目中我是从chunkMeta里取对应的分片Blob,避免重复切片造成的数据不一致。每次真正发请求前还会检查一下uploadedSet,过滤掉那些已经成功的索引,只把待传分片丢进并发池。

刷新后恢复上传的准确姿势是:先调check接口拿到服务端记录的已上传分片列表,再把服务端返回的Set与本地残留记录做合并,过滤出真正需要补传的分片列表。过程中如果出现某个分片上次传成功了但响应丢失的情况,前端会重传,服务端要能幂等处理,忽略重复的分片或者直接覆盖存储。

3.4 进度计算的正确姿势

进度条算是个容易翻车的地方。最直观的错误写法是:在axios的onUploadProgress里直接loadedTotal += event.loaded,然后除以总大小。问题是并发上传时好几个请求同时回调,各自维护的loaded是不同分片的,累计起来不是总进度,进度条会乱跳,甚至超过100%。

我的做法是区分“硬性进度”和“软性进度”。硬性进度指已经成功上传完的分片,用completedBytes表示,这个值只会单调增加;软性进度指正在传输中的分片当前已经传了多少,用inFlightLoaded这个map维护,请求完成时就把对应索引从map里删掉,避免和completedBytes重复计算。

const completedBytes = computed(() => { let sum = 0; chunkMeta.forEach((chunk, index) => { if (uploadedSet.value.has(index)) { sum += chunk.end - chunk.start; } }); return sum; }); const progress = computed(() => { const total = file.value?.size || 1; const inflight = Object.values(inFlightLoaded).reduce((a, b) => a + b, 0); const done = completedBytes.value + inflight; return Math.min(99, Math.round((done / total) * 100)); });

注意合并分片时,进度条还会在99%的地方停一会儿,这一步我通常把提示文案从“上传中”切到“服务端合并中”,避免用户以为卡住了。

4. 接口约定、秒传与后端合并策略

4.1 三个核心接口怎么定义

前端的整条链路离不开后端配合。接口设计得越清晰,前后端联调越省心。我这边用下来最顺的是三个接口:

功能方法请求参数返回数据
校验文件状态GET /upload/checkfileHash{ exists, uploaded: number[] }
上传单个分片POST /upload/chunkmultipart: file, fileHash, chunkIndex, chunkTotal{ ok: true }
触发合并POST /upload/mergefileHash, fileName, totalChunks{ status, url? }

每个参数都有存在的必要。fileHash是整条上传任务的定位符,也是秒传的依据;chunkIndex决定了合并时的顺序,乱传没问题,但合并时必须按它排序;chunkTotal用于后端判断分片是否收齐;fileName用于合并后生成正确的文件扩展名和下载名。缺一个字段,后面排查问题时都会很痛苦。

合并接口的建议是返回一个status字段,值为merging或done。大文件合并本身可能有耗时,比如几个GB的二进制流写入磁盘,如果前端死等同步响应,中间任何超时都会造成困惑。更稳妥的做法是:先POST一个合并任务,后端返回merging,前端轮询合并结果,直到状态变为done并拿到最终文件URL。

4.2 合并正确性与完整性校验

合并的正确性比想象中更容易出问题。我在内部测试时遇到过:文件大小看起来是对的,但视频播放到一半花屏。排查后发现后端合并时没有按index排序,某个分片写到了错误的位置上。正确的合并流程是:拿到所有分片后按index升序排序,逐片以二进制追加方式写入目标文件,最后校验分片数量是否等于totalChunks、每个分片大小是否匹配预期。

合并完成后,强烈建议再对完整文件算一次hash,与前端上传时记录的fileHash比对。一致才标记为上传完成,不一致就删除合并产物并报错,让用户重新触发合并或重新补传。这个校验步骤虽然让后端多做了一次文件读取,但能极大概率拦截损坏文件,尤其当分片来自不同用户、不同网络环境时,非常值得。

每收到一个分片,服务端还应该校验分片大小:除最后一个分片外,其他分片大小应当等于CHUNK_SIZE,否则视为非法分片并拒绝。这个前置校验能防止网络传输损坏的数据被写入磁盘,从源头减少合并后文件损坏的比例。

4.3 秒传与临时文件的清理

秒传在后端的实现很巧妙:同一个hash如果已经有完整文件记录,就不需要再物理存储一份新文件,而是把新任务的记录指向已有文件,直接返回上传完成。这对大量重复上传相同资源的场景收益巨大,比如多个用户上传同一个软件安装包、同一门课程的视频,带宽和磁盘都省下来了。

临时分片处理的坑也要提前考虑。用户传了一半取消、传完分片但没触发合并、合并失败后一直没人管,都会让临时分片堆积在磁盘上。我采用定时扫描策略,删除超过24小时仍未被合并且没有对应完整记录的分片目录。这个任务可以和日常数据库清理放在一起调度,避免临时文件把服务器磁盘打爆。

5. 完整封装:useUploader组合式函数代码拆解

5.1 设计思路与对外暴露

我封装这个组合式函数时,要求组件侧的使用尽量简洁:外部传入一个http实例和可调节参数,内部管理所有状态,返回情况下最多是start、pause、resume、cancel这几个方法。组件只需要监控status和progress,回应用户的操作,完全不需要关心分片列表、并发池、hash计算这些细节。

const { file, progress, status, hashProgress, totalChunks, uploadedChunks, selectFile, start, pause, resume, cancel, } = useUploader({ http: httpClient, chunkSize: 5 * 1024 * 1024, concurrency: 4, });

选完文件后用户点击“上传”,函数内部顺序执行:计算hash、校验状态、并发上传、触发合并。任何一步出错都把状态标记为error,组件统一渲染错误提示和重试按钮。

5.2 完整的useUploader实现

把核心能力整合起来,大概就是下面这段代码。注释里放了几个容易踩坑的点,照着参数调整就能用。

import { ref, reactive, computed, onBeforeUnmount } from 'vue'; import SparkMD5 from 'spark-md5'; const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; const DEFAULT_CONCURRENCY = 4; export function useUploader(options = {}) { const { http, chunkSize = DEFAULT_CHUNK_SIZE, concurrency = DEFAULT_CONCURRENCY, onStatusChange, } = options; const file = ref(null); const fileHash = ref(''); const totalChunks = ref(0); const status = ref('idle'); const progress = ref(0); const hashProgress = ref(0); const uploadedChunks = ref(new Set()); const errorMessage = ref(''); const uploadedSet = uploadedChunks.value; const inFlightLoaded = reactive({}); let chunkMeta = []; let abortController = null; function setStatus(next) { status.value = next; onStatusChange?.(next); } function buildChunks(rawFile) { const list = []; let start = 0; let index = 0; while (start < rawFile.size) { const end = Math.min(start + chunkSize, rawFile.size); list.push({ index, start, end }); start = end; index++; } return list; } async function computeFileHash() { return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let offset = 0; const readNext = () => { if (offset >= file.value.size) { resolve(spark.end()); return; } const blob = file.value.slice(offset, offset + chunkSize); reader.onload = (event) => { spark.append(event.target.result); offset += blob.size; hashProgress.value = offset / file.value.size; readNext(); }; reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(blob); }; readNext(); }); } async function checkUploadStatus() { const res = await http.get('/upload/check', { params: { fileHash: fileHash.value }, }); const { exists, uploaded } = res.data; if (exists) { chunkMeta.forEach((chunk) => uploadedSet.add(chunk.index)); return { exists: true }; } (uploaded || []).forEach((index) => uploadedSet.add(index)); return { exists: false }; } async function uploadOne(chunk, index) { if (uploadedSet.has(index)) return; const formData = new FormData(); const blob = file.value.slice(chunk.start, chunk.end); formData.append('file', blob, file.value.name); formData.append('fileHash', fileHash.value); formData.append('chunkIndex', index); formData.append('chunkTotal', totalChunks.value); inFlightLoaded[index] = 0; try { await http.post('/upload/chunk', formData, { signal: abortController?.signal, onUploadProgress: (event) => { inFlightLoaded[index] = event.loaded; }, }); uploadedSet.add(index); } finally { delete inFlightLoaded[index]; } } async function mapLimit(list, limit, mapper) { const results = []; let index = 0; const worker = async () => { while (index < list.length) { const current = index++; results[current] = await mapper(list[current], current); } }; const workers = Array.from({ length: Math.min(limit, list.length) }, () => worker()); await Promise.all(workers); return results; } async function triggerMerge() { const res = await http.post('/upload/merge', { fileHash: fileHash.value, fileName: file.value.name, totalChunks: totalChunks.value, }); if (res.data.status === 'done') return; setStatus('merging'); await pollMergeResult(); } async function pollMergeResult() { while (status.value === 'merging') { const res = await http.get('/upload/merge/status', { params: { fileHash: fileHash.value }, }); if (res.data.status === 'done') return; await new Promise((resolve) => setTimeout(resolve, 2000)); } } async function start() { if (!file.value || status.value === 'uploading') return; setStatus('hashing'); fileHash.value = await computeFileHash(); setStatus('checking'); const { exists } = await checkUploadStatus(); if (exists) { progress.value = 100; setStatus('done'); return; } setStatus('uploading'); abortController = new AbortController(); const pending = chunkMeta.filter((chunk) => !uploadedSet.has(chunk.index)); await mapLimit(pending, concurrency, (chunk) => uploadOne(chunk, chunk.index)); await triggerMerge(); progress.value = 100; setStatus('done'); } function pause() { if (status.value === 'uploading') { abortController?.abort(); setStatus('paused'); } } function resume() { if (status.value === 'paused') { setStatus('ready'); start(); } } function cancel() { abortController?.abort(); uploadedSet.clear(); progress.value = 0; setStatus('idle'); } function selectFile(rawFile) { cancel(); file.value = rawFile; chunkMeta = buildChunks(rawFile); totalChunks.value = chunkMeta.length; setStatus('ready'); } const totalBytes = computed(() => { let sum = 0; chunkMeta.forEach((chunk) => { if (uploadedSet.has(chunk.index)) sum += chunk.end - chunk.start; }); return sum; }); progress = computed(() => { const total = file.value?.size || 1; const inflight = Object.values(inFlightLoaded).reduce((sum, value) => sum + value, 0); const done = totalBytes.value + inflight; return Math.min(99, Math.round((done / total) * 100)); }); onBeforeUnmount(() => { abortController?.abort(); }); return { file, fileHash, totalChunks, status, progress, hashProgress, uploadedChunks, errorMessage, selectFile, start, pause, resume, cancel, }; }

这里把progress从普通ref改成computed,会覆盖掉初始赋值,需要注意。实际项目里如果Node版本和构建工具支持,也可以用const progress = computed(...)替代原来的定义。整体逻辑跑通后,再根据自己前后端习惯调整接口路径和参数名就可以。

5.3 组件里的使用方式

组件侧就清爽多了。我用最简单的原生input加几个按钮就能跑通全流程。

<template> <div> <input type="file" @change="handleChange" /> <div v-if="file">{{ file.name }} - {{ status }}</div> <progress :value="progress" max="100" /> <button v-if="status === 'ready' || status === 'paused' || status === 'error'" @click="start"> 开始上传 </button> <button v-if="status === 'uploading'" @click="pause">暂停</button> <button v-if="status === 'paused'" @click="resume">继续</button> <button v-if="status === 'uploading' || status === 'paused'" @click="cancel">取消</button> </div> </template> <script setup> import { useUploader } from './useUploader'; import { httpClient } from './http'; const { file, status, progress, selectFile, start, pause, resume, cancel, } = useUploader({ http: httpClient, chunkSize: 5 * 1024 * 1024, concurrency: 4, }); function handleChange(event) { const rawFile = event.target.files[0]; if (rawFile) selectFile(rawFile); } </script>

几个按钮的状态完全由status驱动,界面上不需要写任何上传逻辑。如果项目里接了UI组件库,直接把progress变量绑到进度条组件上即可,连样式适配都很省事。

6. 实战踩坑清单与排查建议

6.1 分片顺序乱导致合并后文件损坏

这应该是分片上传最经典的坑。前面说过,合并时必须按index排序,而且不能用异步并发的方式同时写同一个目标文件,否则分片之间互相覆盖。另一个容易被忽略的点是:分片在网络上传输时不可控,服务端接收到的顺序和发送顺序完全无关。无论前端在做什么并发控制,后端合并前都要统一做“按index排序”和“数量校验”。

6.2 hash计算卡死页面

算速度快但文件太大依然会卡UI。我第一次实现时是在主线程用FileReader边读边算,拖入2GB视频后整个页面失去响应,用户截图给我说“浏览器卡没了”。改成Web Worker或者抽样hash之后问题就消失了。如果项目条件允许,强烈建议优先上Worker方案,同时给用户展示hash计算进度,避免“假装卡死”的观感。

6.3 并发数开太高反而变慢

把concurrency从4调到10之后,表面上看并行度上去了,实际上是排队与竞争。浏览器版本、网络环境、服务端处理能力都会影响最佳并发数。排查思路很简单:先用小文件在固定网络下分别测2、4、6、8的耗时和失败率,再针对弱网环境把并发下调。上生产后我一般保留4作为默认值,代码里做成配置项,运营同学在弱网地区可以手动调低。

6.4 进度条回跳或者超过100%

进度条不准的常见原因在第三章里已经提过,这里整理成速查表:

问题现象常见原因解决办法
进度条乱跳onUploadProgress里全局累加loaded改用已上传Set统计,加上在飞分片map
进度超过100%已上传分片大小重复计算删除inFlightLoaded中已完成索引
合并阶段进度卡住没有展示合并状态将status切换为merging,展示“合并中”
续传后进度倒退未把服务端已有分片计入completedBytes从check接口返回的uploadedList初始化Set

排查进度问题不要盯着进度数字本身,先看status流转是否正常,再看completedBytes和inFlightLoaded两个数据是否单调或受控。

6.5 取消上传时请求还在发

前端组件销毁后,如果不用AbortController把在飞请求全部取消,浏览器里的异步任务仍然会继续执行,后端也会继续接收分片。我在useUploader里统一维护了一个AbortController,每次开始上传时new一个,暂停、取消、组件卸载时调用abort()。注意axios支持signal参数,用这个机制能干净地中断请求。

另一个取消相关的点是:用户点取消后,服务端已接收的分片要不要删。我的建议是对已经接收的合法分片保留,这样用户重新上传时还能通过check接口复用,只有明确放弃上传任务时才标记为可清理。

6.6 多端兼容性注意事项

老生常谈的File.slice兼容问题:现代浏览器基本都支持File.prototype.slice,但部分老旧WebView和早期Safari可能有前缀或行为差异。真实项目里兜底写法仍然值得保留:

const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;

移动端尤其要小心并发数。iOS上同域连接数限制比桌面浏览器更严格,我一般在移动端把并发降到2到3,效果反而是稳定的。Web Worker在主流WebView里也基本能跑,但最好做个能力检测,不支持时自动回退主线程计算,避免白屏。

6.7 用弱网模拟把问题提前逼出来

开发时网速快、网络稳,很多上传问题根本暴露不出来。我的习惯是在Chrome DevTools的网络面板里设置慢速3G或者自定义一个劣化带宽,专门用来测试大文件的弱网表现。分片大小也临时调小到512KB,让分片数量暴涨几十倍,压力测试并发池和进度计算。很多隐藏问题,比如进度重复计算、分片响应乱序、请求排队加剧,都是在这种极端情况下浮出来的。

这套方案跑了大半年,从最初几GB视频传不上去,到后来稳定传几十个GB的离线课程包,最深的体会是:分片上传不是纯前端工程,接口约定必须前后端一起提前对齐;开发时也不要一上来就堆并发、秒传这些高级特性,先把“失败可控”做到位,后面的优化都是锦上添花。最后再分享一个小技巧:每轮发布前用弱网模拟把整个流程走一遍,看着进度条平滑到100%再点合并,比看十遍代码都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询