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

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

前言

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

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

相关推荐
卡戎-caryon30 分钟前
【项目实践】boost 搜索引擎
linux·前端·网络·搜索引擎·boost·jieba·cpp-http
别催小唐敲代码2 小时前
解决跨域的4种方法
java·服务器·前端·json
溪饱鱼2 小时前
Nuxt3还能用吗?
前端·个人开发·seo
丨丨三戒丶3 小时前
layui轮播图根据设备宽度图片等比例,高度自适应
前端·javascript·layui
进取星辰3 小时前
20、数据可视化:魔镜报表——React 19 图表集成
前端·react.js·信息可视化
寧笙(Lycode)4 小时前
React实现B站评论Demo
前端·react.js·前端框架
24白菜头4 小时前
CSS学习笔记
前端·javascript·css·笔记·学习
蠢货爱好者4 小时前
Linux中web服务器的部署及优化
linux·服务器·前端
NightReader6 小时前
Google-chrome版本升级后sogou输入法不工作了
前端·chrome
周山至水数翠峰6 小时前
用网页显示工控仪表
前端