对某个业务的导出需求,JavaScript能不能实现对数据进行分块下载,来实现显示进度条?假设是真实存在的文件,但是下载接口是下载流,哪种方法合适?
是的,JavaScript 完全可以实现对数据进行分块下载,并以此来显示进度条。
这在处理大数据量导出(如导出数万条数据为 Excel 文件)时是一个非常有效的方案,可以避免浏览器因一次性处理大量数据而卡顿或无响应。
💡 核心实现思路
整个流程可以分为三个主要步骤:
- 数据分块 (Chunking)
将需要导出的大量数据分割成多个较小的数据块(例如,每 5000 条数据为一个块)。 - 分块处理与进度更新 (Processing & Progress)
通过一个循环,逐个或并发地获取并处理这些数据块。每处理完一个块,就根据已完成的块数量计算出当前的进度百分比,并更新进度条的显示。 - 合并与生成文件 (Merging & Generation)
当所有数据块都处理完毕后,将它们合并成一个完整的数据集,然后使用如xlsx等库生成最终的 Excel 文件,并触发浏览器下载。
🛠️ 技术实现方案
一个典型的实现会结合使用 Vue/React 等前端框架和 xlsx、file-saver 等库。
- 进度显示: 使用 UI 组件库(如 Ant Design Vue 的
Progress组件)来展示一个美观的进度条。 - 数据获取: 通过
fetch或axios等工具,配合Range请求头,可以实现真正的文件分块下载。或者,如果是前端生成数据,则是在内存中对数据进行分片处理。 - 文件生成: 使用
xlsx库,可以先创建一个工作簿(Workbook),然后通过sheet_add_json等方法将每个数据块逐步添加到工作表中,最后生成文件。
下面是一个简化的逻辑示例,展示了如何通过一个自定义 Hook 来实现带进度条的导出功能:
// 一个简化的逻辑示例
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
const exportDataWithProgress = async (allData, chunkSize = 5000) => {
const totalChunks = Math.ceil(allData.length / chunkSize);
let workbook = XLSX.utils.book_new();
let worksheet = XLSX.utils.aoa_to_sheet([]);
XLSX.utils.book_append_sheet(workbook, worksheet, '报表');
for (let i = 0; i < totalChunks; i++) {
// 1. 获取当前数据块
const start = i * chunkSize;
const end = start + chunkSize;
const currentChunk = allData.slice(start, end);
// 2. 将当前数据块追加到工作表
const startRow = worksheet['!ref'] ? worksheet['!ref'].split(':').replace(/[^0-9]/g, '') * 1 + 1 : 1;
XLSX.utils.sheet_add_json(worksheet, currentChunk, { origin: startRow, skipHeader: true });[[source_group_web_9]]
// 3. 更新进度
const progress = Math.round(((i + 1) / totalChunks) * 100);
console.log(`当前进度: ${progress}%`); // 这里可以更新UI进度条
}
// 4. 所有块处理完毕,生成并下载文件
const blob = new Blob([XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
saveAs(blob, '大数据报表.xlsx');
};
通过这种方式,用户就能清晰地看到导出的实时进度,极大地提升了大数据量操作下的用户体验。
明白了,你的场景是:后端生成流(InputStream),前端接收流并下载,同时需要展示进度条。
针对后端返回流的情况,前端实现进度条的核心难点在于:浏览器原生的 axios 或 fetch 请求在接收流(Blob/Stream)时,通常无法直接获取“已接收字节数”的实时回调。
为了解决这个问题,通常有以下三种成熟的方案,按推荐程度排序:
方案一:双请求模式(最通用、最稳妥)
这是目前前后端分离架构中最常用的方案。既然流本身难以实时计算进度,那就通过“元数据”来解决。
核心逻辑:
- 请求 A(获取信息): 先请求一个接口,获取文件的总大小(
totalSize)。 - 请求 B(下载流): 发起下载请求,利用 XHR 的
onprogress事件(或者 fetch 的变通方式)来监听已加载的数据量(loaded)。 - 计算进度:
进度 = (已加载 / 总大小) * 100。
代码实现思路 (基于 Axios):
import axios from 'axios';
// 1. 先获取文件大小 (后端需提供一个接口返回文件元信息)
const getFileSize = (fileId) => {
return axios.get(`/api/file/info/${fileId}`).then(res => res.data.size);
};
// 2. 下载并监听进度
const downloadWithProgress = async (fileId, fileName) => {
// 获取总大小
const totalSize = await getFileSize(fileId);
// 创建下载请求
axios({
method: 'get',
url: `/api/file/download/${fileId}`,
responseType: 'blob', // 关键:接收二进制流
onDownloadProgress: (progressEvent) => {
// 计算进度百分比
const percentCompleted = Math.round((progressEvent.loaded * 100) / totalSize);
console.log(`下载进度: ${percentCompleted}%`);
// 这里更新你的 UI 进度条组件
updateProgressBar(percentCompleted);
}
}).then(response => {
// 下载完成,触发保存
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
});
};
方案二:HTTP Range 分片下载(最精准,支持断点续传)
如果你的文件非常大(几百 MB 甚至 GB),或者网络不稳定,建议使用分片下载。这完全符合你提到的“分块”概念。
核心逻辑:
- 前端不一次性请求整个流,而是通过循环,每次请求文件的一部分(例如每次请求 5MB)。
- 利用 HTTP 协议头的
Range: bytes=start-end。 - 后端需要支持
Range请求(SpringBoot 等框架通常默认支持,返回状态码206 Partial Content)。 - 前端将下载下来的多个 Blob 块存入数组,全部下载完后合并成一个 Blob 并保存。
优点: 进度条是绝对精准的(比如下载了 3 个片,总共 10 个片,就是 30%),且支持暂停和续传。
代码实现思路:
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 一个分片
async function downloadChunks(url, fileName, totalSize) {
let downloadedChunks = [];
let downloadedBytes = 0;
while (downloadedBytes < totalSize) {
// 计算当前分片的起止位置
const start = downloadedBytes;
const end = Math.min(downloadedBytes + CHUNK_SIZE - 1, totalSize - 1);
// 发起分片请求
const response = await fetch(url, {
headers: {
'Range': `bytes=${start}-${end}`
}
});
const blob = await response.blob();
downloadedChunks.push(blob);
// 更新进度
downloadedBytes += blob.size;
const percent = (downloadedBytes / totalSize) * 100;
updateProgressBar(percent.toFixed(0));
}
// 合并所有分片
const finalBlob = new Blob(downloadedChunks);
saveAs(finalBlob, fileName); // 需引入 file-saver
}
方案三:流式读取(Fetch + ReadableStream)
如果你使用的是现代浏览器,且后端返回的是标准的流(Transfer-Encoding: chunked),可以使用 fetch 的 ReadableStream API。
注意: 这种方式很难准确计算百分比进度,除非后端在每个 Chunk 中包含了特定的长度信息,或者你只是做一个“无限加载”的动画,而不是具体的百分比。因为流式传输往往不知道 Content-Length。
适用场景: 实时日志导出、后端慢慢生成数据的场景(SSE),而不是下载一个已知大小的静态文件。
async function streamDownload(url, fileName) {
const response = await fetch(url);
const reader = response.body.getReader();
const contentLength = +response.headers.get('Content-Length'); // 如果后端能返回长度
let receivedLength = 0;
let chunks = [];
while(true) {
const {done, value} = await reader.read();
if (done) break;
chunks.push(value);
receivedLength += value.length;
// 如果知道总长度,可以计算进度
if (contentLength) {
const percent = (receivedLength / contentLength) * 100;
updateProgressBar(percent);
}
}
// 合并并下载...
}
总结建议
- 首选方案一(双请求):如果你的文件在几百兆以内,且后端能提供一个获取文件大小的接口,这是开发成本最低、兼容性最好的方式。
- 次选方案二(Range 分片):如果文件超大(GB 级),或者需要支持“暂停/继续下载”,请使用分片下载。这完美契合你“分块”的需求。
- 关于后端:请确保后端在返回流时,响应头中正确设置了
Content-Length(方案一需要)或者支持Range头(方案二需要)。
这是一个非常“懂行”的问题!既然后端是流式输出(Stream),且无法预知总大小(导致无法计算百分比),你想通过**“已知总条数”**来反推进度,这完全是可行的。
这种“投机取巧”的方法在业内通常被称为**“基于业务逻辑的进度估算”**。
这里有三种不同“投机”程度的方案,按推荐程度排序:
🕵️ 方案一:利用响应头“偷看”总条数(最优雅)
虽然后端返回的是流,但在 HTTP 响应头(Headers)里,后端是可以塞入自定义字段的。
原理:
后端在开始写流之前,先往响应头里塞一个自定义字段,比如 X-Total-Count: 10000。前端在接收到流的瞬间(response 对象),先读取这个头,拿到总条数,然后后续每接收到一块数据,就根据数据量估算当前处理了多少条。
前端实现逻辑:
async function downloadWithHeaderEstimate(url) {
const response = await fetch(url);
// 1. 【投机点】从响应头里“偷”到总条数
const totalCount = response.headers.get('X-Total-Count');
if (!totalCount) {
console.warn("后端没给总条数,只能显示loading动画了");
return;
}
const reader = response.body.getReader();
let receivedLength = 0;
const chunkSize = 1024; // 假设每条数据大概 1KB(这个值需要根据实际业务调整,或者后端直接返回总字节数)
while(true) {
const {done, value} = await reader.read();
if (done) break;
receivedLength += value.length;
// 2. 【投机点】倒推进度
// 估算当前处理了多少条 = 已接收字节 / 预估单条字节
// 或者更简单:直接用 接收字节 / (总条数 * 预估单条字节)
const estimatedTotalBytes = totalCount * chunkSize;
const progress = Math.min(99, (receivedLength / estimatedTotalBytes) * 100); // 限制最大99%,防止溢出
updateProgressBar(progress);
}
updateProgressBar(100); // 完成
}
- 优点:进度条看起来非常真实。
- 缺点:需要后端配合加一行代码设置响应头。
📊 方案二:基于“平均字节数”的纯前端估算(无需后端配合)
如果后端完全改不了,你只知道“用户勾选了 10,000 条数据导出”,你可以利用历史平均值来造假。
原理:
假设根据经验,导出一条 Excel 数据平均占用 500 字节(或者 200 字节,取决于你的数据字段多少)。
那么:预计总大小 = 请求参数中的条数 * 500。
前端实现逻辑:
const REQUESTED_COUNT = 10000; // 用户勾选的条数
const AVG_BYTES_PER_ROW = 500; // 【投机点】这是一个经验值,需要调试得出
const ESTIMATED_TOTAL_SIZE = REQUESTED_COUNT * AVG_BYTES_PER_ROW;
async function downloadWithFakeTotal(url) {
const response = await fetch(url);
const reader = response.body.getReader();
let receivedLength = 0;
while(true) {
const {done, value} = await reader.read();
if (done) break;
receivedLength += value.length;
// 计算百分比
let percent = (receivedLength / ESTIMATED_TOTAL_SIZE) * 100;
// 【投机点】防止最后卡顿或超出,做个平滑处理
if (percent >= 100) percent = 99;
updateProgressBar(percent.toFixed(1));
}
updateProgressBar(100);
}
- 优点:完全不需要后端改动。
- 缺点:进度条可能不准。比如前 50% 的数据字段很少,进度条会跑得飞快;后 50% 数据字段多,进度条会卡住。
🎭 方案三:非线性的“假装”进度条(纯 UI 欺骗)
如果你连估算都不想估算,只想让用户觉得“没卡死”,可以使用非线性进度条。
原理:
不管数据来没来,先让进度条跑起来。
- 开始阶段:迅速跑到 30%(给用户信心)。
- 中间阶段:利用
setInterval缓慢增加,模拟正在努力工作的样子。 - 结束阶段:一旦流结束(
done为 true),瞬间跳到 100%。
代码逻辑:
let fakeProgress = 0;
let timer = null;
function startFakeProgress() {
fakeProgress = 0;
timer = setInterval(() => {
// 随机增加进度,越往后增加越慢,模拟“正在处理大量数据”
fakeProgress += Math.random() * 2;
if (fakeProgress > 90) fakeProgress = 90; // 卡在 90% 等待完成
updateProgressBar(fakeProgress);
}, 500);
}
function stopFakeProgress() {
clearInterval(timer);
updateProgressBar(100); // 瞬间完成
}
// 使用
startFakeProgress();
fetch(url).then(res => {
// 这里只负责接收流,不计算具体进度
return res.blob();
}).then(() => {
stopFakeProgress();
});
- 优点:用户体验好,感觉很快。
- 缺点:纯属“障眼法”,如果网络真的断了,进度条可能卡在 90% 很久,用户会以为卡死了。
📌 总结建议
针对你的需求,方案二(基于平均字节数估算) 是最符合“投机取巧”且具备一定真实性的方法。
你只需要:
- 拿到用户请求导出的总条数(比如前端传参
count=5000)。 - 在本地定义一个常量
AVG_SIZE = 300(根据你导出的列数大概估算,比如一行 Excel 大概 300 字节)。 - 用
总条数 * AVG_SIZE作为分母,用fetch接收到的loaded作为分子,就能画出一个看起来很像真的进度条了。
针对“真实存在的文件”但通过“流接口”下载的场景,**方案一(双请求模式)和方案二(Range 分片下载)**是最合适的,具体取决于文件大小和对精度的要求。
🎯 方案一:双请求模式(最推荐,性价比最高)
这是处理真实文件下载最通用、开发成本最低的方案。
核心逻辑:
- 请求 A(获取元数据): 先请求一个接口(或者对下载链接发一个
HEAD请求),获取文件的真实大小(Content-Length)和文件名。 - 请求 B(下载流): 发起真正的下载请求,利用
axios的onDownloadProgress或fetch的流式读取,结合步骤 1 拿到的总大小,实时计算百分比。
为什么适合“真实文件”?
因为真实文件在服务器上是静态的,它的大小是固定的。后端可以非常轻松地通过 file.length() 或者 HTTP 头返回这个数值。
代码实现(基于 Axios):
import axios from 'axios';
// 假设后端提供了一个获取文件信息的接口
const getFileInfo = (fileId) => axios.get(`/api/file/info/${fileId}`);
const downloadRealFile = async (fileId) => {
try {
// 1. 【关键】先获取文件真实大小
const infoRes = await getFileInfo(fileId);
const totalSize = infoRes.data.size; // 例如:10485760 (10MB)
const fileName = infoRes.data.name;
// 2. 发起下载请求
const downloadRes = await axios({
url: `/api/file/download/${fileId}`,
method: 'GET',
responseType: 'blob', // 接收二进制流
onDownloadProgress: (progressEvent) => {
// 3. 【计算进度】利用真实的总大小计算
const percentCompleted = Math.round((progressEvent.loaded * 100) / totalSize);
console.log(`下载进度: ${percentCompleted}%`);
updateProgressBar(percentCompleted); // 更新 UI
}
});
// 4. 触发保存
const url = window.URL.createObjectURL(new Blob([downloadRes.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
console.error('下载失败', error);
}
};
🧩 方案二:Range 分片下载(适合超大文件/断点续传)
如果你的“真实文件”非常大(比如超过 500MB 甚至 1GB),或者网络环境较差,方案一可能会导致内存溢出或下载失败,这时分片下载是最佳选择。
核心逻辑:
- 前端通过
HEAD请求获取文件总大小。 - 利用 HTTP 协议的
Range头,告诉服务器:“我只要第 0-5MB 的数据”、“我只要第 5-10MB 的数据”。 - 后端(Nginx/Java/Go 等)通常原生支持
Range请求,直接返回206 Partial Content。 - 前端并发或串行下载这些“块”,全部下完后用
Blob合并。
优点:
- 进度条绝对精准:下载了 3 个块,总共 10 个块,就是 30%。
- 内存友好:不会一次性把 1GB 文件加载到内存,而是分块处理。
- 支持断点续传:如果下载到 50% 断了,下次可以只请求剩下的 50%。
代码实现思路:
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 一片
async function downloadWithRange(url, fileName) {
// 1. 获取文件总大小
const headRes = await fetch(url, { method: 'HEAD' });
const totalSize = parseInt(headRes.headers.get('content-length'));
const chunks = [];
let currentPos = 0;
// 2. 循环分片请求
while (currentPos < totalSize) {
const end = Math.min(currentPos + CHUNK_SIZE - 1, totalSize - 1);
const response = await fetch(url, {
headers: {
'Range': `bytes=${currentPos}-${end}` // 关键:请求指定范围
}
});
const blob = await response.blob();
chunks.push(blob);
// 3. 更新进度
currentPos += blob.size;
const percent = (currentPos / totalSize) * 100;
updateProgressBar(percent);
}
// 4. 合并并下载
const finalBlob = new Blob(chunks);
saveAs(finalBlob, fileName);
}
⚠️ 不推荐:基于流读取的“估算”
虽然你之前问过“投机取巧”的方法,但对于真实文件,不建议使用“预估平均字节数”的方法。
原因:
真实文件的大小是已知且固定的。既然后端能读文件流,它一定能拿到 file.length。如果后端不愿意配合返回总大小,前端可以通过 HEAD 请求自己拿(方案二的第一步)。在这种情况下,再去用“假数据”估算进度,既不准确也没必要。
📌 总结建议
- 常规文件(< 200MB): 请直接使用 方案一(双请求)。先
HEAD或调用元数据接口拿Content-Length,再用axios下载。简单、稳定。 - 超大文件(> 500MB): 请使用 方案二(Range 分片)。利用 HTTP 原生支持的分片机制,既精准又不容易崩。
posted on 2026-05-18 16:55 fox_charon 阅读(2) 评论(0) 收藏 举报
浙公网安备 33010602011771号