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

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

前言

在现代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. 添加文件校验功能

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

相关推荐
LuciferHuang18 分钟前
震惊!三万star开源项目竟有致命Bug?
前端·javascript·debug
GISer_Jing19 分钟前
前端实习总结——案例与大纲
前端·javascript
天天进步201523 分钟前
前端工程化:Webpack从入门到精通
前端·webpack·node.js
姑苏洛言1 小时前
编写产品需求文档:黄历日历小程序
前端·javascript·后端
知识分享小能手2 小时前
Vue3 学习教程,从入门到精通,使用 VSCode 开发 Vue3 的详细指南(3)
前端·javascript·vue.js·学习·前端框架·vue·vue3
姑苏洛言2 小时前
搭建一款结合传统黄历功能的日历小程序
前端·javascript·后端
你的人类朋友3 小时前
🤔什么时候用BFF架构?
前端·javascript·后端
知识分享小能手3 小时前
Bootstrap 5学习教程,从入门到精通,Bootstrap 5 表单验证语法知识点及案例代码(34)
前端·javascript·学习·typescript·bootstrap·html·css3
一只小灿灿4 小时前
前端计算机视觉:使用 OpenCV.js 在浏览器中实现图像处理
前端·opencv·计算机视觉