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

发布于:2025-03-17 ⋅ 阅读:(20) ⋅ 点赞:(0)

效果:
在这里插入图片描述

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

  • 展示目标文件信息
  • 提高下载速度:通过并发请求多个块,可以更有效地利用网络带宽
  • 断点续传:支持暂停后从已下载部分继续,无需重新开始
  • 错误恢复:单个块下载失败只需重试该块,而不是整个文件
  • 更好的用户体验:实时显示下载进度、速度和预计剩余时间
  • 内存效率:通过分块下载和处理,减少了一次性内存占用

大文件分片下载

前端处理流程:

用户操作
重试次数<最大值
达到最大重试
取消当前请求
保存下载状态
暂停下载
重新开始未完成分块
恢复下载
取消所有请求
重置所有状态
取消下载
开始
获取文件信息
计算总分块数
初始化分块状态
并发下载多个分块
分块下载完成?
下载下一个分块
重试逻辑
错误处理
所有分块下载完成?
合并所有分块
创建完整文件
结束

后端处理流程:

后端
前端
API调用
API调用
返回文件元数据
file_info API
解析Range头
download_large_file API
定位文件指针
流式读取响应字节范围
返回206状态码和数据
获取文件信息
初始化下载器
计算分块策略
创建并发下载队列
发送Range请求
所有分块下载完成?
合并分块并下载

django代码

1,代码

# settings.py
# 指定文件访问的 URL 前缀
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media/'




# views.py
import os
import mimetypes
from django.conf import settings
from django.http import StreamingHttpResponse, JsonResponse, HttpResponse
from django.utils.http import http_date
from django.views.decorators.http import require_http_methods


def get_file_info(file_path):
    """
    获取文件信息:
    - name: 文件名
    - size: 文件大小,单位字节
    - type: 文件类型
    """
    if not os.path.exists(file_path):
        return None

    file_size = os.path.getsize(file_path)
    file_name = os.path.basename(file_path)
    content_type, encoding = mimetypes.guess_type(file_path)

    return {
        'name': file_name,
        'size': file_size,
        'type': content_type or 'application/octet-stream'
    }


@require_http_methods(["GET"])
def file_info(request):
    """获取文件信息API"""
    file_path = os.path.join(settings.MEDIA_ROOT, "user_info_big.csv")
    info = get_file_info(file_path)

    if info is None:
        return JsonResponse({"error": "File not found"}, status=404)

    return JsonResponse(info)


@require_http_methods(["GET"])
def download_large_file(request):
    """
    分片下载文件的API
    :param request: 请求对象
    :return: 文件流
    """
    file_path = os.path.join(settings.MEDIA_ROOT, "user_info_big.csv")

    # 1,检查文件是否存在
    if not os.path.exists(file_path):
        return HttpResponse("File not found", status=404)

    # 2,获取文件信息
    file_size = os.path.getsize(file_path)
    file_name = os.path.basename(file_path)
    content_type, encoding = mimetypes.guess_type(file_path)
    content_type = content_type or 'application/octet-stream'

    # 3,获取请求中的Range头
    range_header = request.META.get('HTTP_RANGE', '').strip()
    # 格式:bytes=0-100
    range_match = range_header.replace('bytes=', '').split('-')
    # 起始位置
    range_start = int(range_match[0]) if range_match[0] else 0
    # 结束位置
    range_end = int(range_match[1]) if range_match[1] else file_size - 1

    # 4,确保范围合法
    range_start = max(0, range_start)
    range_end = min(file_size - 1, range_end)

    # 5,计算实际要发送的数据大小
    content_length = range_end - range_start + 1

    # 6,创建响应:使用StreamingHttpResponse,将文件流式传输。206表示部分内容,200表示全部内容
    response = StreamingHttpResponse(
        file_iterator(file_path, range_start, range_end, chunk_size=8192),
        status=206 if range_header else 200,
        content_type=content_type
    )

    # 7,设置响应头
    response['Content-Length'] = content_length
    response['Accept-Ranges'] = 'bytes'
    response['Content-Disposition'] = f'attachment; filename="{file_name}"'

    if range_header:
        response['Content-Range'] = f'bytes {range_start}-{range_end}/{file_size}'

    response['Last-Modified'] = http_date(os.path.getmtime(file_path))

    # 模拟处理延迟,方便测试暂停/继续功能
    # time.sleep(0.1)  # 取消注释以添加人为延迟

    # 8,返回响应
    return response


def file_iterator(file_path, start_byte=0, end_byte=None, chunk_size=8192):
    """
    文件读取迭代器
    :param file_path: 文件路径
    :param start_byte: 起始字节
    :param end_byte: 结束字节
    :param chunk_size: 块大小
    """
    with open(file_path, 'rb') as f:
        # 移动到起始位置
        f.seek(start_byte)

        # 计算剩余字节数
        remaining = end_byte - start_byte + 1 if end_byte else None

        while True:
            if remaining is not None:
                # 如果指定了结束位置,则读取剩余字节或块大小,取小的那个
                bytes_to_read = min(chunk_size, remaining)
                if bytes_to_read <= 0:
                    break
            else:
                # 否则读取指定块大小
                bytes_to_read = chunk_size

            data = f.read(bytes_to_read)
            if not data:
                break

            yield data

            if remaining is not None:
                remaining -= len(data)




# proj urls.py
from django.urls import path, include

urlpatterns = [
    # 下载文件
    path('download/', include(('download.urls', 'download'), namespace='download')),
]





# download.urls.py
from django.urls import path

from download import views

urlpatterns = [
    path('large_file/file_info/', views.file_info, name='file_info'),
    path('large_file/download_large_file/', views.download_large_file, name='download_large_file'),
]

2,核心功能解析

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

这个端点提供文件的基本信息,让前端能够规划下载策略:

  • 功能:返回文件名称、大小和MIME类型
  • 用途:前端根据文件大小和设置的块大小计算出需要下载的分块数量

(2)download_large_file 端点 - 实现分片下载

这是实现分片下载的核心API,通过HTTP Range请求实现:
1,解析Range头:从HTTP_RANGE头部解析客户端请求的字节范围

range_header = request.META.get('HTTP_RANGE', '').strip()
range_match = range_header.replace('bytes=', '').split('-')
range_start = int(range_match[0]) if range_match[0] else 0
range_end = int(range_match[1]) if range_match[1] else file_size - 1

2,流式传输:使用StreamingHttpResponse和迭代器按块读取和传输文件,避免一次加载整个文件到内存

response = StreamingHttpResponse(
    file_iterator(file_path, range_start, range_end, chunk_size=8192),
    status=206 if range_header else 200,
    content_type=content_type
)

3,返回响应头:设置必要的响应头,包括Content-Range指示返回内容的范围

response['Content-Range'] = f'bytes {range_start}-{range_end}/{file_size}'

(3)file_iterator 函数 - 高效的文件读取

这个函数创建一个迭代器,高效地读取文件的指定部分:

1,文件定位:将文件指针移动到请求的起始位置

f.seek(start_byte)

2,分块读取:按指定的块大小读取文件,避免一次性读取大量数据

data = f.read(bytes_to_read)

3,边界控制:确保只读取请求范围内的数据

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组件):

  • 提供配置选项:并发块数、块大小
  • 显示下载进度:进度条、已下载量、下载速度、剩余时间提供操作按钮:开始、暂停、继续、取消
  • 可视化显示每个分块的下载状态
<template>
  <div class="enhanced-downloader">
    <div class="card">
      <h2>大文件分块下载</h2>

      <div class="file-info" v-if="fileInfo">
        <p><strong>文件名:</strong> {{ fileInfo.name }}</p>
        <p><strong>文件大小:</strong> {{ formatFileSize(fileInfo.size) }}</p>
        <p><strong>类型:</strong> {{ fileInfo.type }}</p>
      </div>

      <div class="config-panel" v-if="!isDownloading && !isPaused">
        <div class="config-item">
          <label>并发块数:</label>
          <select v-model="concurrency">
            <option :value="1">1</option>
            <option :value="2">2</option>
            <option :value="3">3</option>
            <option :value="5">5</option>
            <option :value="8">8</option>
          </select>
        </div>

        <div class="config-item">
          <label>块大小:</label>
          <select v-model="chunkSize">
            <option :value="512 * 1024">512 KB</option>
            <option :value="1024 * 1024">1 MB</option>
            <option :value="2 * 1024 * 1024">2 MB</option>
            <option :value="5 * 1024 * 1024">5 MB</option>
          </select>
        </div>
      </div>

      <div class="progress-container" v-if="isDownloading || isPaused">
        <div class="progress-bar">
          <div class="progress" :style="{ width: `${progress}%` }"></div>
        </div>

        <div class="progress-stats">
          <div class="stat-item">
            <span class="label">进度:</span>
            <span class="value">{{ progress.toFixed(2) }}%</span>
          </div>

          <div class="stat-item">
            <span class="label">已下载:</span>
            <span class="value">{{ formatFileSize(downloadedBytes) }} / {{ formatFileSize(totalBytes) }}</span>
          </div>

          <div class="stat-item">
            <span class="label">速度:</span>
            <span class="value">{{ downloadSpeed }}</span>
          </div>

          <div class="stat-item">
            <span class="label">已完成块:</span>
            <span class="value">{{ downloadedChunks }} / {{ totalChunks }}</span>
          </div>

          <div class="stat-item">
            <span class="label">剩余时间:</span>
            <span class="value">{{ remainingTime }}</span>
          </div>
        </div>
      </div>

      <div class="chunk-visualization" v-if="isDownloading || isPaused">
        <div class="chunk-grid">
          <div
              v-for="(chunk, index) in chunkStatus"
              :key="index"
              class="chunk-block"
              :class="{
              'downloaded': chunk === 'completed',
              'downloading': chunk === 'downloading',
              'pending': chunk === 'pending',
              'error': chunk === 'error'
            }"
              :title="`块 ${index + 1}: ${chunk}`"
          ></div>
        </div>
      </div>

      <div class="actions">
        <button
            @click="startDownload"
            :disabled="isDownloading"
            v-if="!isDownloading && !isPaused"
            class="btn btn-primary">
          开始下载
        </button>

        <button
            @click="pauseDownload"
            :disabled="!isDownloading"
            v-if="isDownloading && !isPaused"
            class="btn btn-warning">
          暂停
        </button>

        <button
            @click="resumeDownload"
            :disabled="!isPaused"
            v-if="isPaused"
            class="btn btn-success">
          继续
        </button>

        <button
            @click="cancelDownload"
            :disabled="!isDownloading && !isPaused"
            class="btn btn-danger">
          取消
        </button>
      </div>

      <div class="status-message" v-if="statusMessage">
        {{ statusMessage }}
      </div>
    </div>
  </div>
</template>

<script setup>
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {ChunkDownloader} from './downloadService';

// API URL
const API_BASE_URL = 'http://localhost:8000/download/';

// 下载配置
const concurrency = ref(3);
const chunkSize = ref(1024 * 1024); // 1MB
const downloader = ref(null);

// 状态变量
const fileInfo = ref(null);
const isDownloading = ref(false);
const isPaused = ref(false);
const downloadedBytes = ref(0);
const totalBytes = ref(0);
const downloadedChunks = ref(0);
const totalChunks = ref(0);
const statusMessage = ref('准备就绪');
const downloadStartTime = ref(0);
const lastUpdateTime = ref(0);
const lastBytes = ref(0);
const downloadSpeed = ref('0 KB/s');
const remainingTime = ref('计算中...');
const speedInterval = ref(null);

// 块状态
const chunkStatus = ref([]);

// 计算下载进度百分比
const progress = computed(() => {
  if (totalBytes.value === 0) return 0;
  return (downloadedBytes.value / totalBytes.value) * 100;
});

// 初始化
onMounted(async () => {
  try {
    await fetchFileInfo();
  } catch (error) {
    console.error('获取文件信息失败:', error);
    statusMessage.value = `获取文件信息失败: ${error.message}`;
  }
});

// 清理资源
onUnmounted(() => {
  if (downloader.value) {
    downloader.value.cancel();
  }
  clearInterval(speedInterval.value);
});

// 获取文件信息
async function fetchFileInfo() {
  const response = await fetch(`${API_BASE_URL}large_file/file_info/`);
  if (!response.ok) {
    throw new Error(`HTTP 错误! 状态码: ${response.status}`);
  }

  fileInfo.value = await response.json();
  totalBytes.value = fileInfo.value.size;

  // 根据文件大小初始化分块状态
  const initialTotalChunks = Math.ceil(fileInfo.value.size / chunkSize.value);
  chunkStatus.value = Array(initialTotalChunks).fill('pending');
  totalChunks.value = initialTotalChunks;
}

// 开始下载
async function startDownload() {
  if (isDownloading.value) return;

  try {
    // 初始化下载器
    downloader.value = new ChunkDownloader(`${API_BASE_URL}`, {
      chunkSize: chunkSize.value,
      concurrency: concurrency.value,
      maxRetries: 3,
      onProgress: handleProgress,
      onComplete: handleComplete,
      onError: handleError,
      onStatusChange: handleStatusChange
    });

    // 初始化状态
    downloadedBytes.value = 0;
    downloadedChunks.value = 0;
    isDownloading.value = true;
    isPaused.value = false;
    statusMessage.value = '准备下载...';

    // 获取文件信息
    await downloader.value.fetchFileInfo();

    // 更新总块数
    totalChunks.value = Math.ceil(downloader.value.fileSize / chunkSize.value);
    chunkStatus.value = Array(totalChunks.value).fill('pending');

    // 开始下载
    downloadStartTime.value = Date.now();
    lastUpdateTime.value = Date.now();
    lastBytes.value = 0;
    startSpeedCalculator();

    await downloader.value.start();
  } catch (error) {
    console.error('下载启动失败:', error);
    statusMessage.value = `下载启动失败: ${error.message}`;
    isDownloading.value = false;
  }
}

// 暂停下载
function pauseDownload() {
  if (!isDownloading.value || !downloader.value) return;

  downloader.value.pause();
  isDownloading.value = false;
  isPaused.value = true;
  statusMessage.value = '下载已暂停';
  clearInterval(speedInterval.value);
}

// 继续下载
function resumeDownload() {
  if (!isPaused.value || !downloader.value) return;

  downloader.value.resume();
  isDownloading.value = true;
  isPaused.value = false;
  statusMessage.value = '继续下载...';

  // 重新开始速度计算
  lastUpdateTime.value = Date.now();
  lastBytes.value = downloadedBytes.value;
  startSpeedCalculator();
}

// 取消下载
function cancelDownload() {
  if (!downloader.value) return;

  downloader.value.cancel();
  isDownloading.value = false;
  isPaused.value = false;
  downloadedBytes.value = 0;
  downloadedChunks.value = 0;
  statusMessage.value = '下载已取消';
  clearInterval(speedInterval.value);

  // 重置块状态
  chunkStatus.value = Array(totalChunks.value).fill('pending');
}

// 处理进度更新
function handleProgress(data) {
  downloadedBytes.value = data.downloadedBytes;
  downloadedChunks.value = data.downloadedChunks;

  // 更新块状态(这里仅是简化的更新方式,实际上应该由downloader提供精确的块状态)
  const newChunkStatus = [...chunkStatus.value];
  const completedChunksCount = Math.floor(downloadedChunks.value);

  for (let i = 0; i < newChunkStatus.length; i++) {
    if (i < completedChunksCount) {
      newChunkStatus[i] = 'completed';
    } else if (i < completedChunksCount + concurrency.value && newChunkStatus[i] !== 'completed') {
      newChunkStatus[i] = 'downloading';
    }
  }

  chunkStatus.value = newChunkStatus;
}

// 处理下载完成
function handleComplete(data) {
  isDownloading.value = false;
  isPaused.value = false;
  statusMessage.value = '下载完成';
  clearInterval(speedInterval.value);

  // 标记所有块为已完成
  chunkStatus.value = Array(totalChunks.value).fill('completed');
}

// 处理错误
function handleError(error) {
  console.error('下载错误:', error);
  statusMessage.value = `下载错误: ${error.message}`;
}

// 处理状态变化
function handleStatusChange(status, error) {
  switch (status) {
    case 'downloading':
      isDownloading.value = true;
      isPaused.value = false;
      statusMessage.value = '下载中...';
      break;
    case 'paused':
      isDownloading.value = false;
      isPaused.value = true;
      statusMessage.value = '下载已暂停';
      break;
    case 'completed':
      isDownloading.value = false;
      isPaused.value = false;
      statusMessage.value = '下载完成';
      break;
    case 'error':
      isDownloading.value = false;
      statusMessage.value = `下载错误: ${error?.message || '未知错误'}`;
      break;
  }
}

// 启动下载速度计算器
function startSpeedCalculator() {
  clearInterval(speedInterval.value);
  speedInterval.value = setInterval(() => {
    const now = Date.now();
    const timeElapsed = (now - lastUpdateTime.value) / 1000; // 转换为秒
    const bytesDownloaded = downloadedBytes.value - lastBytes.value;

    if (timeElapsed > 0) {
      const speed = bytesDownloaded / timeElapsed; // 字节/秒
      downloadSpeed.value = formatFileSize(speed) + '/s';

      // 计算剩余时间
      if (speed > 0) {
        const bytesRemaining = totalBytes.value - downloadedBytes.value;
        const secondsRemaining = bytesRemaining / speed;
        remainingTime.value = formatTime(secondsRemaining);
      } else {
        remainingTime.value = '计算中...';
      }

      lastUpdateTime.value = now;
      lastBytes.value = downloadedBytes.value;
    }
  }, 1000);
}

// 格式化文件大小
function formatFileSize(bytes) {
  if (bytes === 0) return '0 B';

  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));

  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

// 格式化时间
function formatTime(seconds) {
  if (!isFinite(seconds) || seconds < 0) {
    return '计算中...';
  }

  if (seconds < 60) {
    return `${Math.ceil(seconds)}`;
  } else if (seconds < 3600) {
    const minutes = Math.floor(seconds / 60);
    const secs = Math.ceil(seconds % 60);
    return `${minutes}${secs}`;
  } else {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    return `${hours}小时${minutes}分钟`;
  }
}

// 监听配置改变,更新块状态
watch([chunkSize], () => {
  if (fileInfo.value && fileInfo.value.size) {
    totalChunks.value = Math.ceil(fileInfo.value.size / chunkSize.value);
    chunkStatus.value = Array(totalChunks.value).fill('pending');
  }
});
</script>

<style scoped>
.enhanced-downloader {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.card {
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  padding: 20px;
}

h2 {
  margin-top: 0;
  color: #333;
}

.file-info {
  margin-bottom: 20px;
}

.progress-container {
  margin-bottom: 20px;
}

.progress-bar {
  height: 20px;
  background-color: #f0f0f0;
  border-radius: 10px;
  overflow: hidden;
  margin-bottom: 10px;
}

.progress {
  height: 100%;
  background-color: #4CAF50;
  transition: width 0.3s ease;
}

.progress-stats {
  display: flex;
  justify-content: space-between;
  font-size: 14px;
  color: #666;
}

.actions {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: bold;
  transition: background-color 0.3s;
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-primary {
  background-color: #4CAF50;
  color: white;
}

.btn-warning {
  background-color: #FF9800;
  color: white;
}

.btn-success {
  background-color: #2196F3;
  color: white;
}

.btn-danger {
  background-color: #F44336;
  color: white;
}

.status {
  font-style: italic;
  color: #666;
}
</style>

2,下载服务 (ChunkDownloader类):

  • 负责管理整个下载过程
  • 处理文件信息获取、分块下载、进度追踪
  • 实现并发控制、重试机制、暂停/继续功能
// downloadService.js - 分块下载实现

/*
 文件分块下载器
 */
export class ChunkDownloader {
    constructor(url, options = {}) {
        this.url = url;
        this.chunkSize = options.chunkSize || 1024 * 1024; // 默认1MB每块
        this.maxRetries = options.maxRetries || 3;
        this.concurrency = options.concurrency || 3; // 并发下载块数
        this.timeout = options.timeout || 30000; // 超时时间

        this.fileSize = 0;
        this.fileName = '';
        this.contentType = '';
        this.chunks = [];
        this.downloadedChunks = 0;
        this.activeDownloads = 0;
        this.totalChunks = 0;
        this.downloadedBytes = 0;
        this.status = 'idle'; // idle, downloading, paused, completed, error
        this.error = null;

        this.onProgress = options.onProgress || (() => {
        });
        this.onComplete = options.onComplete || (() => {
        });
        this.onError = options.onError || (() => {
        });
        this.onStatusChange = options.onStatusChange || (() => {
        });

        this.abortControllers = new Map();
        this.pendingChunks = [];
        this.processedChunks = new Set();
    }

    // 获取文件信息
    async fetchFileInfo() {
        try {
            const response = await fetch(this.url + 'large_file/file_info/');
            if (!response.ok) {
                throw new Error(`无法获取文件信息: ${response.status}`);
            }

            const info = await response.json();
            this.fileSize = info.size;
            this.fileName = info.name;
            this.contentType = info.type;

            // 计算分块数量
            this.totalChunks = Math.ceil(this.fileSize / this.chunkSize);

            return info;
        } catch (error) {
            this.error = error;
            this.status = 'error';
            this.onStatusChange(this.status, error);
            this.onError(error);
            throw error;
        }
    }

    // 开始下载
    async start() {
        if (this.status === 'downloading') {
            return;
        }

        try {
            // 如果还没获取文件信息,先获取
            if (this.fileSize === 0) {
                await this.fetchFileInfo();
            }

            // 初始化状态
            this.status = 'downloading';
            this.onStatusChange(this.status);

            // 如果是全新下载,初始化块数组
            if (this.chunks.length === 0) {
                this.chunks = new Array(this.totalChunks).fill(null);
                this.pendingChunks = Array.from({length: this.totalChunks}, (_, i) => i);
            }

            // 开始并发下载
            this.startConcurrentDownloads();

        } catch (error) {
            this.error = error;
            this.status = 'error';
            this.onStatusChange(this.status, error);
            this.onError(error);
        }
    }

    // 开始并发下载
    startConcurrentDownloads() {
        // 确保同时只有指定数量的并发下载
        while (this.activeDownloads < this.concurrency && this.pendingChunks.length > 0) {
            const chunkIndex = this.pendingChunks.shift();
            this.downloadChunk(chunkIndex);
        }
    }

    // 下载指定的块
    async downloadChunk(chunkIndex, retryCount = 0) {
        if (this.status !== 'downloading' || this.processedChunks.has(chunkIndex)) {
            return;
        }

        this.activeDownloads++;
        const startByte = chunkIndex * this.chunkSize;
        const endByte = Math.min(startByte + this.chunkSize - 1, this.fileSize - 1);

        // 创建用于取消请求的控制器
        const controller = new AbortController();
        this.abortControllers.set(chunkIndex, controller);

        try {
            const response = await fetch(
                this.url + 'large_file/download_large_file/',
                {
                    method: 'GET',
                    headers: {
                        'Range': `bytes=${startByte}-${endByte}`
                    },
                    signal: controller.signal,
                    timeout: this.timeout
                });

            if (!response.ok && response.status !== 206) {
                throw new Error(`服务器错误: ${response.status}`);
            }

            // 获取块数据
            const blob = await response.blob();
            this.chunks[chunkIndex] = blob;
            this.downloadedChunks++;
            this.downloadedBytes += blob.size;
            this.processedChunks.add(chunkIndex);

            // 更新进度
            this.onProgress({
                downloadedChunks: this.downloadedChunks,
                totalChunks: this.totalChunks,
                downloadedBytes: this.downloadedBytes,
                totalBytes: this.fileSize,
                progress: (this.downloadedBytes / this.fileSize) * 100
            });

            // 清理控制器
            this.abortControllers.delete(chunkIndex);

            // 检查是否下载完成
            if (this.downloadedChunks === this.totalChunks) {
                this.completeDownload();
            } else if (this.status === 'downloading') {
                // 继续下载下一个块
                this.activeDownloads--;
                this.startConcurrentDownloads();
            }

        } catch (error) {
            this.abortControllers.delete(chunkIndex);

            if (error.name === 'AbortError') {
                // 用户取消,不进行重试
                this.activeDownloads--;
                return;
            }

            // 重试逻辑
            if (retryCount < this.maxRetries) {
                console.warn(`${chunkIndex} 下载失败,重试 ${retryCount + 1}/${this.maxRetries}`);
                this.activeDownloads--;
                this.downloadChunk(chunkIndex, retryCount + 1);
            } else {
                console.error(`${chunkIndex} 下载失败,已达到最大重试次数`);
                this.error = error;
                this.status = 'error';
                this.onStatusChange(this.status, error);
                this.onError(error);
                this.activeDownloads--;
            }
        }
    }

    // 完成下载
    completeDownload() {
        if (this.status === 'completed') {
            return;
        }

        try {
            // 合并所有块
            const blob = new Blob(this.chunks, {type: this.contentType});

            // 创建下载链接
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = this.fileName;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);

            // 清理资源
            setTimeout(() => URL.revokeObjectURL(url), 100);

            // 更新状态
            this.status = 'completed';
            this.onStatusChange(this.status);
            this.onComplete({
                fileName: this.fileName,
                fileSize: this.fileSize,
                contentType: this.contentType,
                blob: blob
            });

        } catch (error) {
            this.error = error;
            this.status = 'error';
            this.onStatusChange(this.status, error);
            this.onError(error);
        }
    }

    // 暂停下载
    pause() {
        if (this.status !== 'downloading') {
            return;
        }

        // 取消所有正在进行的请求
        this.abortControllers.forEach(controller => {
            controller.abort();
        });

        // 清空控制器集合
        this.abortControllers.clear();

        // 更新状态
        this.status = 'paused';
        this.activeDownloads = 0;
        this.onStatusChange(this.status);

        // 将当前处理中的块重新加入待处理队列
        this.pendingChunks = Array.from({length: this.totalChunks}, (_, i) => i)
            .filter(i => !this.processedChunks.has(i));
    }

    // 继续下载
    resume() {
        if (this.status !== 'paused') {
            return;
        }

        this.status = 'downloading';
        this.onStatusChange(this.status);
        this.startConcurrentDownloads();
    }

    // 取消下载
    cancel() {
        // 取消所有正在进行的请求
        this.abortControllers.forEach(controller => {
            controller.abort();
        });

        // 重置所有状态
        this.chunks = [];
        this.downloadedChunks = 0;
        this.activeDownloads = 0;
        this.downloadedBytes = 0;
        this.status = 'idle';
        this.error = null;
        this.abortControllers.clear();
        this.pendingChunks = [];
        this.processedChunks.clear();

        this.onStatusChange(this.status);
    }

    // 获取当前状态
    getStatus() {
        return {
            status: this.status,
            downloadedChunks: this.downloadedChunks,
            totalChunks: this.totalChunks,
            downloadedBytes: this.downloadedBytes,
            totalBytes: this.fileSize,
            progress: this.fileSize ? (this.downloadedBytes / this.fileSize) * 100 : 0,
            fileName: this.fileName,
            error: this.error
        };
    }
}

2,核心技术原理

(1)HTTP Range请求

该实现通过HTTP的Range头部实现分块下载:

const response = await fetch(
    this.url + 'large_file/download_large_file/',
    {
        method: 'GET',
        headers: {
            'Range': `bytes=${startByte}-${endByte}`
        },
        signal: controller.signal,
        timeout: this.timeout
    });
  • 服务器会返回状态码206(Partial Content)和请求的文件片段。

(2)并发控制

代码通过控制同时活跃的下载请求数量来实现并发:

while (this.activeDownloads < this.concurrency && this.pendingChunks.length > 0) {
    const chunkIndex = this.pendingChunks.shift();
    this.downloadChunk(chunkIndex);
}

(3)状态管理和进度追踪

  • 跟踪每个块的下载状态(待下载、下载中、已完成、错误)
  • 计算并报告总体进度、下载速度和剩余时间

(4)错误处理和重试机制

对下载失败的块进行自动重试:

if (retryCount < this.maxRetries) {
    console.warn(`${chunkIndex} 下载失败,重试 ${retryCount + 1}/${this.maxRetries}`);
    this.activeDownloads--;
    this.downloadChunk(chunkIndex, retryCount + 1);
}

(5)暂停/恢复功能

通过AbortController取消活跃的请求,并保存未完成的块索引:

pause() {
    // 取消所有正在进行的请求
    this.abortControllers.forEach(controller => {
        controller.abort();
    });
    
    // 将当前处理中的块重新加入待处理队列
    this.pendingChunks = Array.from({length: this.totalChunks}, (_, i) => i)
        .filter(i => !this.processedChunks.has(i));
}

(6)文件合并和下载

所有块下载完成后,使用Blob API合并所有分块并创建下载链接:

const blob = new Blob(this.chunks, {type: this.contentType});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.fileName;
a.click();