大文件分片上传

79 阅读4分钟

背景

一般上传超过 100MB 算大文件,看各公司定义情况。

大文件上传很慢,影响用户体验。

问题

  1. 上传很慢
  2. 网络差的时候,会更慢
  3. 网络波动会影响上传的速度,还会影响上传是否成功。
  4. 上传一半,如果失败,影响用户体验

整体流程

分片脑图

分片具体代码

  • 创建分片

/*
* params: file 文件对象
*	return: 返回分片数组
*/
const createChunks = (file) => {
  const result = [];
  const size = 1024 * 1024; // 根据公司要求或文件大小,每一片多大
  for(let i = 0; i <= file.size; i += size) {
    // 文件对象原型自带的 slice 方法进行切割
    const chunk = file.slice(i, i + size);
    result.push(chunk)
  }

  return result
}
  • 文件命名hash

const hash = (chunks) => {
  // 使用MD5进行唯一值处理
  const spark = new SparkMD5();

  // 通过递归给每个分片进行处理
  function _read(index) {
    // 先校验,如果到最后一个,结束递归
    if(index >= chunks.length) {
      spark.end();
      return;
    }

    // 获取其中的一个片段
    const blob = chunks[index];
    // 创建一个FileReader对象
    const reader = new FileReader(blob);
    reader.onload = (e) => {
      // 拿到当前的文件
      const bytes = e.target.result;
      // 添加到 spark 中
      spark.append(bytes);
      // 递归到下一个片段
      _read(index + 1)
    }
    // FileReader 接口的 readAsArrayBuffer() 方法用于开始读取指定 Blob 或 File 的内容。当读取操作完成时,readyState 属性变为 DONE,并触发 loadend 事件。此时,result 属性包含一个表示文件数据的 ArrayBuffer。
    reader.readAsArrayBuffer(blob);
  }
  // 从第一个开始
  _read(0)
}

通过上面的代码,可以实现一个同步的上传,但是如果中途某些方法会变为异步的话,会导致阻塞或者报错,所以我们要给这个改为异步处理的,这样既不会影响同步,也不会影响异步。


const hash = (chunks) => {
  return new Promise((resolve) => {
     // 使用MD5进行唯一值处理
  const spark = new SparkMD5();

  // 通过递归给每个分片进行处理
  function _read(index) {
    // 先校验,如果到最后一个,结束递归
    if(index >= chunks.length) {
      // 假设spark.end 为异步,需要通过resolve包裹
      resolve(spark.end());
      return;
    }

    // 获取其中的一个片段
    const blob = chunks[index];
    // 创建一个FileReader对象
    const reader = new FileReader(blob);
    reader.onload = (e) => {
      // 拿到当前的文件
      const bytes = e.target.result;
      // 添加到 spark 中
      spark.append(bytes);
      // 递归到下一个片段
      _read(index + 1)
    }
    // FileReader 接口的 readAsArrayBuffer() 方法用于开始读取指定 Blob 或 File 的内容。当读取操作完成时,readyState 属性变为 DONE,并触发 loadend 事件。此时,result 属性包含一个表示文件数据的 ArrayBuffer。
    reader.readAsArrayBuffer(blob);
  }
  // 从第一个开始
  _read(0)
  })
}
  • 发送分片
/*
* params: 分片数组,分片后的hash数组,文件名称
*
*/
const upload = (chunks, hash, fileName) => {
  // 通过promise.all进行上传,如果全部成功会有返回
  // 创建一个数组,存储每个上传任务
  const taskArr = [];

  chunks.forEach((chunk, index) => {
    // 暂时我这里使用formData对象进行上传
    // 后续根据后端要求进行更改
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('hash', `${hash}-${index}-${fileName}`);
    formData.append('fileName', fileName);
    const task = axios.post('your-url', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })
    taskArr.push(task);
  })

  Promise.all(taskArr).then(res => {
    console.log('上传成功', res)
  }).catch(err => {
     console.log('上传失败', err)
  })
}

断点续传脑图

断点续传代码(暂时不支持关闭浏览器后的断点续传)

  • 保存已上传的分片
// 使用ref保存已上传的分片
 const fileInfoRef = useRef(null); // 保存文件信息:hash, chunks, fileName

// 判断是否是暂停后点击继续操作
// 如果是,并且ref内有值
if(isResume && fileInfoRef.current) {
  chunks = fileInfoRef.current.chunks;
  hash = fileInfoRef.current.hash;
  console.log('继续上传,使用已保存的 hash:', hash);
} else {
  chunks = createChunks(file);
  try {
    // 计算hash(唯一值,为后续断点续传、合并文件做准备)
    hash = await createHash(chunks, isCancelledRef);
    if (isCancelledRef.current) {
      console.log('Hash 计算被取消');
      return;
    }
    // 保存文件信息
    fileInfoRef.current = { chunks, hash, fileName: file.name };
  } catch (error) {
    if (error.message === 'Hash calculation cancelled') {
      message.error('Hash 计算被用户取消');
      return;
    }
    message.error('文件哈希计算失败');
    console.error(error);
  }
}
  • 检查上传状态(根据接口查询)
// 先检查已上传的分片
const verifyRes = await axios.post('http://127.0.0.1:3000/verify', {
  hash,
  fileName,
  chunkCount: chunks.length
});

const { uploadedChunks = [], isComplete } = verifyRes.data;

// 如果已完成,直接合并
if (isComplete) {
  setPercent(100);
  mergeChunks(hash, fileName, chunks);
  return;
}
  • 检查文件是否存在(接口处理,直接返回是否存在)

做大文件上传想到和遇到的一些问题

当文件上传前计算hash的时候,用户暂停,再继续上传,这个hash计算需要从新计算吗?

答案是: 需要从新计算

原因是:

  • hash 是基于文件内容动态计算出来的
  • 计算过程是异步的、可中断的
  • 你没有缓存中间计算结果(SparkMD5 的中间状态)

好一点的解决方案:

  • 增加 UI 展示逻辑,让用户在计算 hash 的时候不能进行暂停