大文件下载、断点续传功能

实现文件下载断点续传功能:从零到一

前言

在现代Web应用中,大文件下载是一个常见的需求。为了提高用户体验,实现断点续传功能是必不可少的。本文将详细介绍如何使用Vue3和NestJS实现一个支持断点续传的文件下载功能。

技术栈

  • 前端:Vue3 + Element Plus
  • 后端:NestJS
  • 文件系统:Node.js fs模块

功能特点

  • 支持文件列表展示
  • 支持断点续传
  • 支持暂停/继续/停止操作
  • 实时显示下载进度
  • 优雅的错误处理

后端实现

typescript 复制代码
@Controller('download')
export class DownloadController {
  private readonly targetDir: string;

  constructor() {
    this.targetDir = path.resolve(process.cwd(), 'target');
    // 确保目录存在
    if (!fs.existsSync(this.targetDir)) {
      fs.mkdirSync(this.targetDir, { recursive: true });
    }
  }

  @Get(':filename')
  async downloadFile(@Param('filename') filename: string, @Res() res: Response) {
    try {
      const decodedFilename = decodeURIComponent(filename);
      const filePath = path.join(this.targetDir, decodedFilename);
      
      if (!fs.existsSync(filePath)) {
        return res.status(200).json({ 
          code: 200,
          message: '',
          requestedFile: decodedFilename,
          data: fs.readdirSync(this.targetDir)
        });
      }

      const stat = fs.statSync(filePath);
      const fileSize = stat.size;
      const range = res.req.headers.range;

      if (range) {
        // 处理断点续传
        const parts = range.replace(/bytes=/, '').split('-');
        const start = parseInt(parts[0], 10);
        const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
        const chunksize = (end - start) + 1;
        
        const file = fs.createReadStream(filePath, { start, end });
        
        res.writeHead(206, {
          'Content-Range': `bytes ${start}-${end}/${fileSize}`,
          'Accept-Ranges': 'bytes',
          'Content-Length': chunksize,
          'Content-Type': 'application/octet-stream',
        });
        
        file.pipe(res);
      } else {
        // 普通下载
        res.writeHead(200, {
          'Content-Length': fileSize,
          'Content-Type': 'application/octet-stream',
          'Content-Disposition': `attachment; filename=${encodeURIComponent(decodedFilename)}`,
          'Accept-Ranges': 'bytes',
        });
        
        fs.createReadStream(filePath).pipe(res);
      }
    } catch (error) {
      return res.status(500).json({ 
        message: '下载文件失败',
        error: error.message 
      });
    }
  }
}

前端实现

vue 复制代码
<template>
  <div class="download-container">
    <h1>文件下载</h1>
    <div class="file-list">
      <el-table v-loading="loading" :data="fileList" style="width: 100%">
        <el-table-column prop="filename" label="文件名" />
        <el-table-column prop="size" label="文件大小" />
        <el-table-column prop="createdAt" label="创建时间">
          <template #default="scope">
            {{ formatDate(scope.row.createdAt) }}
          </template>
        </el-table-column>
        <el-table-column label="操作">
          <template #default="scope">
            <div class="operation-column">
              <template v-if="!downloadingStates[scope.row.filename]">
                <el-button type="primary" @click="startDownload(scope.row.filename)">
                  下载
                </el-button>
              </template>
              <template v-else>
                <el-button 
                  v-if="!downloadingStates[scope.row.filename].paused"
                  type="warning" 
                  @click="pauseDownload(scope.row.filename)"
                >
                  暂停
                </el-button>
                <el-button 
                  v-else
                  type="success" 
                  @click="resumeDownload(scope.row.filename)"
                >
                  继续
                </el-button>
                <el-button 
                  type="danger" 
                  @click="stopDownload(scope.row.filename)"
                >
                  停止
                </el-button>
              </template>
              <el-progress 
                v-if="downloadingStates[scope.row.filename]"
                :percentage="downloadProgress[scope.row.filename] || 0"
                :stroke-width="15"
                :show-text="false"
                style="width: 100px; margin-left: 10px;"
              />
            </div>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

核心功能实现

1. 下载控制

typescript 复制代码
const startDownload = async (filename) => {
  if (downloadingStates.value[filename]) return;

  downloadingStates.value[filename] = {
    paused: false,
    receivedLength: 0,
    totalSize: 0
  };

  downloadControllers.value[filename] = new AbortController();
  await downloadFile(filename);
};

const pauseDownload = (filename) => {
  if (downloadingStates.value[filename]) {
    downloadingStates.value[filename].paused = true;
    downloadControllers.value[filename].abort();
  }
};

const resumeDownload = async (filename) => {
  if (downloadingStates.value[filename]?.paused) {
    downloadingStates.value[filename].paused = false;
    downloadControllers.value[filename] = new AbortController();
    await downloadFile(filename);
  }
};

const stopDownload = (filename) => {
  if (downloadingStates.value[filename]) {
    downloadControllers.value[filename].abort();
    delete downloadingStates.value[filename];
    delete downloadControllers.value[filename];
    downloadProgress.value[filename] = 0;
    downloading.value = '';
    ElMessage.info('下载已停止');
  }
};

2. 文件下载实现

typescript 复制代码
const downloadFile = async (filename) => {
  const state = downloadingStates.value[filename];
  
  try {
    const response = await fetchWithRetry(`http://localhost:3030/download/${filename}`, {
      headers: {
        'Accept': 'application/octet-stream',
        'Range': `bytes=${state.receivedLength}-`
      },
      signal: downloadControllers.value[filename].signal
    });

    if (!downloadingStates.value[filename]) return;

    const contentLength = response.headers.get('Content-Length');
    const totalSize = parseInt(contentLength, 10) + state.receivedLength;
    state.totalSize = totalSize;
    
    const reader = response.body.getReader();
    const chunks = [];
    let receivedLength = state.receivedLength;

    while(true) {
      if (!downloadingStates.value[filename]) return;
      if (state.paused) break;

      const {done, value} = await reader.read();
      if (done) break;
      
      chunks.push(value);
      receivedLength += value.length;
      state.receivedLength = receivedLength;
      
      const progress = Math.round((receivedLength / totalSize) * 100);
      downloadProgress.value[filename] = progress;
    }

    if (!state.paused && downloadingStates.value[filename]) {
      const blob = new Blob(chunks);
      const url = window.URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      window.URL.revokeObjectURL(url);
      document.body.removeChild(a);
      ElMessage.success('下载成功');
      
      delete downloadingStates.value[filename];
      delete downloadControllers.value[filename];
      downloadProgress.value[filename] = 0;
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('下载已暂停或停止');
    } else {
      ElMessage.error(error.message || '下载失败,请重试');
      delete downloadingStates.value[filename];
      delete downloadControllers.value[filename];
      downloadProgress.value[filename] = 0;
    }
  }
};

后续优化方向

  1. 添加下载速度显示
  2. 实现多文件并行下载
  3. 添加下载历史记录
  4. 实现下载队列管理
  5. 添加文件校验功能

希望这篇文章对你有所帮助!如果你有任何问题或建议,欢迎在评论区留言讨论。

相关推荐
持续升级打怪中19 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路23 分钟前
GDAL 实现矢量合并
前端
hxjhnct25 分钟前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 小时前
我学习到的AG-UI的概念
前端
韩师傅1 小时前
前端开发消亡史:AI也无法掩盖没有设计创造力的真相
前端·人工智能·后端
XiaoYu20021 小时前
第12章 支付宝SDK
前端
双向332 小时前
RAG的下一站:检索增强生成如何重塑企业知识中枢?
前端
拖拉斯旋风2 小时前
从零开始:使用 Ollama 在本地部署开源大模型并集成到 React 应用
前端·javascript·ollama