在前端开发中,处理几 MB 的小文件轻而易举,但当面对几百 MB 甚至 GB 级别的大文件时,直接上传往往会遇到以下痛点:

  1. 超时失败:网络波动导致长连接中断,整个文件需重新上传。
  2. 内存溢出:一次性读取大文件可能导致浏览器崩溃。
  3. 体验极差:用户无法感知进度,且无法暂停或恢复。

本文将基于 Vue 3 + TypeScript + Element Plus,结合 spark-md5,手把手教你实现一个支持秒传、分片上传、并发控制及 Blob 下载的完整解决方案。

📂 核心思路

1. 上传流程

  1. 计算 Hash:利用 SparkMD5 计算文件的唯一标识(用于秒传和断点续传识别)。
  2. 文件切片使用 File.prototype.slice()将大文件切割成固定大小(如 2MB)的 Chunk。
  3. 并发上传:将切片并行发送给后端接口。
  4. 合并文件:所有切片上传成功后,通知后端合并。

2. 下载流程

  1. 请求数据:通过 fetch 或 axios 获取二进制流。
  2. 创建 Blob将响应数据转换为 Blob 对象。
  3. 触发下载:动态创建 <a> 标签并模拟点击。

安装

  • SparkMD5 (高性能 MD5 计算库)
npm install spark-md5

💻 核心代码实现

1. 模板结构 (Template)

我们使用 el-upload 的自定义模式,关闭自动上传,完全由我们控制逻辑。

<template>
  <div class="upload-container">
    <!-- 拖拽上传区域 -->
    <el-upload
      drag
      :auto-upload="false"
      :show-file-list="false"
      :on-change="handleFileChange"
      action="#"
    >
      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
      <div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
      <template #tip>
        <div class="el-upload__tip">支持大文件分片上传,请勿上传超过 2GB 的文件</div>
      </template>
    </el-upload>

    <!-- 进度显示 -->
    <div v-if="uploading" class="progress-box">
      <span>{{ fileName }}</span>
      <el-progress :percentage="uploadPercentage" :status="uploadStatus" />
      <span>{{ uploadStatusText }}</span>
    </div>
    
    <!-- 模拟下载测试区 -->
    <el-divider />
    <div class="download-list">
      <h3>文件列表 (点击下载)</h3>
      <el-button type="primary" link @click="handleDownload('example.zip', 'http://mock-api.com/file.zip')">
        下载: example_large_file.zip
      </el-button>
    </div>
  </div>
</template>

2. 逻辑实现 (Script Setup)

A. 状态定义与配置
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import SparkMD5 from "spark-md5";

// 常量配置
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 切片

// 响应式状态
const fileObj = ref(null);
const uploading = ref(false);
const uploadPercentage = ref(0);
const uploadStatus = ref(""); // success, exception, ''
const uploadStatusText = ref("");
const fileName = ref("");
B. 核心上传入口
const handleFileChange = (file) => {
  fileObj.value = file.raw;
  fileName.value = file.name;
  uploadFileRequest(); // 触发上传
};

const uploadFileRequest = async () => {
  if (!fileObj.value) return;
  
  const file = fileObj.value;
  uploading.value = true;
  uploadPercentage.value = 0;
  uploadStatusText.value = "正在计算文件哈希...";

  try {
    // 1. 计算 Hash
    const fileHash = await calculateHash(file);
    
    // 2. 秒传校验 (Mock)
    // const verifyRes = await axios.post('/api/upload/verify', { hash: fileHash });
    const verifyRes = { needUpload: true }; 

    if (!verifyRes.needUpload) {
      finishUpload("秒传成功:文件已存在");
      return;
    }

    // 3. 切片
    const chunks = createFileChunks(file, CHUNK_SIZE);
    uploadStatusText.value = `准备上传 ${chunks.length} 个分片...`;

    // 4. 并发上传
    const uploadPromises = chunks.map((chunk, index) => 
      uploadChunk({ chunk, hash: fileHash, index, filename: file.name })
    );
    
    await Promise.all(uploadPromises);

    // 5. 合并文件
    uploadStatusText.value = "正在合并文件...";
    // await axios.post('/api/upload/merge', { hash: fileHash, filename: file.name });
    await new Promise(r => setTimeout(r, 1000)); // 模拟合并耗时

    finishUpload("上传成功");
  } catch (error) {
    handleError(error);
  } finally {
    uploading.value = false;
    fileObj.value = null;
  }
};
C. 关键辅助函数

1. 非阻塞式 Hash 计算 大文件计算 Hash容易卡死 UI,我们采用分片读取 + setTimeout 让出主线程的策略。

const calculateHash = (file) => {
  return new Promise((resolve, reject) => {
    const spark = new SparkMD5.ArrayBuffer();
    const fileReader = new FileReader();
    let offset = 0;

    const readChunk = () => {
      if (offset >= file.size) {
        spark.end();
        resolve(spark.getResult());
        return;
      }
      const chunk = file.slice(offset, offset + CHUNK_SIZE);
      offset += CHUNK_SIZE;
      fileReader.readAsArrayBuffer(chunk);
    };

    fileReader.onload = (e) => {
      spark.append(e.target.result);
      // 关键:让出主线程,防止 UI 冻结
      setTimeout(readChunk, 0); 
    };

    fileReader.onerror = reject;
    readChunk();
  });
};

2. 文件切片

const createFileChunks = (file, size) => {
  const chunks = [];
  let cur = 0;
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + size));
    cur += size;
  }
  return chunks;
};

3. 模拟分片上传与进度更新

const uploadChunk = ({ chunk, hash, index }) => {
  return new Promise((resolve) => {
    // 实际项目中这里使用 axios.post FormData
    const time = Math.random() * 1000 + 500; // 模拟网络延迟
    
    setTimeout(() => {
      // 简单进度计算:当前索引 / 总分片数
      // 注意:实际并发下进度条可能跳动,建议维护一个 uploadedCount计数器
      const totalChunks = Math.ceil(fileObj.value.size / CHUNK_SIZE);
      uploadPercentage.value = Math.floor(((index + 1) / totalChunks) * 100);
      resolve();
    }, time);
  });
};

3. 大文件下载实现

前端下载大文件通常有两种方式:

  1. Window Open:简单 GET 请求,无法携带复杂 Header。
  2. Blob Download:推荐方式,支持鉴权 Token,可处理二进制流。
const handleDownload = (fileName, url) => {
  ElMessage.info(`开始下载: ${fileName}`);
  downloadByBlob(url, fileName);
};

const downloadByBlob = async (url, fileName) => {
  try {
    // 实际项目中使用 fetch 或 axios
    // const response = await fetch(url, { headers: { 'Authorization': 'Bearer token' } });
    // const blob = await response.blob();

    // 模拟生成 Blob
    const mockContent = "这是一个模拟的大文件内容。";
    const blob = new Blob([mockContent], { type: "application/octet-stream" });

    // 创建临时链接触发下载
    const link = document.createElement("a");
    link.href = window.URL.createObjectURL(blob);
    link.download = fileName;
    document.body.appendChild(link);
    link.click();
    
    // 清理
    document.body.removeChild(link);
    window.URL.revokeObjectURL(link.href); 
  } catch (error) {
    ElMessage.error("下载失败");
  }
};

⚡ 性能优化与最佳实践

1. 真正的并发控制

上面的代码使用了 Promise.all,这意味着如果文件有 1000 个分片,会同时发起 1000 个请求,这会导致浏览器卡顿或被服务器拒绝。 优化方案使用队列限制最大并发数(例如最多同时上传 3-5 个分片)。

// 简易并发控制器
async function concurrentRun(tasks, limit) {
  const results = [];
  let index = 0;
  const runNext = async () => {
    if (index >= tasks.length) return;
    const task = tasks[index++];
    await task();
    await runNext();
  };
  const workers = Array(Math.min(limit, tasks.length)).fill(null).map(() => runNext());
  await Promise.all(workers);
}

2. 断点续传

 uploadFileRequest 中,切片上传前应先调用后端接口 /api/upload/check?hash=xxx。 后端返回已上传的分片索引数组(如 [0, 1, 3]),前端过滤掉这些分片,只上传剩余部分。

3. Web Worker 计算 Hash

如果文件极大(>500MB),即使在 setTimeout 中计算 Hash 仍可能影响交互。 终极方案:将 calculateHash 逻辑移至 Web Worker 中运行,彻底隔离主线程

4. 错误重试

网络不稳定时,单个分片可能失败。应在 uploadChunk 中加入重试机制(Retry Pattern),失败后自动重试 3 次。


📝 总结

通过本文的实现,我们解决了大文件上传的核心痛点:

  • 稳定性:分片上传避免单点失败导致全盘重来。
  • 效率:秒传功能节省带宽和时间。
  • 体验:实时进度反馈和流畅的 UI 交互。

这套方案不仅适用于 Vue,其核心逻辑(切片、Hash、并发、合并)在任何前端框架甚至原生 JS 中都是通用的。希望这篇博客能帮助你轻松搞定大文件传输难题!

更多推荐