django+vue3实现前后端大文件分片下载

[复制链接]
发表于 2025-10-13 20:00:08 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
效果:

大文件分片下载支持的功能:


  • 展示目标文件信息
  • 进步下载速率:通过并发哀求多个块,可以更有效地利用网络带宽
  • 断点续传:支持停息后从已下载部分继承,无需重新开始
  • 错误规复:单个块下载失败只需重试该块,而不是整个文件
  • 更好的用户体验:及时表现下载进度、速率和预计剩余时间
  • 内存服从:通太过块下载和处理处罚,淘汰了一次性内存占用
大文件分片下载

前端处理处罚流程:
   后端处理处罚流程:
   django代码

1,代码

  1. # settings.py
  2. # 指定文件访问的 URL 前缀
  3. MEDIA_URL = '/media/'
  4. MEDIA_ROOT = BASE_DIR / 'media/'
  5. # views.py
  6. import os
  7. import mimetypes
  8. from django.conf import settings
  9. from django.http import StreamingHttpResponse, JsonResponse, HttpResponse
  10. from django.utils.http import http_date
  11. from django.views.decorators.http import require_http_methods
  12. def get_file_info(file_path):
  13.     """
  14.     获取文件信息:
  15.     - name: 文件名
  16.     - size: 文件大小,单位字节
  17.     - type: 文件类型
  18.     """
  19.     if not os.path.exists(file_path):
  20.         return None
  21.     file_size = os.path.getsize(file_path)
  22.     file_name = os.path.basename(file_path)
  23.     content_type, encoding = mimetypes.guess_type(file_path)
  24.     return {
  25.         'name': file_name,
  26.         'size': file_size,
  27.         'type': content_type or 'application/octet-stream'
  28.     }
  29. @require_http_methods(["GET"])
  30. def file_info(request):
  31.     """获取文件信息API"""
  32.     file_path = os.path.join(settings.MEDIA_ROOT, "user_info_big.csv")
  33.     info = get_file_info(file_path)
  34.     if info is None:
  35.         return JsonResponse({"error": "File not found"}, status=404)
  36.     return JsonResponse(info)
  37. @require_http_methods(["GET"])
  38. def download_large_file(request):
  39.     """
  40.     分片下载文件的API
  41.     :param request: 请求对象
  42.     :return: 文件流
  43.     """
  44.     file_path = os.path.join(settings.MEDIA_ROOT, "user_info_big.csv")
  45.     # 1,检查文件是否存在
  46.     if not os.path.exists(file_path):
  47.         return HttpResponse("File not found", status=404)
  48.     # 2,获取文件信息
  49.     file_size = os.path.getsize(file_path)
  50.     file_name = os.path.basename(file_path)
  51.     content_type, encoding = mimetypes.guess_type(file_path)
  52.     content_type = content_type or 'application/octet-stream'
  53.     # 3,获取请求中的Range头
  54.     range_header = request.META.get('HTTP_RANGE', '').strip()
  55.     # 格式:bytes=0-100
  56.     range_match = range_header.replace('bytes=', '').split('-')
  57.     # 起始位置
  58.     range_start = int(range_match[0]) if range_match[0] else 0
  59.     # 结束位置
  60.     range_end = int(range_match[1]) if range_match[1] else file_size - 1
  61.     # 4,确保范围合法
  62.     range_start = max(0, range_start)
  63.     range_end = min(file_size - 1, range_end)
  64.     # 5,计算实际要发送的数据大小
  65.     content_length = range_end - range_start + 1
  66.     # 6,创建响应:使用StreamingHttpResponse,将文件流式传输。206表示部分内容,200表示全部内容
  67.     response = StreamingHttpResponse(
  68.         file_iterator(file_path, range_start, range_end, chunk_size=8192),
  69.         status=206 if range_header else 200,
  70.         content_type=content_type
  71.     )
  72.     # 7,设置响应头
  73.     response['Content-Length'] = content_length
  74.     response['Accept-Ranges'] = 'bytes'
  75.     response['Content-Disposition'] = f'attachment; filename="{file_name}"'
  76.     if range_header:
  77.         response['Content-Range'] = f'bytes {range_start}-{range_end}/{file_size}'
  78.     response['Last-Modified'] = http_date(os.path.getmtime(file_path))
  79.     # 模拟处理延迟,方便测试暂停/继续功能
  80.     # time.sleep(0.1)  # 取消注释以添加人为延迟
  81.     # 8,返回响应
  82.     return response
  83. def file_iterator(file_path, start_byte=0, end_byte=None, chunk_size=8192):
  84.     """
  85.     文件读取迭代器
  86.     :param file_path: 文件路径
  87.     :param start_byte: 起始字节
  88.     :param end_byte: 结束字节
  89.     :param chunk_size: 块大小
  90.     """
  91.     with open(file_path, 'rb') as f:
  92.         # 移动到起始位置
  93.         f.seek(start_byte)
  94.         # 计算剩余字节数
  95.         remaining = end_byte - start_byte + 1 if end_byte else None
  96.         while True:
  97.             if remaining is not None:
  98.                 # 如果指定了结束位置,则读取剩余字节或块大小,取小的那个
  99.                 bytes_to_read = min(chunk_size, remaining)
  100.                 if bytes_to_read <= 0:
  101.                     break
  102.             else:
  103.                 # 否则读取指定块大小
  104.                 bytes_to_read = chunk_size
  105.             data = f.read(bytes_to_read)
  106.             if not data:
  107.                 break
  108.             yield data
  109.             if remaining is not None:
  110.                 remaining -= len(data)
  111. # proj urls.py
  112. from django.urls import path, include
  113. urlpatterns = [
  114.     # 下载文件
  115.     path('download/', include(('download.urls', 'download'), namespace='download')),
  116. ]
  117. # download.urls.py
  118. from django.urls import path
  119. from download import views
  120. urlpatterns = [
  121.     path('large_file/file_info/', views.file_info, name='file_info'),
  122.     path('large_file/download_large_file/', views.download_large_file, name='download_large_file'),
  123. ]
复制代码
2,焦点功能分析

(1)file_info 端点 - 获取文件元数据

这个端点提供文件的根本信息,让前端可以大概规划下载计谋:


  • 功能:返回文件名称、巨细和MIME范例
  • 用途:前端根据文件巨细和设置的块巨细盘算出必要下载的分块数目
(2)download_large_file 端点 - 实现分片下载

这是实现分片下载的焦点API,通过HTTP Range哀求实现:
1,分析Range头:从HTTP_RANGE头部分析客户端哀求的字节范围
  1. range_header = request.META.get('HTTP_RANGE', '').strip()
  2. range_match = range_header.replace('bytes=', '').split('-')
  3. range_start = int(range_match[0]) if range_match[0] else 0
  4. range_end = int(range_match[1]) if range_match[1] else file_size - 1
复制代码
2,流式传输:利用StreamingHttpResponse和迭代器按块读取和传输文件,克制一次加载整个文件到内存
  1. response = StreamingHttpResponse(
  2.     file_iterator(file_path, range_start, range_end, chunk_size=8192),
  3.     status=206 if range_header else 200,
  4.     content_type=content_type
  5. )
复制代码
3,返反相应头:设置须要的相应头,包罗Content-Range指示返回内容的范围
  1. response['Content-Range'] = f'bytes {range_start}-{range_end}/{file_size}'
复制代码
(3)file_iterator 函数 - 高效的文件读取

这个函数创建一个迭代器,高效地读取文件的指定部分:
1,文件定位:将文件指针移动到哀求的起始位置
  1. f.seek(start_byte)
复制代码
2,分块读取:按指定的块巨细读取文件,克制一次性读取大量数据
  1. data = f.read(bytes_to_read)
复制代码
3,界限控制:确保只读取哀求范围内的数据
  1. remaining -= len(data)
复制代码
HTTP状态码和相应头的作用

1,206 Partial Content:


  • 体现服务器乐成处理处罚了部分GET哀求
  • 分片下载的标准HTTP状态码
2,Content-Range: bytes start-end/total:


  • 指示相应中包罗的字节范围和文件总巨细
  • 资助客户端确认吸取的是哪部分数据
3,Accept-Ranges: bytes:


  • 表明服务器支持范围哀求
  • 让客户端知道可以利用Range头哀求部分内容
4,Content-Length:


  • 体现当前相应内容的长度
  • 不是文件总长度,而是本次返回的片断长度
vue3代码

1,代码

1,前端界面 (Vue组件):


  • 提供设置选项:并发块数、块巨细
  • 表现下载进度:进度条、已下载量、下载速率、剩余时间提供操纵按钮:开始、停息、继承、取消
  • 可视化表现每个分块的下载状态
  1. <template>
  2.   <div class="enhanced-downloader">
  3.     <div class="card">
  4.       <h2>大文件分块下载</h2>
  5.       <div class="file-info" v-if="fileInfo">
  6.         <p><strong>文件名:</strong> {{ fileInfo.name }}</p>
  7.         <p><strong>文件大小:</strong> {{ formatFileSize(fileInfo.size) }}</p>
  8.         <p><strong>类型:</strong> {{ fileInfo.type }}</p>
  9.       </div>
  10.       <div class="config-panel" v-if="!isDownloading && !isPaused">
  11.         <div class="config-item">
  12.           <label>并发块数:</label>
  13.           <select v-model="concurrency">
  14.             <option :value="1">1</option>
  15.             <option :value="2">2</option>
  16.             <option :value="3">3</option>
  17.             <option :value="5">5</option>
  18.             <option :value="8">8</option>
  19.           </select>
  20.         </div>
  21.         <div class="config-item">
  22.           <label>块大小:</label>
  23.           <select v-model="chunkSize">
  24.             <option :value="512 * 1024">512 KB</option>
  25.             <option :value="1024 * 1024">1 MB</option>
  26.             <option :value="2 * 1024 * 1024">2 MB</option>
  27.             <option :value="5 * 1024 * 1024">5 MB</option>
  28.           </select>
  29.         </div>
  30.       </div>
  31.       <div class="progress-container" v-if="isDownloading || isPaused">
  32.         <div class="progress-bar">
  33.           <div class="progress" :style="{ width: `${progress}%` }"></div>
  34.         </div>
  35.         <div class="progress-stats">
  36.           <div class="stat-item">
  37.             <span class="label">进度:</span>
  38.             <span class="value">{{ progress.toFixed(2) }}%</span>
  39.           </div>
  40.           <div class="stat-item">
  41.             <span class="label">已下载:</span>
  42.             <span class="value">{{ formatFileSize(downloadedBytes) }} / {{ formatFileSize(totalBytes) }}</span>
  43.           </div>
  44.           <div class="stat-item">
  45.             <span class="label">速度:</span>
  46.             <span class="value">{{ downloadSpeed }}</span>
  47.           </div>
  48.           <div class="stat-item">
  49.             <span class="label">已完成块:</span>
  50.             <span class="value">{{ downloadedChunks }} / {{ totalChunks }}</span>
  51.           </div>
  52.           <div class="stat-item">
  53.             <span class="label">剩余时间:</span>
  54.             <span class="value">{{ remainingTime }}</span>
  55.           </div>
  56.         </div>
  57.       </div>
  58.       <div class="chunk-visualization" v-if="isDownloading || isPaused">
  59.         <div class="chunk-grid">
  60.           <div
  61.               v-for="(chunk, index) in chunkStatus"
  62.               :key="index"
  63.               class="chunk-block"
  64.               :class="{
  65.               'downloaded': chunk === 'completed',
  66.               'downloading': chunk === 'downloading',
  67.               'pending': chunk === 'pending',
  68.               'error': chunk === 'error'
  69.             }"
  70.               :title="`块 ${index + 1}: ${chunk}`"
  71.           ></div>
  72.         </div>
  73.       </div>
  74.       <div class="actions">
  75.         <button
  76.             @click="startDownload"
  77.             :disabled="isDownloading"
  78.             v-if="!isDownloading && !isPaused"
  79.             class="btn btn-primary">
  80.           开始下载
  81.         </button>
  82.         <button
  83.             @click="pauseDownload"
  84.             :disabled="!isDownloading"
  85.             v-if="isDownloading && !isPaused"
  86.             class="btn btn-warning">
  87.           暂停
  88.         </button>
  89.         <button
  90.             @click="resumeDownload"
  91.             :disabled="!isPaused"
  92.             v-if="isPaused"
  93.             class="btn btn-success">
  94.           继续
  95.         </button>
  96.         <button
  97.             @click="cancelDownload"
  98.             :disabled="!isDownloading && !isPaused"
  99.             class="btn btn-danger">
  100.           取消
  101.         </button>
  102.       </div>
  103.       <div class="status-message" v-if="statusMessage">
  104.         {{ statusMessage }}
  105.       </div>
  106.     </div>
  107.   </div>
  108. </template>
  109. <script setup>
  110. import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
  111. import {ChunkDownloader} from './downloadService';
  112. // API URL
  113. const API_BASE_URL = 'http://localhost:8000/download/';
  114. // 下载配置
  115. const concurrency = ref(3);
  116. const chunkSize = ref(1024 * 1024); // 1MB
  117. const downloader = ref(null);
  118. // 状态变量
  119. const fileInfo = ref(null);
  120. const isDownloading = ref(false);
  121. const isPaused = ref(false);
  122. const downloadedBytes = ref(0);
  123. const totalBytes = ref(0);
  124. const downloadedChunks = ref(0);
  125. const totalChunks = ref(0);
  126. const statusMessage = ref('准备就绪');
  127. const downloadStartTime = ref(0);
  128. const lastUpdateTime = ref(0);
  129. const lastBytes = ref(0);
  130. const downloadSpeed = ref('0 KB/s');
  131. const remainingTime = ref('计算中...');
  132. const speedInterval = ref(null);
  133. // 块状态
  134. const chunkStatus = ref([]);
  135. // 计算下载进度百分比
  136. const progress = computed(() => {
  137.   if (totalBytes.value === 0) return 0;
  138.   return (downloadedBytes.value / totalBytes.value) * 100;
  139. });
  140. // 初始化
  141. onMounted(async () => {
  142.   try {
  143.     await fetchFileInfo();
  144.   } catch (error) {
  145.     console.error('获取文件信息失败:', error);
  146.     statusMessage.value = `获取文件信息失败: ${error.message}`;
  147.   }
  148. });
  149. // 清理资源
  150. onUnmounted(() => {
  151.   if (downloader.value) {
  152.     downloader.value.cancel();
  153.   }
  154.   clearInterval(speedInterval.value);
  155. });
  156. // 获取文件信息
  157. async function fetchFileInfo() {
  158.   const response = await fetch(`${API_BASE_URL}large_file/file_info/`);
  159.   if (!response.ok) {
  160.     throw new Error(`HTTP 错误! 状态码: ${response.status}`);
  161.   }
  162.   fileInfo.value = await response.json();
  163.   totalBytes.value = fileInfo.value.size;
  164.   // 根据文件大小初始化分块状态
  165.   const initialTotalChunks = Math.ceil(fileInfo.value.size / chunkSize.value);
  166.   chunkStatus.value = Array(initialTotalChunks).fill('pending');
  167.   totalChunks.value = initialTotalChunks;
  168. }
  169. // 开始下载
  170. async function startDownload() {
  171.   if (isDownloading.value) return;
  172.   try {
  173.     // 初始化下载器
  174.     downloader.value = new ChunkDownloader(`${API_BASE_URL}`, {
  175.       chunkSize: chunkSize.value,
  176.       concurrency: concurrency.value,
  177.       maxRetries: 3,
  178.       onProgress: handleProgress,
  179.       onComplete: handleComplete,
  180.       onError: handleError,
  181.       onStatusChange: handleStatusChange
  182.     });
  183.     // 初始化状态
  184.     downloadedBytes.value = 0;
  185.     downloadedChunks.value = 0;
  186.     isDownloading.value = true;
  187.     isPaused.value = false;
  188.     statusMessage.value = '准备下载...';
  189.     // 获取文件信息
  190.     await downloader.value.fetchFileInfo();
  191.     // 更新总块数
  192.     totalChunks.value = Math.ceil(downloader.value.fileSize / chunkSize.value);
  193.     chunkStatus.value = Array(totalChunks.value).fill('pending');
  194.     // 开始下载
  195.     downloadStartTime.value = Date.now();
  196.     lastUpdateTime.value = Date.now();
  197.     lastBytes.value = 0;
  198.     startSpeedCalculator();
  199.     await downloader.value.start();
  200.   } catch (error) {
  201.     console.error('下载启动失败:', error);
  202.     statusMessage.value = `下载启动失败: ${error.message}`;
  203.     isDownloading.value = false;
  204.   }
  205. }
  206. // 暂停下载
  207. function pauseDownload() {
  208.   if (!isDownloading.value || !downloader.value) return;
  209.   downloader.value.pause();
  210.   isDownloading.value = false;
  211.   isPaused.value = true;
  212.   statusMessage.value = '下载已暂停';
  213.   clearInterval(speedInterval.value);
  214. }
  215. // 继续下载
  216. function resumeDownload() {
  217.   if (!isPaused.value || !downloader.value) return;
  218.   downloader.value.resume();
  219.   isDownloading.value = true;
  220.   isPaused.value = false;
  221.   statusMessage.value = '继续下载...';
  222.   // 重新开始速度计算
  223.   lastUpdateTime.value = Date.now();
  224.   lastBytes.value = downloadedBytes.value;
  225.   startSpeedCalculator();
  226. }
  227. // 取消下载
  228. function cancelDownload() {
  229.   if (!downloader.value) return;
  230.   downloader.value.cancel();
  231.   isDownloading.value = false;
  232.   isPaused.value = false;
  233.   downloadedBytes.value = 0;
  234.   downloadedChunks.value = 0;
  235.   statusMessage.value = '下载已取消';
  236.   clearInterval(speedInterval.value);
  237.   // 重置块状态
  238.   chunkStatus.value = Array(totalChunks.value).fill('pending');
  239. }
  240. // 处理进度更新
  241. function handleProgress(data) {
  242.   downloadedBytes.value = data.downloadedBytes;
  243.   downloadedChunks.value = data.downloadedChunks;
  244.   // 更新块状态(这里仅是简化的更新方式,实际上应该由downloader提供精确的块状态)
  245.   const newChunkStatus = [...chunkStatus.value];
  246.   const completedChunksCount = Math.floor(downloadedChunks.value);
  247.   for (let i = 0; i < newChunkStatus.length; i++) {
  248.     if (i < completedChunksCount) {
  249.       newChunkStatus[i] = 'completed';
  250.     } else if (i < completedChunksCount + concurrency.value && newChunkStatus[i] !== 'completed') {
  251.       newChunkStatus[i] = 'downloading';
  252.     }
  253.   }
  254.   chunkStatus.value = newChunkStatus;
  255. }
  256. // 处理下载完成
  257. function handleComplete(data) {
  258.   isDownloading.value = false;
  259.   isPaused.value = false;
  260.   statusMessage.value = '下载完成';
  261.   clearInterval(speedInterval.value);
  262.   // 标记所有块为已完成
  263.   chunkStatus.value = Array(totalChunks.value).fill('completed');
  264. }
  265. // 处理错误
  266. function handleError(error) {
  267.   console.error('下载错误:', error);
  268.   statusMessage.value = `下载错误: ${error.message}`;
  269. }
  270. // 处理状态变化
  271. function handleStatusChange(status, error) {
  272.   switch (status) {
  273.     case 'downloading':
  274.       isDownloading.value = true;
  275.       isPaused.value = false;
  276.       statusMessage.value = '下载中...';
  277.       break;
  278.     case 'paused':
  279.       isDownloading.value = false;
  280.       isPaused.value = true;
  281.       statusMessage.value = '下载已暂停';
  282.       break;
  283.     case 'completed':
  284.       isDownloading.value = false;
  285.       isPaused.value = false;
  286.       statusMessage.value = '下载完成';
  287.       break;
  288.     case 'error':
  289.       isDownloading.value = false;
  290.       statusMessage.value = `下载错误: ${error?.message || '未知错误'}`;
  291.       break;
  292.   }
  293. }
  294. // 启动下载速度计算器
  295. function startSpeedCalculator() {
  296.   clearInterval(speedInterval.value);
  297.   speedInterval.value = setInterval(() => {
  298.     const now = Date.now();
  299.     const timeElapsed = (now - lastUpdateTime.value) / 1000; // 转换为秒
  300.     const bytesDownloaded = downloadedBytes.value - lastBytes.value;
  301.     if (timeElapsed > 0) {
  302.       const speed = bytesDownloaded / timeElapsed; // 字节/秒
  303.       downloadSpeed.value = formatFileSize(speed) + '/s';
  304.       // 计算剩余时间
  305.       if (speed > 0) {
  306.         const bytesRemaining = totalBytes.value - downloadedBytes.value;
  307.         const secondsRemaining = bytesRemaining / speed;
  308.         remainingTime.value = formatTime(secondsRemaining);
  309.       } else {
  310.         remainingTime.value = '计算中...';
  311.       }
  312.       lastUpdateTime.value = now;
  313.       lastBytes.value = downloadedBytes.value;
  314.     }
  315.   }, 1000);
  316. }
  317. // 格式化文件大小
  318. function formatFileSize(bytes) {
  319.   if (bytes === 0) return '0 B';
  320.   const k = 1024;
  321.   const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
  322.   const i = Math.floor(Math.log(bytes) / Math.log(k));
  323.   return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  324. }
  325. // 格式化时间
  326. function formatTime(seconds) {
  327.   if (!isFinite(seconds) || seconds < 0) {
  328.     return '计算中...';
  329.   }
  330.   if (seconds < 60) {
  331.     return `${Math.ceil(seconds)}秒`;
  332.   } else if (seconds < 3600) {
  333.     const minutes = Math.floor(seconds / 60);
  334.     const secs = Math.ceil(seconds % 60);
  335.     return `${minutes}分${secs}秒`;
  336.   } else {
  337.     const hours = Math.floor(seconds / 3600);
  338.     const minutes = Math.floor((seconds % 3600) / 60);
  339.     return `${hours}小时${minutes}分钟`;
  340.   }
  341. }
  342. // 监听配置改变,更新块状态
  343. watch([chunkSize], () => {
  344.   if (fileInfo.value && fileInfo.value.size) {
  345.     totalChunks.value = Math.ceil(fileInfo.value.size / chunkSize.value);
  346.     chunkStatus.value = Array(totalChunks.value).fill('pending');
  347.   }
  348. });
  349. </script>
  350. <style scoped>
  351. .enhanced-downloader {
  352.   max-width: 800px;
  353.   margin: 0 auto;
  354.   padding: 20px;
  355. }
  356. .card {
  357.   background: #fff;
  358.   border-radius: 8px;
  359.   box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  360.   padding: 20px;
  361. }
  362. h2 {
  363.   margin-top: 0;
  364.   color: #333;
  365. }
  366. .file-info {
  367.   margin-bottom: 20px;
  368. }
  369. .progress-container {
  370.   margin-bottom: 20px;
  371. }
  372. .progress-bar {
  373.   height: 20px;
  374.   background-color: #f0f0f0;
  375.   border-radius: 10px;
  376.   overflow: hidden;
  377.   margin-bottom: 10px;
  378. }
  379. .progress {
  380.   height: 100%;
  381.   background-color: #4CAF50;
  382.   transition: width 0.3s ease;
  383. }
  384. .progress-stats {
  385.   display: flex;
  386.   justify-content: space-between;
  387.   font-size: 14px;
  388.   color: #666;
  389. }
  390. .actions {
  391.   display: flex;
  392.   gap: 10px;
  393.   margin-bottom: 20px;
  394. }
  395. .btn {
  396.   padding: 8px 16px;
  397.   border: none;
  398.   border-radius: 4px;
  399.   cursor: pointer;
  400.   font-weight: bold;
  401.   transition: background-color 0.3s;
  402. }
  403. .btn:disabled {
  404.   opacity: 0.5;
  405.   cursor: not-allowed;
  406. }
  407. .btn-primary {
  408.   background-color: #4CAF50;
  409.   color: white;
  410. }
  411. .btn-warning {
  412.   background-color: #FF9800;
  413.   color: white;
  414. }
  415. .btn-success {
  416.   background-color: #2196F3;
  417.   color: white;
  418. }
  419. .btn-danger {
  420.   background-color: #F44336;
  421.   color: white;
  422. }
  423. .status {
  424.   font-style: italic;
  425.   color: #666;
  426. }
  427. </style>
复制代码
2,下载服务 (ChunkDownloader类):


  • 负责管理整个下载过程
  • 处理处罚文件信息获取、分块下载、进度追踪
  • 实现并发控制、重试机制、停息/继承功能
  1. // downloadService.js - 分块下载实现
  2. /*
  3. 文件分块下载器
  4. */
  5. export class ChunkDownloader {
  6.     constructor(url, options = {}) {
  7.         this.url = url;
  8.         this.chunkSize = options.chunkSize || 1024 * 1024; // 默认1MB每块
  9.         this.maxRetries = options.maxRetries || 3;
  10.         this.concurrency = options.concurrency || 3; // 并发下载块数
  11.         this.timeout = options.timeout || 30000; // 超时时间
  12.         this.fileSize = 0;
  13.         this.fileName = '';
  14.         this.contentType = '';
  15.         this.chunks = [];
  16.         this.downloadedChunks = 0;
  17.         this.activeDownloads = 0;
  18.         this.totalChunks = 0;
  19.         this.downloadedBytes = 0;
  20.         this.status = 'idle'; // idle, downloading, paused, completed, error
  21.         this.error = null;
  22.         this.onProgress = options.onProgress || (() => {
  23.         });
  24.         this.onComplete = options.onComplete || (() => {
  25.         });
  26.         this.onError = options.onError || (() => {
  27.         });
  28.         this.onStatusChange = options.onStatusChange || (() => {
  29.         });
  30.         this.abortControllers = new Map();
  31.         this.pendingChunks = [];
  32.         this.processedChunks = new Set();
  33.     }
  34.     // 获取文件信息
  35.     async fetchFileInfo() {
  36.         try {
  37.             const response = await fetch(this.url + 'large_file/file_info/');
  38.             if (!response.ok) {
  39.                 throw new Error(`无法获取文件信息: ${response.status}`);
  40.             }
  41.             const info = await response.json();
  42.             this.fileSize = info.size;
  43.             this.fileName = info.name;
  44.             this.contentType = info.type;
  45.             // 计算分块数量
  46.             this.totalChunks = Math.ceil(this.fileSize / this.chunkSize);
  47.             return info;
  48.         } catch (error) {
  49.             this.error = error;
  50.             this.status = 'error';
  51.             this.onStatusChange(this.status, error);
  52.             this.onError(error);
  53.             throw error;
  54.         }
  55.     }
  56.     // 开始下载
  57.     async start() {
  58.         if (this.status === 'downloading') {
  59.             return;
  60.         }
  61.         try {
  62.             // 如果还没获取文件信息,先获取
  63.             if (this.fileSize === 0) {
  64.                 await this.fetchFileInfo();
  65.             }
  66.             // 初始化状态
  67.             this.status = 'downloading';
  68.             this.onStatusChange(this.status);
  69.             // 如果是全新下载,初始化块数组
  70.             if (this.chunks.length === 0) {
  71.                 this.chunks = new Array(this.totalChunks).fill(null);
  72.                 this.pendingChunks = Array.from({length: this.totalChunks}, (_, i) => i);
  73.             }
  74.             // 开始并发下载
  75.             this.startConcurrentDownloads();
  76.         } catch (error) {
  77.             this.error = error;
  78.             this.status = 'error';
  79.             this.onStatusChange(this.status, error);
  80.             this.onError(error);
  81.         }
  82.     }
  83.     // 开始并发下载
  84.     startConcurrentDownloads() {
  85.         // 确保同时只有指定数量的并发下载
  86.         while (this.activeDownloads < this.concurrency && this.pendingChunks.length > 0) {
  87.             const chunkIndex = this.pendingChunks.shift();
  88.             this.downloadChunk(chunkIndex);
  89.         }
  90.     }
  91.     // 下载指定的块
  92.     async downloadChunk(chunkIndex, retryCount = 0) {
  93.         if (this.status !== 'downloading' || this.processedChunks.has(chunkIndex)) {
  94.             return;
  95.         }
  96.         this.activeDownloads++;
  97.         const startByte = chunkIndex * this.chunkSize;
  98.         const endByte = Math.min(startByte + this.chunkSize - 1, this.fileSize - 1);
  99.         // 创建用于取消请求的控制器
  100.         const controller = new AbortController();
  101.         this.abortControllers.set(chunkIndex, controller);
  102.         try {
  103.             const response = await fetch(
  104.                 this.url + 'large_file/download_large_file/',
  105.                 {
  106.                     method: 'GET',
  107.                     headers: {
  108.                         'Range': `bytes=${startByte}-${endByte}`
  109.                     },
  110.                     signal: controller.signal,
  111.                     timeout: this.timeout
  112.                 });
  113.             if (!response.ok && response.status !== 206) {
  114.                 throw new Error(`服务器错误: ${response.status}`);
  115.             }
  116.             // 获取块数据
  117.             const blob = await response.blob();
  118.             this.chunks[chunkIndex] = blob;
  119.             this.downloadedChunks++;
  120.             this.downloadedBytes += blob.size;
  121.             this.processedChunks.add(chunkIndex);
  122.             // 更新进度
  123.             this.onProgress({
  124.                 downloadedChunks: this.downloadedChunks,
  125.                 totalChunks: this.totalChunks,
  126.                 downloadedBytes: this.downloadedBytes,
  127.                 totalBytes: this.fileSize,
  128.                 progress: (this.downloadedBytes / this.fileSize) * 100
  129.             });
  130.             // 清理控制器
  131.             this.abortControllers.delete(chunkIndex);
  132.             // 检查是否下载完成
  133.             if (this.downloadedChunks === this.totalChunks) {
  134.                 this.completeDownload();
  135.             } else if (this.status === 'downloading') {
  136.                 // 继续下载下一个块
  137.                 this.activeDownloads--;
  138.                 this.startConcurrentDownloads();
  139.             }
  140.         } catch (error) {
  141.             this.abortControllers.delete(chunkIndex);
  142.             if (error.name === 'AbortError') {
  143.                 // 用户取消,不进行重试
  144.                 this.activeDownloads--;
  145.                 return;
  146.             }
  147.             // 重试逻辑
  148.             if (retryCount < this.maxRetries) {
  149.                 console.warn(`块 ${chunkIndex} 下载失败,重试 ${retryCount + 1}/${this.maxRetries}`);
  150.                 this.activeDownloads--;
  151.                 this.downloadChunk(chunkIndex, retryCount + 1);
  152.             } else {
  153.                 console.error(`块 ${chunkIndex} 下载失败,已达到最大重试次数`);
  154.                 this.error = error;
  155.                 this.status = 'error';
  156.                 this.onStatusChange(this.status, error);
  157.                 this.onError(error);
  158.                 this.activeDownloads--;
  159.             }
  160.         }
  161.     }
  162.     // 完成下载
  163.     completeDownload() {
  164.         if (this.status === 'completed') {
  165.             return;
  166.         }
  167.         try {
  168.             // 合并所有块
  169.             const blob = new Blob(this.chunks, {type: this.contentType});
  170.             // 创建下载链接
  171.             const url = URL.createObjectURL(blob);
  172.             const a = document.createElement('a');
  173.             a.href = url;
  174.             a.download = this.fileName;
  175.             document.body.appendChild(a);
  176.             a.click();
  177.             document.body.removeChild(a);
  178.             // 清理资源
  179.             setTimeout(() => URL.revokeObjectURL(url), 100);
  180.             // 更新状态
  181.             this.status = 'completed';
  182.             this.onStatusChange(this.status);
  183.             this.onComplete({
  184.                 fileName: this.fileName,
  185.                 fileSize: this.fileSize,
  186.                 contentType: this.contentType,
  187.                 blob: blob
  188.             });
  189.         } catch (error) {
  190.             this.error = error;
  191.             this.status = 'error';
  192.             this.onStatusChange(this.status, error);
  193.             this.onError(error);
  194.         }
  195.     }
  196.     // 暂停下载
  197.     pause() {
  198.         if (this.status !== 'downloading') {
  199.             return;
  200.         }
  201.         // 取消所有正在进行的请求
  202.         this.abortControllers.forEach(controller => {
  203.             controller.abort();
  204.         });
  205.         // 清空控制器集合
  206.         this.abortControllers.clear();
  207.         // 更新状态
  208.         this.status = 'paused';
  209.         this.activeDownloads = 0;
  210.         this.onStatusChange(this.status);
  211.         // 将当前处理中的块重新加入待处理队列
  212.         this.pendingChunks = Array.from({length: this.totalChunks}, (_, i) => i)
  213.             .filter(i => !this.processedChunks.has(i));
  214.     }
  215.     // 继续下载
  216.     resume() {
  217.         if (this.status !== 'paused') {
  218.             return;
  219.         }
  220.         this.status = 'downloading';
  221.         this.onStatusChange(this.status);
  222.         this.startConcurrentDownloads();
  223.     }
  224.     // 取消下载
  225.     cancel() {
  226.         // 取消所有正在进行的请求
  227.         this.abortControllers.forEach(controller => {
  228.             controller.abort();
  229.         });
  230.         // 重置所有状态
  231.         this.chunks = [];
  232.         this.downloadedChunks = 0;
  233.         this.activeDownloads = 0;
  234.         this.downloadedBytes = 0;
  235.         this.status = 'idle';
  236.         this.error = null;
  237.         this.abortControllers.clear();
  238.         this.pendingChunks = [];
  239.         this.processedChunks.clear();
  240.         this.onStatusChange(this.status);
  241.     }
  242.     // 获取当前状态
  243.     getStatus() {
  244.         return {
  245.             status: this.status,
  246.             downloadedChunks: this.downloadedChunks,
  247.             totalChunks: this.totalChunks,
  248.             downloadedBytes: this.downloadedBytes,
  249.             totalBytes: this.fileSize,
  250.             progress: this.fileSize ? (this.downloadedBytes / this.fileSize) * 100 : 0,
  251.             fileName: this.fileName,
  252.             error: this.error
  253.         };
  254.     }
  255. }
复制代码
2,焦点技能原理

(1)HTTP Range哀求

该实现通过HTTP的Range头部实现分块下载:
  1. const response = await fetch(
  2.     this.url + 'large_file/download_large_file/',
  3.     {
  4.         method: 'GET',
  5.         headers: {
  6.             'Range': `bytes=${startByte}-${endByte}`
  7.         },
  8.         signal: controller.signal,
  9.         timeout: this.timeout
  10.     });
复制代码


  • 服务器会返回状态码206(Partial Content)和哀求的文件片断。
(2)并发控制

代码通过控制同时活泼的下载哀求数目来实现并发:
  1. while (this.activeDownloads < this.concurrency && this.pendingChunks.length > 0) {
  2.     const chunkIndex = this.pendingChunks.shift();
  3.     this.downloadChunk(chunkIndex);
  4. }
复制代码
(3)状态管理和进度追踪



  • 跟踪每个块的下载状态(待下载、下载中、已完成、错误)
  • 盘算并陈诉总体进度、下载速率和剩余时间
(4)错误处理处罚和重试机制

对下载失败的块举行自动重试:
  1. if (retryCount < this.maxRetries) {
  2.     console.warn(`块 ${chunkIndex} 下载失败,重试 ${retryCount + 1}/${this.maxRetries}`);
  3.     this.activeDownloads--;
  4.     this.downloadChunk(chunkIndex, retryCount + 1);
  5. }
复制代码
(5)停息/规复功能

通过AbortController取消活泼的哀求,并生存未完成的块索引:
  1. pause() {
  2.     // 取消所有正在进行的请求
  3.     this.abortControllers.forEach(controller => {
  4.         controller.abort();
  5.     });
  6.    
  7.     // 将当前处理中的块重新加入待处理队列
  8.     this.pendingChunks = Array.from({length: this.totalChunks}, (_, i) => i)
  9.         .filter(i => !this.processedChunks.has(i));
  10. }
复制代码
(6)文件归并和下载

全部块下载完成后,利用Blob API归并全部分块并创建下载链接:
  1. const blob = new Blob(this.chunks, {type: this.contentType});
  2. const url = URL.createObjectURL(blob);
  3. const a = document.createElement('a');
  4. a.href = url;
  5. a.download = this.fileName;
  6. a.click();
复制代码
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表