大文件分片上传并保存上传进度

一、基本原理

1.1 为什么需要分片上传?

在前端开发中,当需要上传大文件时,常常会遇到以下问题:

  • 请求超时:大文件上传耗时长,容易触发服务器或浏览器的超时限制
  • 上传失败成本高:如果上传到 99% 时网络中断,则需要重新上传整个文件
  • 内存占用大:一次性加载大文件会占用大量内存资源
  • 用户体验差:无法精确显示上传进度,用户等待时间长

分片上传技术可以有效解决上述问题,它的核心思想是:将大文件切分成多个小块,分别上传,最后在服务器端合并

1.2 分片上传的基本流程

分片上传流程

  1. 文件分片:使用 Blob.slice() 方法将文件切分成多个小块
  2. 并发上传:将这些分片并发上传到服务器
  3. 上传进度记录:记录每个分片的上传状态和进度
  4. 断点续传:当上传中断时,可以从已上传的部分继续
  5. 服务器合并:所有分片上传完成后,服务器将分片合并成完整文件

二、前端实现方法

2.1 文件分片

使用 JavaScript 的 File 和 Blob API 可以轻松实现文件分片:

/**
 * 将文件切片
 * @param {File} file - 要上传的文件
 * @param {Number} chunkSize - 分片大小(字节)
 * @returns {Array} 分片数组
 */
function createFileChunks(file, chunkSize = 2 * 1024 * 1024) {
  const chunks = [];
  let cur = 0;
  while (cur < file.size) {
    chunks.push(file.slice(cur, cur + chunkSize));
    cur += chunkSize;
  }
  return chunks;
}

2.2 计算文件唯一标识

为了实现断点续传和秒传功能,需要给每个文件生成唯一标识:

/**
 * 计算文件的hash值(使用spark-md5)
 * @param {File} file - 文件对象
 * @returns {Promise<string>} 文件hash值
 */
async function calculateFileHash(file) {
  return new Promise((resolve) => {
    const chunks = createFileChunks(file);
    const spark = new SparkMD5.ArrayBuffer();
    let count = 0;
    
    const loadNext = (index) => {
      const reader = new FileReader();
      reader.onload = (e) => {
        spark.append(e.target.result);
        count++;
        
        if (count === chunks.length) {
          resolve(spark.end());
        } else {
          loadNext(count);
        }
      };
      reader.readAsArrayBuffer(chunks[index]);
    };
    
    loadNext(0);
  });
}

2.3 上传分片

/**
 * 上传单个分片
 * @param {Blob} chunk - 分片数据
 * @param {Number} index - 分片索引
 * @param {String} fileHash - 文件hash
 * @param {String} fileName - 文件名
 * @param {Function} onProgress - 进度回调
 * @returns {Promise}
 */
async function uploadChunk(chunk, index, fileHash, fileName, onProgress) {
  const formData = new FormData();
  formData.append('chunk', chunk);
  formData.append('hash', fileHash);
  formData.append('fileName', fileName);
  formData.append('chunkIndex', index);
  
  return axios.post('/upload/chunk', formData, {
    onUploadProgress: (progressEvent) => {
      if (onProgress) {
        onProgress({
          chunkIndex: index,
          loaded: progressEvent.loaded,
          total: progressEvent.total
        });
      }
    }
  });
}

2.4 并发控制

为了避免同时发送过多请求导致浏览器或服务器崩溃,需要控制并发数:

/**
 * 控制并发上传
 * @param {Array} chunks - 分片数组
 * @param {String} fileHash - 文件hash
 * @param {String} fileName - 文件名
 * @param {Number} limit - 并发数限制
 * @param {Function} onProgress - 进度回调
 * @returns {Promise}
 */
async function uploadChunksWithConcurrencyLimit(chunks, fileHash, fileName, limit = 3, onProgress) {
  const pool = []; // 并发池
  const results = []; // 所有请求的结果
  let uploadedChunks = 0; // 已上传的分片数
  
  // 创建上传任务
  const createTask = (chunk, index) => {
    const task = uploadChunk(chunk, index, fileHash, fileName, (progress) => {
      // 更新进度
      if (onProgress) {
        onProgress({
          chunkIndex: index,
          loaded: progress.loaded,
          total: progress.total,
          uploadedChunks,
          totalChunks: chunks.length
        });
      }
    }).then(result => {
      uploadedChunks++;
      results[index] = result;
      // 从并发池中移除已完成的任务
      const taskIndex = pool.findIndex(t => t === task);
      if (taskIndex !== -1) pool.splice(taskIndex, 1);
    });
    
    pool.push(task);
    return task;
  };
  
  // 开始上传
  for (let i = 0; i < chunks.length; i++) {
    const task = createTask(chunks[i], i);
    
    // 控制并发数
    if (pool.length >= limit) {
      await Promise.race(pool);
    }
  }
  
  // 等待所有分片上传完成
  await Promise.all(pool);
  return results;
}

三、保存和恢复上传进度

3.1 进度信息存储

可以使用 localStorage 或 IndexedDB 存储上传进度信息:

/**
 * 保存上传进度
 * @param {String} fileHash - 文件hash
 * @param {Object} progressInfo - 进度信息
 */
function saveUploadProgress(fileHash, progressInfo) {
  try {
    localStorage.setItem(
      `upload_progress_${fileHash}`,
      JSON.stringify(progressInfo)
    );
  } catch (e) {
    console.error('保存上传进度失败', e);
  }
}
 
/**
 * 获取上传进度
 * @param {String} fileHash - 文件hash
 * @returns {Object|null} 进度信息
 */
function getUploadProgress(fileHash) {
  try {
    const progress = localStorage.getItem(`upload_progress_${fileHash}`);
    return progress ? JSON.parse(progress) : null;
  } catch (e) {
    console.error('获取上传进度失败', e);
    return null;
  }
}

3.2 断点续传实现

在上传前,先检查是否有未完成的上传任务:

/**
 * 检查文件上传状态
 * @param {String} fileHash - 文件hash
 * @returns {Promise<Object>} 上传状态
 */
async function checkFileUploadStatus(fileHash) {
  try {
    const response = await axios.post('/upload/check', { fileHash });
    return response.data;
  } catch (e) {
    console.error('检查文件上传状态失败', e);
    return { uploaded: false, uploadedChunks: [] };
  }
}
 
/**
 * 上传文件(支持断点续传)
 * @param {File} file - 文件对象
 */
async function uploadFile(file) {
  // 1. 计算文件hash
  const fileHash = await calculateFileHash(file);
  const fileName = file.name;
  
  // 2. 检查文件上传状态
  const { uploaded, uploadedChunks = [] } = await checkFileUploadStatus(fileHash);
  
  // 3. 如果文件已上传完成,直接返回
  if (uploaded) {
    console.log('文件已上传,秒传成功');
    return;
  }
  
  // 4. 创建文件分片
  const chunks = createFileChunks(file);
  
  // 5. 过滤掉已上传的分片
  const chunksToUpload = chunks.filter((_, index) => !uploadedChunks.includes(index));
  
  // 6. 上传分片
  const uploadProgress = {
    fileHash,
    fileName,
    chunkProgress: {},
    uploadedChunks: [...uploadedChunks],
    totalChunks: chunks.length
  };
  
  // 保存初始进度
  saveUploadProgress(fileHash, uploadProgress);
  
  // 上传分片并更新进度
  await uploadChunksWithConcurrencyLimit(
    chunksToUpload,
    fileHash,
    fileName,
    3,
    (progress) => {
      // 更新进度信息
      uploadProgress.chunkProgress[progress.chunkIndex] = {
        loaded: progress.loaded,
        total: progress.total
      };
      
      if (!uploadProgress.uploadedChunks.includes(progress.chunkIndex) && 
          progress.loaded === progress.total) {
        uploadProgress.uploadedChunks.push(progress.chunkIndex);
      }
      
      // 保存进度
      saveUploadProgress(fileHash, uploadProgress);
      
      // 计算总进度
      const totalLoaded = Object.values(uploadProgress.chunkProgress)
        .reduce((acc, cur) => acc + cur.loaded, 0);
      const totalSize = chunks.reduce((acc, chunk) => acc + chunk.size, 0);
      const percent = Math.floor((totalLoaded / totalSize) * 100);
      
      console.log(`上传进度: ${percent}%`);
    }
  );
  
  // 7. 通知服务器合并分片
  await axios.post('/upload/merge', {
    fileHash,
    fileName,
    size: file.size
  });
  
  // 8. 清除进度记录
  localStorage.removeItem(`upload_progress_${fileHash}`);
  
  console.log('文件上传成功');
}

四、完整实现示例

下面是一个完整的前端实现示例,包含UI交互:

// 引入依赖
import SparkMD5 from 'spark-md5';
import axios from 'axios';
 
class FileUploader {
  constructor(options = {}) {
    this.chunkSize = options.chunkSize || 2 * 1024 * 1024; // 默认2MB
    this.concurrency = options.concurrency || 3; // 默认并发数
    this.baseUrl = options.baseUrl || '';
    this.onProgress = options.onProgress || (() => {});
    this.onSuccess = options.onSuccess || (() => {});
    this.onError = options.onError || (() => {});
  }
  
  // 创建文件分片
  createFileChunks(file) {
    const chunks = [];
    let cur = 0;
    while (cur < file.size) {
      chunks.push(file.slice(cur, cur + this.chunkSize));
      cur += this.chunkSize;
    }
    return chunks;
  }
  
  // 计算文件hash
  calculateFileHash(file) {
    return new Promise((resolve) => {
      const chunks = this.createFileChunks(file);
      const spark = new SparkMD5.ArrayBuffer();
      let count = 0;
      
      const loadNext = (index) => {
        const reader = new FileReader();
        reader.onload = (e) => {
          spark.append(e.target.result);
          count++;
          
          // 更新hash计算进度
          this.onProgress({
            stage: 'hash',
            percentage: Math.floor((count / chunks.length) * 100)
          });
          
          if (count === chunks.length) {
            resolve(spark.end());
          } else {
            loadNext(count);
          }
        };
        reader.readAsArrayBuffer(chunks[index]);
      };
      
      loadNext(0);
    });
  }
  
  // 检查文件上传状态
  async checkFileUploadStatus(fileHash) {
    try {
      const response = await axios.post(`${this.baseUrl}/upload/check`, { fileHash });
      return response.data;
    } catch (e) {
      console.error('检查文件上传状态失败', e);
      return { uploaded: false, uploadedChunks: [] };
    }
  }
  
  // 上传单个分片
  uploadChunk(chunk, index, fileHash, fileName) {
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('hash', fileHash);
    formData.append('fileName', fileName);
    formData.append('chunkIndex', index);
    
    return axios.post(`${this.baseUrl}/upload/chunk`, formData, {
      onUploadProgress: (progressEvent) => {
        this.chunkProgress[index] = {
          loaded: progressEvent.loaded,
          total: progressEvent.total
        };
        
        this.updateTotalProgress();
      }
    });
  }
  
  // 更新总进度
  updateTotalProgress() {
    const loaded = Object.values(this.chunkProgress)
      .reduce((acc, cur) => acc + cur.loaded, 0);
    const total = this.chunks.reduce((acc, chunk) => acc + chunk.size, 0);
    const percentage = Math.floor((loaded / total) * 100);
    
    // 更新上传进度
    this.onProgress({
      stage: 'upload',
      percentage,
      loaded,
      total
    });
    
    // 保存进度
    this.saveUploadProgress();
  }
  
  // 保存上传进度
  saveUploadProgress() {
    const uploadedChunks = Object.entries(this.chunkProgress)
      .filter(([_, progress]) => progress.loaded === progress.total)
      .map(([index]) => parseInt(index));
    
    const progressInfo = {
      fileHash: this.fileHash,
      fileName: this.fileName,
      chunkProgress: this.chunkProgress,
      uploadedChunks,
      totalChunks: this.chunks.length
    };
    
    try {
      localStorage.setItem(
        `upload_progress_${this.fileHash}`,
        JSON.stringify(progressInfo)
      );
    } catch (e) {
      console.error('保存上传进度失败', e);
    }
  }
  
  // 获取上传进度
  getUploadProgress(fileHash) {
    try {
      const progress = localStorage.getItem(`upload_progress_${fileHash}`);
      return progress ? JSON.parse(progress) : null;
    } catch (e) {
      console.error('获取上传进度失败', e);
      return null;
    }
  }
  
  // 控制并发上传
  async uploadChunksWithConcurrencyLimit() {
    const pool = []; // 并发池
    const chunksToUpload = this.chunks.filter((_, index) => 
      !this.uploadedChunks.includes(index)
    );
    
    // 创建上传任务
    const createTask = (chunk, index) => {
      const task = this.uploadChunk(chunk, index, this.fileHash, this.fileName)
        .then(result => {
          // 从并发池中移除已完成的任务
          const taskIndex = pool.findIndex(t => t === task);
          if (taskIndex !== -1) pool.splice(taskIndex, 1);
          return result;
        });
      
      pool.push(task);
      return task;
    };
    
    // 开始上传
    for (let i = 0; i < chunksToUpload.length; i++) {
      const chunk = chunksToUpload[i];
      const index = this.chunks.findIndex(c => c === chunk);
      
      const task = createTask(chunk, index);
      
      // 控制并发数
      if (pool.length >= this.concurrency) {
        await Promise.race(pool);
      }
    }
    
    // 等待所有分片上传完成
    await Promise.all(pool);
  }
  
  // 合并分片
  async mergeChunks() {
    await axios.post(`${this.baseUrl}/upload/merge`, {
      fileHash: this.fileHash,
      fileName: this.fileName,
      size: this.file.size
    });
  }
  
  // 上传文件
  async upload(file) {
    try {
      this.file = file;
      this.fileName = file.name;
      this.chunkProgress = {};
      
      // 1. 计算文件hash
      this.onProgress({ stage: 'hash', percentage: 0 });
      this.fileHash = await this.calculateFileHash(file);
      
      // 2. 检查文件上传状态
      const { uploaded, uploadedChunks = [] } = await this.checkFileUploadStatus(this.fileHash);
      
      // 3. 如果文件已上传完成,直接返回
      if (uploaded) {
        this.onProgress({ stage: 'upload', percentage: 100 });
        this.onSuccess({ fileHash: this.fileHash, fileName: this.fileName });
        return;
      }
      
      // 4. 创建文件分片
      this.chunks = this.createFileChunks(file);
      this.uploadedChunks = uploadedChunks;
      
      // 5. 恢复上传进度
      const savedProgress = this.getUploadProgress(this.fileHash);
      if (savedProgress) {
        this.chunkProgress = savedProgress.chunkProgress || {};
        this.uploadedChunks = savedProgress.uploadedChunks || [];
        this.updateTotalProgress();
      }
      
      // 6. 上传分片
      this.onProgress({ stage: 'upload', percentage: 0 });
      await this.uploadChunksWithConcurrencyLimit();
      
      // 7. 合并分片
      this.onProgress({ stage: 'merge', percentage: 0 });
      await this.mergeChunks();
      
      // 8. 清除进度记录
      localStorage.removeItem(`upload_progress_${this.fileHash}`);
      
      this.onProgress({ stage: 'merge', percentage: 100 });
      this.onSuccess({ fileHash: this.fileHash, fileName: this.fileName });
    } catch (error) {
      this.onError(error);
    }
  }
}
 
// 使用示例
const uploader = new FileUploader({
  baseUrl: 'http://localhost:3000',
  onProgress: (progress) => {
    console.log(`阶段: ${progress.stage}, 进度: ${progress.percentage}%`);
    // 更新UI进度条
    document.getElementById('progress').style.width = `${progress.percentage}%`;
    document.getElementById('progress-text').innerText = `${progress.percentage}%`;
  },
  onSuccess: (result) => {
    console.log('上传成功', result);
    // 更新UI
    document.getElementById('status').innerText = '上传成功';
  },
  onError: (error) => {
    console.error('上传失败', error);
    // 更新UI
    document.getElementById('status').innerText = '上传失败';
  }
});
 
// 绑定上传事件
document.getElementById('file-input').addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (file) {
    uploader.upload(file);
  }
});

五、常见问题及解决方案

5.1 如何处理上传失败的分片?

当某个分片上传失败时,可以采用以下策略:

  1. 重试机制:对失败的分片进行多次重试
  2. 降级策略:如果重试多次仍失败,可以降低并发数或分片大小
  3. 记录失败信息:将失败的分片信息记录下来,下次上传时优先处理
// 分片上传失败重试示例
async function uploadChunkWithRetry(chunk, index, fileHash, fileName, retries = 3) {
  try {
    return await uploadChunk(chunk, index, fileHash, fileName);
  } catch (err) {
    if (retries > 0) {
      console.log(`分片${index}上传失败,剩余重试次数:${retries}`);
      return uploadChunkWithRetry(chunk, index, fileHash, fileName, retries - 1);
    } else {
      throw new Error(`分片${index}上传失败,已达到最大重试次数`);
    }
  }
}

5.2 如何优化文件hash计算?

对于大文件,计算完整hash可能会很耗时,可以采用以下优化方法:

  1. 抽样hash:只取文件的部分内容计算hash
  2. Web Worker:将hash计算放在Web Worker中进行,避免阻塞主线程
  3. 分片计算:分批计算hash,避免一次性读取大文件
// 抽样hash计算示例
async function calculateFileHashSample(file) {
  return new Promise((resolve) => {
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();
    
    // 取文件的头部、中间和尾部各2MB计算hash
    const chunks = [
      file.slice(0, 2 * 1024 * 1024),
      file.slice(file.size / 2 - 1024 * 1024, file.size / 2 + 1024 * 1024),
      file.slice(file.size - 2 * 1024 * 1024, file.size)
    ];
    
    let count = 0;
    
    reader.onload = (e) => {
      spark.append(e.target.result);
      count++;
      
      if (count === chunks.length) {
        // 加入文件大小信息,提高唯一性
        spark.append(file.size.toString());
        resolve(spark.end());
      } else {
        reader.readAsArrayBuffer(chunks[count]);
      }
    };
    
    reader.readAsArrayBuffer(chunks[0]);
  });
}

5.3 如何处理浏览器刷新或关闭?

当用户刷新或关闭浏览器时,可以通过以下方式保存上传状态:

  1. localStorage:保存上传进度和状态
  2. beforeunload事件:在页面关闭前提示用户
  3. Service Worker:在后台继续上传(高级用法)
// 监听页面关闭事件
window.addEventListener('beforeunload', (e) => {
  if (uploader.isUploading) {
    // 显示提示信息
    const message = '文件正在上传中,关闭页面将中断上传,是否确认关闭?';
    e.returnValue = message;
    return message;
  }
});

5.4 秒传功能实现

秒传功能的核心是通过文件hash判断服务器是否已存在相同文件:

async function uploadFile(file) {
  // 1. 计算文件hash
  const fileHash = await calculateFileHash(file);
  
  // 2. 检查文件是否已存在
  const response = await axios.post('/upload/check', { fileHash });
  
  // 3. 如果文件已存在,直接返回成功
  if (response.data.uploaded) {
    console.log('文件已存在,秒传成功');
    return response.data.url;
  }
  
  // 4. 文件不存在,继续上传流程
  // ...
}

六、服务器端实现思路

虽然本文主要关注前端实现,但服务器端的处理也很重要:

  1. 接收分片:处理前端上传的文件分片
  2. 保存分片:将分片保存到临时目录
  3. 合并分片:所有分片上传完成后,合并成完整文件
  4. 清理分片:合并完成后,清理临时分片文件

服务器端可以使用Node.js、Java、Python等语言实现,核心逻辑相似。

七、总结

大文件分片上传是解决大文件上传问题的有效方案,通过合理的前端实现,可以显著提升用户体验:

  • 可靠性提升:断点续传确保上传过程可靠
  • 性能优化:并发上传提高上传速度
  • 用户体验改善:精确的进度显示和状态保存
  • 服务器压力减轻:分片处理减轻服务器压力

在实际项目中,可以根据具体需求调整分片大小、并发数等参数,以达到最佳效果。

八、参考资源