1. 问题复现与整体设计思路
1.1 为什么普通下载方案会在大文件场景下崩溃
先聊一个真实场景。前阵子我负责的一个后台管理系统里有个数据导出功能,用户在页面上勾选条件,点一下“导出报表”,后端就把数据生成一个几百MB甚至几个GB的Excel或CSV文件返回给前端下载。最开始实现得很简单,后端给一个文件URL,前端直接window.location.href = url或者搞一个<a>标签就完事了。
测试阶段一切正常,等真正投到生产环境,问题就接二连三冒出来了。
第一是内存问题。浏览器下载一个200MB的文件,如果用fetch或axios把整个响应体放进内存再触发下载,页面会明显卡顿,甚至直接白屏崩溃。这个过程本质上就是把整个文件内容一次性读进JavaScript堆里,再转成Blob,浏览器底层还要再复制一份数据,内存占用轻松翻倍。你要是处理一个1GB的文件,Chrome标签页直接“Aw,Snap!”给你看。
第二是网络稳定性问题。大文件下载耗时动辄几分钟到几十分钟,网络稍微有点抖动,TCP连接一断,整个下载就废了,又得从头开始。用户骂娘不说,后端服务压力也大,文件越大,单次请求占用后端连接的时间越长,并发一高,Tomcat或Node服务的线程池直接被拖垮。
第三是失败重试成本高。普通方案没有断点续传能力,一旦下载中断,整段重来,浪费带宽也浪费时间。
所以后来我意识到,处理大文件下载,绝对不能把它当成普通请求来处理。正确思路是把大文件“切碎”,利用HTTP协议本身就支持的范围请求(Range Request)能力,一片一片地拉取数据,最后在浏览器端拼接合并。这就是前端大文件分片下载的底层逻辑。
1.2 分片下载的核心思想:HTTP Range是你的好帮手
分片下载并不是什么高深莫测的黑科技,它完全建立在HTTP协议已有能力之上。
HTTP协议里有两个关键响应头字段:Accept-Ranges和Content-Range。其中Accept-Ranges: bytes表示服务端支持按字节范围返回数据;Content-Range则在响应中说明实际返回的是文件整体的哪个字节区间,比如Content-Range: bytes 0-1048575/104857600,意思就是“这个响应返回的是整个104857600字节文件中的第0到第1048575字节”。
对应地,客户端在发起请求时,需要带上Range请求头,比如Range: bytes=0-1048575,表示“我只想要文件的这一段”。服务端收到后,如果支持范围请求,就返回状态码206 Partial Content,并且响应体里只包含对应区间的二进制数据。
这个机制天然适合前端去做分片下载。我们通过fetch或XHR发起多个并发请求,每个请求指定不同的Range,就能拿到文件的不同片段,全部拿到之后再用Blob拼接合并,最后生成一个完整文件触发浏览器下载。整个过程不需要后端做任何额外改动,只要后端是标准的HTTP服务,几乎都支持Range请求。
我在实际项目中验证过很多次,用这种方式下载大文件,内存占用稳定在几十MB级别,就算文件本身有2GB,浏览器也扛得住。原因很简单:每个分片请求拿到的数据量是可控的,拿到一片就释放一片的引用,不会出现所有数据同时驻留在内存里的情况。
1.3 方案选型:几把“刀”该怎么选
在实践中,分片下载的实现方式有好几种,我梳理一下各自的适用场景。
第一种是最轻量的方案,直接用浏览器原生能力:<a>标签带上download属性,指向文件URL。这种方案最简单,代码量几乎为零,但致命短板是无法掌控下载进度,也无法做断点续传和并发控制。适合小文件、一次性下载、后端没做特殊处理的场景。
第二种是用XHR或fetch手动实现分片逻辑。我们可以自由控制并发数量、分片大小、失败重试、进度统计,灵活性最高。代价是代码量多一些,要处理各种边界情况。这是本文要重点讲透的方案,也是解决大文件下载的主流姿势。
第三种是使用现成的第三方库,比如axios配合axios-range插件,或者直接用StreamSaver.js这类库。StreamSaver.js的思路是在浏览器端创建一个可写流,配合service worker把数据分块写入磁盘,内存占用极低,但它的实现复杂度高,兼容性也有一定限制。适合对内存占用要求极其苛刻的场景。
第四种是web worker方案。把分片下载的计算和数据处理逻辑放到worker线程里,避免阻塞主线程。如果文件特别大、并发数很高,或者你同时还希望页面保持流畅交互,这个方案值得考虑。我在后面会给出一个小demo。
我个人的倾向是:如果是公司内部后台系统、中后台管理平台,直接用第二种方案,配合web worker做数据组装,必要的时候再叠加StreamSaver做落盘。下面我会把从零实现一个完整分片下载的各个关键环节都拆开讲。
2. 核心细节解析:请求、合并与触发下载的完整链路
2.1 前置准备:探测服务端是否支持Range请求
在正式写分片逻辑之前,第一步不是for循环发请求,而是先探测服务端支不支持Range。如果不支持,分片请求大概率会返回200全量数据,你辛辛苦苦写的拼接代码不但没用,还可能因为多个200响应混在一起导致数据错乱。
探测方法很简单,发一个不带Range或只带Range: bytes=0-0的HEAD请求,看响应头里的Accept-Ranges和状态码。
我一般会单独写一个探测函数,代码如下:
async function checkRangeSupport(url) { try { const response = await fetch(url, { method: 'GET', headers: { Range: 'bytes=0-0' }, }); if (response.status === 206) { const total = Number(response.headers.get('Content-Range').split('/')[1]); return { supported: true, totalSize: total }; } if (response.status === 200) { const total = Number(response.headers.get('Content-Length') || 0); return { supported: false, totalSize: total }; } return { supported: false, totalSize: 0 }; } catch (error) { return { supported: false, totalSize: 0, error }; } }这里有个细节:Range: bytes=0-0这种方式,服务端如果支持Range,会返回206,Content-Range的格式是bytes 0-0/总大小,我们不光拿到了“是否支持”的结论,顺便还拿到了文件总大小,这个总大小后续用于计算分片数量很关键。
如果探测结果不支持Range,就得退化为普通单请求下载。不过现在绝大多数主流Web服务器(Nginx、Apache、Spring Boot内置容器等)和对象存储服务(阿里云OSS、腾讯云COS、MinIO)都支持Range请求,所以不用太担心。
2.2 分片大小与并发数怎么定
这个参数选得好不好,直接影响下载速度和稳定性。分片太小,请求数量太多,HTTP握手和头部传输开销占比过高,速度反而下降;分片太大,单请求失败重试成本高,也起不到节省内存的作用。
我的经验值是:单分片大小建议在2MB到8MB之间,默认取4MB比较稳妥。文件特别小(比如小于16MB)时不分片,直接用普通下载。并发数建议控制在3到6个之间,默认取4个。并发太高,浏览器会限制同一域名的连接数,反而引发排队;并发太低,大文件的下载速度又上不来。
不同文件大小的配置可以参考这个表格:
| 文件大小区间 | 分片大小 | 并发数 | 说明 |
|---|---|---|---|
| 0 ~ 16MB | 不分片,单请求 | 1 | 分片反而增加无谓开销 |
| 16MB ~ 500MB | 4MB | 4 | 大多数场景的均衡值 |
| 500MB ~ 2GB | 8MB | 5 | 适当加大分片,减少请求数量 |
| 2GB以上 | 16MB | 6 | 内存和速度的平衡点,需要谨慎 |
这个表不是死规则,可以根据实际网络环境调整。如果下载速度很慢,可以适当减小分片;如果延迟很高,则应该增大分片。核心思路是让每个分片下载时间在2到5秒之间,这样重试成本可控,进度条也平滑。
2.3 并发调度器:控制同时请求的数量
如果直接把全部分片一次性Promise.all丢出去,浏览器和服务器都会受不了。尤其是几百个分片同时发起请求,后端连接数瞬间打满,前端自己也会因为并发过高而出现大量超时。所以需要实现一个简单的并发调度器。
我常用的实现方式是一个带max限制的任务队列。维护一个待执行任务数组和一个正在执行的任务数计数器,每完成一个任务,立刻从队列里取下一个任务执行。逻辑不复杂,二十几行代码就能搞定:
async function runTaskWithConcurrency(tasks, maxConcurrency) { const results = new Array(tasks.length); let currentIndex = 0; async function worker() { while (currentIndex < tasks.length) { const index = currentIndex; currentIndex += 1; try { results[index] = await tasks[index]; } catch (error) { results[index] = { error }; } } } const workers = []; for (let i = 0; i < Math.min(maxConcurrency, tasks.length); i += 1) { workers.push(worker()); } await Promise.all(workers); return results; }这个调度器的思路相当于一个小型线程池:开固定数量的“工人”,每个工人不断地从任务列表里取活干,干完一个再取下一个,直到全部任务完成。这样做的好处是,无论任务有多少,同一时刻真正在飞的网络请求始终只有maxConcurrency个。
实际项目中,我还会在 worker 内部加上简单的失败重试逻辑,比如每个分片最多重试3次,每次失败后等待2秒再继续。重试不是无脑重发,最好加上指数退避,第一次失败等1秒,第二次等2秒,第三次等4秒,这样可以避免服务端在瞬时故障时被重试请求打爆。
2.4 分片合并:如何拼接出完整文件
分片下载最关键的环节之一,就是把从服务端拉回来的多个分片数据合并成一个完整文件。合并方式有两种,第一种是直接按顺序push进一个数组,最后new Blob(chunks)。第二种是预先分配一个ArrayBuffer,然后把每个分片的ArrayBuffer通过set方法复制到指定位置。
第一种方式简单直观,适合分片数量不是特别多的场景。要点是必须保证分片在数组中的顺序正确,如果某个分片下载完成先后顺序不同,不能直接按完成顺序push,而应该按分片索引放置。
const chunks = new Array(totalChunks); chunks[index] = new Uint8Array(await response.arrayBuffer());第二种方式适合超大文件和大量分片。预先创建一个总大小为文件总长度的ArrayBuffer,然后每个分片下载完成后,用set方法把数据写入对应的偏移位置。这种方式的好处是内存不会有碎片,且最终Blob只需要包一层ArrayBuffer引用即可。
const fullBuffer = new Uint8Array(totalSize); async function writeChunk(index, chunkData) { fullBuffer.set(new Uint8Array(chunkData), index * CHUNK_SIZE); } // 全部写完后 const blob = new Blob([fullBuffer.buffer], { type: mimeType });合并过程中有一个十分隐蔽的坑:Blob构造函数接收的数组如果含有不同类型的对象(ArrayBuffer、Blob、Uint8Array等),部分浏览器处理时可能出现兼容性问题。所以我在合并前会统一把所有分片转换成Uint8Array,再进行合并,这样最稳。
另一个需要特别注意的点是:最后一个分片的大小通常比正常分片小。计算方式为总大小 - (分片总数 - 1) * 单分片大小,不能直接整除。如果你在合并时拿了多余的数据,文件会损坏。
2.5 触发浏览器下载:Blob URL与文件名处理
数据合并成Blob之后,还需要转成一个可下载的链接,触发浏览器另存为。典型代码是:
const blobURL = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobURL; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(blobURL); setTimeout(() => { window.URL.revokeObjectURL(blobURL); }, 1000);文件名建议从后端响应头的Content-Disposition字段解析,格式通常是attachment; filename="报表.xlsx",需要解码URL编码。如果后端没有返回文件名,就只能自己从URL最后一段去截取。
中文文件名是个高频坑。如果download属性直接写中文,大部分浏览器都能正确处理,但个别旧版浏览器会变成乱码。我有一个小技巧:用encodeURIComponent先把文件名编码一下,再放到download属性里,兼容性会好一些。
这里我再送一个经验:revokeObjectURL不能太早调用。有些浏览器在调用click()之后异步地读取Blob数据,如果你立即revokeObjectURL,下载就会静默失败。我之前踩过这个坑,排查了整整一个下午,最后发现是提前释放了对象URL。稳妥做法是延迟1秒左右再释放。
3. 实操过程:从零实现一个可复用的大文件分片下载器
3.1 工程目录与依赖说明
我习惯把这部分逻辑封装成一个独立的模块,不散落在业务代码里。下面这个方案用原生fetch实现,不依赖任何第三方请求库,放到任何用Vue、React或者原生JS的前端工程里都能直接用。
目录结构大致是这样:
src/ ├── utils/ │ └── chunkDownloader.js # 核心下载器 └── demo/ └── DownloadPage.vue # 示例页面核心模块对外只需要暴露一个方法:
downloadFile({ url: 'https://example.com/files/big-data.csv', fileName: 'big-data.csv', chunkSize: 4 * 1024 * 1024, concurrency: 4, onProgress: (percent, loaded, total) => {}, onComplete: (blob) => {}, onError: (message) => {}, });3.2 下载器模块完整实现
下面我给出核心模块的完整代码,然后逐段解释关键逻辑。这里不是让你直接复制就完事,我会在每个关键节点说明为什么这样写。
// chunkDownloader.js const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024; // 4MB const DEFAULT_CONCURRENCY = 4; const DEFAULT_RETRY_COUNT = 3; async function fetchWithRetry(url, options, retries = DEFAULT_RETRY_COUNT, delay = 1000) { try { const response = await fetch(url, options); if (!response.ok && response.status !== 206) { throw new Error(`HTTP error: ${response.status}`); } return response; } catch (error) { if (retries <= 0) { throw error; } await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWithRetry(url, options, retries - 1, delay * 2); } } export async function downloadFile({ url, fileName, chunkSize = DEFAULT_CHUNK_SIZE, concurrency = DEFAULT_CONCURRENCY, onProgress, onComplete, onError }) { // 1. 探测服务端Range支持情况 const probeResponse = await fetch(url, { headers: { Range: 'bytes=0-0' } }); if (probeResponse.status !== 206) { // 不支持Range,直接普通下载 const fullResponse = await fetch(url); const blob = await fullResponse.blob(); onProgress?.(100, blob.size, blob.size); onComplete?.(blob); const downloadUrl = window.URL.createObjectURL(blob); triggerDownload(downloadUrl, fileName || getFileNameFromUrl(url)); window.URL.revokeObjectURL(downloadUrl); return; } const contentRange = probeResponse.headers.get('Content-Range'); const totalSize = Number(contentRange.split('/')[1]); const chunkCount = Math.ceil(totalSize / chunkSize); // 2. 预分配合并缓冲区 const fullBuffer = new Uint8Array(totalSize); let downloadedBytes = 0; let completedChunks = 0; // 3. 生成分片任务 const tasks = []; for (let i = 0; i < chunkCount; i += 1) { const start = i * chunkSize; const end = i === chunkCount - 1 ? totalSize - 1 : start + chunkSize - 1; tasks.push(async () => { const response = await fetchWithRetry(url, { headers: { Range: `bytes=${start}-${end}` }, }); const arrayBuffer = await response.arrayBuffer(); const uint8View = new Uint8Array(arrayBuffer); fullBuffer.set(uint8View, start); downloadedBytes += uint8View.length; completedChunks += 1; onProgress?.(Number(((downloadedBytes / totalSize) * 100).toFixed(2)), downloadedBytes, totalSize); }); } // 4. 并发执行 try { await runTaskWithConcurrency(tasks, concurrency); const blob = new Blob([fullBuffer.buffer], { type: probeResponse.headers.get('Content-Type') || 'application/octet-stream' }); const downloadUrl = window.URL.createObjectURL(blob); triggerDownload(downloadUrl, fileName || getFileNameFromUrl(url)); window.URL.revokeObjectURL(downloadUrl); onComplete?.(blob); } catch (error) { onError?.(error.message || '下载失败'); } } function triggerDownload(href, fileName) { const link = document.createElement('a'); link.href = href; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); } function getFileNameFromUrl(url) { try { const path = new URL(url).pathname; return decodeURIComponent(path.split('/').pop() || 'download'); } catch { return 'download'; } }3.3 代码细节的几种特殊情况和思考
上面代码里有一个容易被忽略的小细节:Range请求的end值为什么是totalSize - 1而不是totalSize?因为HTTP的Range区间是闭区间,且字节序号从0开始。如果文件大小是100字节,最后一个字节的索引是99。第一次发Range: bytes=0-99才是完整的,如果发bytes=0-100,某些服务端会返回416状态码。
再比如合并缓冲区为什么用Uint8Array.set而不是concat?因为concat会重新分配内存并把所有数据复制一遍。对于分片很多的大文件,频繁concat会产生大量临时对象和内存复制,性能很差。预先分配好固定大小的Uint8Array,每个分片到位后直接写入对应偏移位置,只复制一次,效率最高。
另外还要注意并发调度里的runTaskWithConcurrency。它执行的是tasks数组里的异步函数,每个任务内部包含下载、数据写入、进度更新三个步骤。之所以把任务定义成函数而不是直接定义成Promise,是为了让“并发控制”真正生效。如果你直接for循环里tasks.push(fetch(...)),fetch 在push 的瞬间就已经发起了,并发控制就形同虚设了。这是一个很容易犯的错误。
3.4 进度条:要平滑,不要跳动
进度条的体验直接影响用户对系统的信任感。我在项目里实现进度条时踩过不少坑,最典型的就是“进度条跳到99%后卡住很久”,或者“刚开始0%跳1%就卡住,最后突然100%”。
前者通常是因为合并数据或内存复制阶段耗时长,这个阶段的耗时无法通过请求进度体现,所以用户感觉卡住了。后者可能是因为后端首块数据生成耗时很长,比如导出任务需要先查询数据库、组装数据,这个时间用户端完全无感知。
处理思路是:进度值至少要分两层。第一层是数据下载进度,就是代码里计算出来的downloadedBytes / totalSize;第二层是已合并写入的进度,也就是completedChunks / chunkCount。在UI层做平滑处理时,可以用一个加权公式:
uiProgress = 0.85 * downloadProgress + 0.15 * completeProgress也就是说,下载数据完成度占85%的权重,最终合并写入占15%。这样用户看到的进度条在下载完成后还有一个平缓收尾阶段,不会突然跳变,也不会出现99%卡死的感觉。
另外,进度事件的触发频率也要控制。如果分片小、下载快,每秒钟会触发几十次进度回调,如果每次回调里都做DOM更新或状态更新,性能会很差。我一般在回调里加一个时间过滤,至少间隔200毫秒才允许更新一次进度条。
let lastProgressUpdate = 0; function throttleProgress(percent) { const now = Date.now(); if (now - lastProgressUpdate >= 200) { onProgress(percent); lastProgressUpdate = now; } }3.5 示例页面:在Vue 3里怎么接
放到实际业务页面里,调用方式也很直接。下面是一个Vue 3组合式API的示例:
import { ref } from 'vue'; import { downloadFile } from '@/utils/chunkDownloader'; const progress = ref(0); const downloading = ref(false); async function handleDownload() { downloading.value = true; progress.value = 0; try { await downloadFile({ url: '/api/export/report', fileName: '年度报表.xlsx', chunkSize: 4 * 1024 * 1024, concurrency: 4, onProgress: (percent) => { progress.value = percent; }, }); } finally { downloading.value = false; } }模板里的进度条直接绑定progress就行。这里我再补肾一个坑:当你用fetch请求带认证信息的接口时,记得要在请求配置里带上credentials: 'include',否则如果后端依赖Cookie做鉴权,第一个探测请求就会返回401,整个下载流程直接挂掉。如果是用Bearer Token鉴权,则需要在请求头里动态加上Authorization字段。
4. 进阶之路:断点续传、Web Worker与原理解析
4.1 断点续传:如何让下载中途不慌
严格意义上的断点续传,需要记住“哪些分片已经下载成功”,浏览器刷新或者网络恢复后,从断点继续而不是全部重新下载。前端实现断点续传的思路其实不复杂:用localStorage或IndexedDB记录每个分片的下载状态,下次进入页面时,读取状态,只下载未完成的分片,已下载的分片如果还驻留在本地缓存(比如IndexedDB里的 Blob 对象),就不需要重新请求。
不过这里有一个很大的限制:浏览器内存有限,如果把已下载分片数据持久化到IndexedDB,刷新后再加载回来做合并,数据量大的时候仍然可能出现内存紧张。所以实际项目中,我一般不会把完整的“断点续传”做成默认能力,而是优先保证“失败重试”。只有下载可靠性要求极高的系统(比如大文件下载工具站、网盘工具)才值得投入成本做持久化。
一个简化版的断点续传实现思路:
// 记录分片状态的逻辑 const chunkStatusKey = `download:${url}`; const savedStatus = JSON.parse(localStorage.getItem(chunkStatusKey) || '{}'); // 恢复时,只重新生成未完成的任务 for (let i = 0; i < chunkCount; i += 1) { if (savedStatus[i]) { downloadedBytes += savedStatus[i]; continue; } tasks.push(createChunkTask(i)); }注意,这个方案只记录了每个分片是否完成以及大小,并没有保存实际数据。所以刷新后,已完成的分片可以跳过下载,但因为数据不在本地,合并后文件不完整。要实现真正的断点续传,必须把分片的数据内容也持久化,这就要用到IndexedDB存 Blob。利弊需要根据场景权衡。
4.2 Web Worker:把数据处理扔到后台线程
当文件特别大、分片特别多时,把所有分片下载后的数据处理逻辑放在主线程会有两个问题:一是UI线程容易被占用,导致页面卡顿;二是大数组的拷贝和拼接会有明显的GC压力。把这些活儿丢到Web Worker里,主线程就能保持流畅。
Web Worker的基本用法是:主线程new Worker(worker.js),通过postMessage与 Worker 通信。在下载场景中,可以让 Worker 负责数据的临时存储和Blob合并,主线程只负责发请求和接收进度。
下面是一个简化例子,先看主线程侧:
const worker = new Worker('/worker.js'); worker.onmessage = (event) => { const { type, data } = event.data; if (type === 'PROGRESS') { // 更新进度条 } else if (type === 'DONE') { // data.blob 合并完成,触发下载 } }; // 把分片数据传给 worker 存储 worker.postMessage({ type: 'STORE_CHUNK', index: i, data: arrayBuffer, start: start, });Worker 侧的代码:
// worker.js let totalSize = 0; let buffer = null; self.onmessage = (event) => { const { type } = event.data; if (type === 'INIT') { totalSize = event.data.totalSize; buffer = new Uint8Array(totalSize); } else if (type === 'STORE_CHUNK') { const { index, data, start } = event.data; buffer.set(new Uint8Array(data), start); self.postMessage({ type: 'PROGRESS', index, done: index + 1, }); } else if (type === 'FINISH') { const blob = new Blob([buffer.buffer], { type: 'application/octet-stream' }); self.postMessage({ type: 'DONE', blob }); } };这种方案需要注意一个问题:主线程往Worker传递ArrayBuffer数据时,默认会转移所有权(transferable),发送之后主线程那边的数据引用就变成空的了。如果你还要在主线程做进度计算或别的处理,记得在发送时带上event.data.data的值再拷贝一份,或者干脆用postMessage(msg, [arrayBuffer])的第二参数来显式转移。
4.3 内存占用分析:估算你的浏览器扛不扛得住
你可以在实现之前先算一笔账,看看自己的方案内存是否安全。以2GB文件、4MB分片、4并发为例:
- 进行中的4个分片,每个4MB,每个请求的数据还会额外占用一份ArrayBuffer,所以瞬时最多约4 * 4MB * 2 = 32MB。
- 合并缓冲区固定一个2GB的空间。如果预分配
Uint8Array(2GB),浏览器会真的占用2GB内存。这里要注意,2GB对于大多数现代电脑来说可以接受,但如果是32位系统,内存上限只有4GB,再加上页面其他内容,很容易OOM。 - 如果采用“分片数组逐个存,最后Blob合并”的方式,则总内存占用约等于2倍文件大小(因为所有分片数组加Blob各有一份数据),2GB文件会吃掉4GB左右内存,非常危险。
所以大文件场景下,我更推荐预分配缓冲区方案,它可以把峰值内存控制在“文件大小 + 并发分片大小 + 少量开销”这个量级。真正的极限场景(比如10GB文件),建议走StreamSaver+ service worker的流式落盘方案,或者干脆在后端生成下载链接,引导用户用下载工具下载。
4.4 兼容性维度:哪些浏览器能力需要关注
分片下载依赖的几个核心API,fetch、Blob、URL.createObjectURL、Uint8Array、Web Worker,在主流现代浏览器(Chrome 60+、Firefox 55+、Safari 14+、Edge 80+)里都已经支持得很好了。唯一要注意的是Uint8Array的可转移对象传递,在Safari上对ArrayBuffer转移的处理有一些历史bug,如果线上有大量Safari用户,建议做降级处理:检测Worker不可用或Blob构造异常时,自动回退到普通fetch全量下载。
检测代码倒是很简单:
const isSupportRangeDownload = 'fetch' in window && 'Blob' in window && 'URL' in window && Boolean(window.URL.createObjectURL);如果不满足,就直接用最原始的window.open(url)或<a href="url">下载,功能可用性优先于性能。
5. 常见问题与排查技巧实录
5.1 进度条到99%卡住,然后突然完成
这个问题我在前面提过,最典型的原因是合并写入阶段耗时较长。解决办法是在进度计算中引入“合并进度”权重,把最终收尾的步骤也纳入进度条计算。另外一个常见原因是最后一个分片的end算错了,所有分片都返回了,但是最后一个分片实际内容比预期少,Blob合并后少了一段数据,文件校验失败但进度计算仍然显示99%卡住。排查方法是输出每个分片的实际字节数,和预估值对比,看是否一致。
5.2 下载下来的文件打不开,提示文件已损坏
文件损坏的元凶九成是合并顺序错乱或 Range 区间算错。第一步检查分片写入位置,确认是按start偏移写入而不是按完成顺序append。第二步检查最后一个分片的长度,直接拿totalSize % chunkSize,如果余数是0,说明最后一个分片长度就是chunkSize。第三步检查服务端有没有做gzip压缩,如果服务端对Range请求做了内容压缩,你拿到的Content-Length和原始文件的偏移量就对不上。遇到这种情况,建议在请求头里强制加上Accept-Encoding: identity,禁用压缩。
我实际排查过的一个案例就是服务端 Nginx 开启了gzip on,3GB的压缩包下载后解压失败,改成identity后一切正常。
5.3 下载速度反而不如普通下载
分片下载的优点是稳定和可重试,如果网络很好、带宽充裕,分片下载不会比普通下载更快,甚至因为分片请求的header开销略慢一点。如果你发现分片下载明显慢于普通下载,大概率是并发数太小或分片大小不合理。排查步骤:看一下Network面板里的实际请求耗时,如果每个分片耗时都小于1秒,说明分片太小,请求握手开销占比太高;如果所有分片都排队等待,说明并发数被浏览器限制住了,可以调整并发数,或者把域名切到CDN节点分散连接。
5.4 请求带上了鉴权头,却出现401或403
这个坑出现的频率非常高。用fetch发Range请求时,默认不会携带Cookie,服务端鉴权失败。解决办法是在所有请求(包括探测请求、分片请求)里都加上credentials: 'include'。如果用的是Token鉴权,可以在封装函数里统一加一个headers参数。另外,某些后端的鉴权中间件可能会对Range请求做特殊处理,导致校验顺序出问题。这些情况都要提前和后端同学对齐。
5.5 并发下载导致后端压力过大
前端把并发数设置得很高,多个用户同时触发下载,后端连接数会瞬间飙升。除了前端限制并发数之外,更好的做法是在后端为下载接口设置限流,比如每用户同一时间只能有一个下载任务在“进行中”。通过返回429或一个任务ID,前端轮询任务状态,可以大幅降低错误率和服务端压力。
我项目中就遇到过用户连点三次下载按钮,生成了三批分片请求,把后端文件服务打挂的情况。后来的解决办法是:前端做按钮防抖,后端做token限流,双管齐下才彻底解决。
5.6 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 探测请求返回200而非206 | 服务端不支持Range | 降级为普通下载 |
| 部分分片请求返回416 | Range区间写错或超出文件实际大小 | 检查 end 值,确认总大小 |
| 合并完成但文件损坏 | 分片写入偏移错误或压缩干扰 | 按 start 偏移写入,禁用内容编码 |
| 进度条卡在99% | 合并写入阶段耗时 | 进度公式加入合并权重 |
| 页面崩溃或内存飙升 | 全量数据在内存中驻留 | 改用预分配缓冲区,或StreamSaver |
| 下载文件名乱码 | 中文文件名未编码 | 使用 encodeURIComponent 处理 |
| 请求带鉴权仍401 | 未携带cookie或token | 添加 credentials: 'include' |
| 下载速度缓慢 | 分片太小或并发太低 | 调整分片大小与并发数 |
5.7 一些值得长期沉淀的经验
最后再分享几个我在多个项目里反复验证过的细节。
第一个是“先探测,再下载”这个步骤永远不要省。它既确认了服务端能力,还能免费获得文件总大小,直接决定了后面的分片策略。一条请求的成本极低,收益却很大。
第二个是分片下载适合在前端做,但没必要追求“所有文件都走前端分片”。如果文件达到了好几个GB,前端做分片在内存上始终有天花板,更稳的做法是后端生成一个临时下载链接,配合Content-Disposition头让浏览器直接拉起下载工具。前端分片下载的最佳应用区间是100MB到2GB之间,这个区间里体验和性能都能兼顾。
第三个是代码要设计成“可插拔”的。我建议把探测、分片任务生成、并发调度、合并、触发下载这五个步骤拆成独立函数,方便后续扩展。比如以后想换用StreamSaver,只需要替换“合并数据并触发下载”这一步,别的逻辑完全不用动。这种松耦合的设计在实际维护中会省很多事。
如果你手头正好要处理大文件下载的需求,我建议你先把探测和并发调度这两个基础能力写好,跑通一个200MB的测试文件,再逐步叠加断点续传、Web Worker等高级功能。分片下载不是一个难啃的骨头,但它里面有大量细节,踩过坑才真正理解。上面这些内容是我踩过坑之后的总结,希望能帮你少走点弯路。