对某个业务的导出需求,JavaScript能不能实现对数据进行分块下载,来实现显示进度条?假设是真实存在的文件,但是下载接口是下载流,哪种方法合适?

是的,JavaScript 完全可以实现对数据进行分块下载,并以此来显示进度条。

这在处理大数据量导出(如导出数万条数据为 Excel 文件)时是一个非常有效的方案,可以避免浏览器因一次性处理大量数据而卡顿或无响应。

💡 核心实现思路

整个流程可以分为三个主要步骤:

  1. 数据分块 (Chunking)
    将需要导出的大量数据分割成多个较小的数据块(例如,每 5000 条数据为一个块)。
  2. 分块处理与进度更新 (Processing & Progress)
    通过一个循环,逐个或并发地获取并处理这些数据块。每处理完一个块,就根据已完成的块数量计算出当前的进度百分比,并更新进度条的显示。
  3. 合并与生成文件 (Merging & Generation)
    当所有数据块都处理完毕后,将它们合并成一个完整的数据集,然后使用如 xlsx 等库生成最终的 Excel 文件,并触发浏览器下载。

🛠️ 技术实现方案

一个典型的实现会结合使用 Vue/React 等前端框架和 xlsxfile-saver 等库。

  • 进度显示: 使用 UI 组件库(如 Ant Design Vue 的 Progress 组件)来展示一个美观的进度条。
  • 数据获取: 通过 fetchaxios 等工具,配合 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),前端接收流并下载,同时需要展示进度条

针对后端返回流的情况,前端实现进度条的核心难点在于:浏览器原生的 axiosfetch 请求在接收流(Blob/Stream)时,通常无法直接获取“已接收字节数”的实时回调

为了解决这个问题,通常有以下三种成熟的方案,按推荐程度排序:

方案一:双请求模式(最通用、最稳妥)

这是目前前后端分离架构中最常用的方案。既然流本身难以实时计算进度,那就通过“元数据”来解决。

核心逻辑:

  1. 请求 A(获取信息): 先请求一个接口,获取文件的总大小(totalSize)。
  2. 请求 B(下载流): 发起下载请求,利用 XHR 的 onprogress 事件(或者 fetch 的变通方式)来监听已加载的数据量(loaded)。
  3. 计算进度: 进度 = (已加载 / 总大小) * 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),或者网络不稳定,建议使用分片下载。这完全符合你提到的“分块”概念。

核心逻辑:

  1. 前端不一次性请求整个流,而是通过循环,每次请求文件的一部分(例如每次请求 5MB)。
  2. 利用 HTTP 协议头的 Range: bytes=start-end
  3. 后端需要支持 Range 请求(SpringBoot 等框架通常默认支持,返回状态码 206 Partial Content)。
  4. 前端将下载下来的多个 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),可以使用 fetchReadableStream 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);
    }
  }
  
  // 合并并下载...
}

总结建议

  1. 首选方案一(双请求):如果你的文件在几百兆以内,且后端能提供一个获取文件大小的接口,这是开发成本最低、兼容性最好的方式。
  2. 次选方案二(Range 分片):如果文件超大(GB 级),或者需要支持“暂停/继续下载”,请使用分片下载。这完美契合你“分块”的需求。
  3. 关于后端:请确保后端在返回流时,响应头中正确设置了 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 欺骗)

如果你连估算都不想估算,只想让用户觉得“没卡死”,可以使用非线性进度条

原理:
不管数据来没来,先让进度条跑起来。

  1. 开始阶段:迅速跑到 30%(给用户信心)。
  2. 中间阶段:利用 setInterval 缓慢增加,模拟正在努力工作的样子。
  3. 结束阶段:一旦流结束(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% 很久,用户会以为卡死了。

📌 总结建议

针对你的需求,方案二(基于平均字节数估算) 是最符合“投机取巧”且具备一定真实性的方法。

你只需要:

  1. 拿到用户请求导出的总条数(比如前端传参 count=5000)。
  2. 在本地定义一个常量 AVG_SIZE = 300(根据你导出的列数大概估算,比如一行 Excel 大概 300 字节)。
  3. 总条数 * AVG_SIZE 作为分母,用 fetch 接收到的 loaded 作为分子,就能画出一个看起来很像真的进度条了。

针对“真实存在的文件”但通过“流接口”下载的场景,**方案一(双请求模式)方案二(Range 分片下载)**是最合适的,具体取决于文件大小和对精度的要求。


🎯 方案一:双请求模式(最推荐,性价比最高)

这是处理真实文件下载最通用、开发成本最低的方案。

核心逻辑:

  1. 请求 A(获取元数据): 先请求一个接口(或者对下载链接发一个 HEAD 请求),获取文件的真实大小(Content-Length)和文件名。
  2. 请求 B(下载流): 发起真正的下载请求,利用 axiosonDownloadProgressfetch 的流式读取,结合步骤 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),或者网络环境较差,方案一可能会导致内存溢出或下载失败,这时分片下载是最佳选择。

核心逻辑:

  1. 前端通过 HEAD 请求获取文件总大小。
  2. 利用 HTTP 协议的 Range 头,告诉服务器:“我只要第 0-5MB 的数据”、“我只要第 5-10MB 的数据”。
  3. 后端(Nginx/Java/Go 等)通常原生支持 Range 请求,直接返回 206 Partial Content
  4. 前端并发或串行下载这些“块”,全部下完后用 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 请求自己拿(方案二的第一步)。在这种情况下,再去用“假数据”估算进度,既不准确也没必要。

📌 总结建议

  1. 常规文件(< 200MB): 请直接使用 方案一(双请求)。先 HEAD 或调用元数据接口拿 Content-Length,再用 axios 下载。简单、稳定。
  2. 超大文件(> 500MB): 请使用 方案二(Range 分片)。利用 HTTP 原生支持的分片机制,既精准又不容易崩。

posted on 2026-05-18 16:55  fox_charon  阅读(2)  评论(0)    收藏  举报

导航