年终盘点:如何实现文件秒传?让用户体验开挂上传🚀

2,568 阅读6分钟

背景

上传一个100MB的视频文件,只需要1~3秒,是真的吗?靠谱吗?

此前,经常有用户反馈在正常网络下上传一个1GB的视频大约需要10分钟,感觉上传速度太慢。我们也只能回复:“其实这种情况和上传的网络以及文件大小有关”。

second.jpeg

在互联网高速发展的今天,文件上传已经成为网页应用中的一个基本功能。随着用户上传文件尺寸的不断增大、对质量清晰度的要求也越来越高。如何提高上传速度、优化用户体验成为了前端开发者必须面对的问题。

在此之前,最常见的优化方案就是分块上传、断点续传,在用户因异常断开上传 或 刷新页面后,能继续在上一次的基础上继续上传。这的确能较好的提升用户上传体验,也是很有必要的优化手段,但无法实现文件秒传。

什么是文件秒传?

文件秒传指的是当用户上传文件时,如果服务器已存在完全相同的文件,那么无需用户再次上传,直接使用服务器上的文件副本,实现瞬间完成上传的过程。这种技术可以显著减少不必要的数据传输,节省时间和带宽资源。(服务器会根据有无hash的情况返回3种状态,下面会有详细说明)

文件秒传的原理

文件秒传的核心原理是“文件指纹”。即文件唯一ID,通常是指文件的哈希值(如MD5、SHA-1等),它是通过哈希算法计算得出的一串固定长度的字符串,可以唯一标识文件的内容。即使文件非常庞大,其哈希值也能迅速计算出来,并且即便只是文件中的一个字节发生变化,所得到的哈希值也会完全不同。

注📢:同一个资源下载链接在不同电脑下载🉐到的将是同一个资源,因此hash也会是同一个。
当数据库视频等资源存量达到一定级别后能大幅提升上传体验,同时也能极大减少上传开销,避免重复视频上传,真正实现降本增效。

秒传的三种状态处理说明

三种状态都需要将前端计算得出的文件hash传递给服务端查询获得

状态一(notHash):文件在服务器不存在
此时正常分块上传,上传结束后返回上传结果

状态二(hasHash):文件在服务器存在
根据文件hash查询到上传结果,直接返回(实现秒传)

状态三(hashIng):新文件第一次上传完,但后端任务未完成, 而该文件在前端又被上传。此时轮询后端接口,等待后端任务完成后直接拿到上传结果(该文件第二次或后续上传均已实现秒传)

状态三中说的后端任务主要是:新文件上传完后,后端并不会直接拿前端的hash存到数据库,而是会自己在服务端根据上传完的视频生成hash(生成hash规则和前端一样)再和前端比对,以确保数据的准确性及唯一性。

如何在前端页面实现文件秒传?

关键技术点

  • 文件分块hash计算
  • 拿得到的hash到后端查询文件状态(确定文件是否在服务端存在)
  • 根据服务端返回的上传状态,处理上传。

1. 计算文件hash

计算hash的方法封装,使用md5会有相对较大的概率出现重复hash,建议至少使用sha1的方式计算。

/**
 * @description: 分块计算文件hash
 * @param {*} file 文件对象
 * @param {*} chunkSize 分块计算的文件大小,默认10MB
 * @return {*}
 */
export function calculateSliceFileHash({ file, chunkSize = 10 * 1024 * 1024 }) {
  let currentChunkIndex = 0;
  const maxChunkCount = Math.ceil(file.size / chunkSize);
  let sha1WordArray = CryptoJS.algo.SHA1.create();
  const startTime = Date.now()
  return new Promise((resolve, reject) => {
    function loadNextChunk() {
      const start = currentChunkIndex * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      const reader = new FileReader();
      reader.onload = function (e) {
        const arrayBuffer = e.target.result;
        const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer);
        sha1WordArray.update(wordArray);
        const diffTime = Date.now() - startTime
        if (currentChunkIndex < maxChunkCount - 1) {
          currentChunkIndex++;
          loadNextChunk()
        } else {
          const sha1 = sha1WordArray.finalize().toString(CryptoJS.enc.Hex); // CryptoJS.enc.Hex
          resolve({ sha1, diffTime }); // 返回计算出的hash值
        }
      };
      reader.onerror = function (error) {
        console.error('Error reading file chunk:', error);
        reject({ error: parseError(error) }); // 处理错误
      };
      const blobSlice = file.slice(start, end);
      reader.readAsArrayBuffer(blobSlice);
    }
    loadNextChunk();
  })
}

在需要计算的时候直接调用const {sha1} = calculateSliceFileHash({file})即可

2. 根据hash查询后端状态

  async uploadFile(params) {
    // 获取hash
    const {sha1} = await calculateSliceFileHash({ file })
    // 根据hash查询文件状态
    const {data} = await this.axios.post(`/api/xxx`, params)
    const { key, bucket, region, state, url } = data.data
    // state='hasHash' | 'hashIng' | 'notHash'
 ...
 }

3. 根据服务端的状态,返回上传结果

async uploadFile(params) {
...
    // 文件存在,直接返回结果
    if (state === 'hasHash') {
      return Promise.resolve({url})
    }
    // 文件已上传,后端处理中
    if (state === 'hashIng') {
      // 设置轮询开始时间
      if (!params.pollStartTs) {
        params.pollStartTs = Date.now()
      } else if (Date.now() - params.pollStartTs >= 60000) {
        // 轮询超过1分钟认定为超时
        return Promise.reject({ error: '文件加载超时,请稍后再试' })
      }
      await sleep(1000) // 每隔1s轮询
      await this.uploadFile(params)
    }
  	// state==='notHash'时,执行下面的上传
    return new Promise(async (resolve, reject) => {
      const cos = new Cos({
        getAuthorization: this.cosAuthorization({
          resolve,
          reject,
          bucket,
          key
        }).bind(this)
      })
      cos.sliceUploadFile(
        {
          Bucket: bucket,
          Region: region,
          Key: key,
          Body: file,
          SliceSize: 1024 * 1024 * 10, // 超10M使用分块(cos单个块最大不超过5GB)
          onProgress,
          onTaskReady
        },
        (error, data) => {
          if (error) {
            return reject({ error })
          }
          resolve(data)
        }
      )
    })
  }

批量上传应用秒传

批量上传也同样适应,遍历调用uploadFile({file: singleFile}),逐个处理。也可使用Promise.all(...),等待所有文件处理完后再返回结果集合。 注:建议使用遍历逐个上传,能有更好的用户体验。

存在的主要问题

上传大文件并在浏览器中进行SHA1或MD5哈希计算时可能会导致浏览器崩溃,原因通常是在处理大文件时所需的计算和内存资源超过了浏览器的能力

image.png 如上所示,通常的处理办法包括: 通过setTimeoutrequestAnimationFrame分时段计算、切割合适的块、使用Web Workers、使用Stream Processing优化、优化算法,内存管理、在服务端计算等。
但最佳的处理办法,是在浏览器中使用webworker多线程计算hash,同时需要兼顾其兼容性、线程数量(需根据实际应用调整),目前项目已做优化、整体体验尚佳。webworker在后面的文章中会有详细的介绍。

总结

实现文件秒传能够显著提升用户的上传体验,特别是在处理大文件上传时(上传1GB大概20秒左右)
通过文件哈希比对、分块上传和断点续传等技术,可以让用户感受到上传速度的极大提升。
当然,文件秒传的具体实现还是有一定的复杂性,需要前后端紧密协作,确保整个上传过程的稳定性和安全性。随着技术的不断进步,相信未来的文件上传体验将会更加流畅,让用户真正体验到“秒传”的魔力。

篇幅已经比较长了,后续再详细介绍如何使用webworker多线程来优化文件秒传。如果你有其它更好的优化方案,欢迎评论区讨论。