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

3,991 阅读8分钟

背景

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

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

second.jpeg

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

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

什么是文件秒传?

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

文件秒传的原理

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

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

秒传的三种状态处理说明

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

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

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

状态三(hashIng:相同文件正在被别的客户端上传中
此时轮询后端接口,等待上传完成后直接拿到上传结果(该文件后续上传均已实现秒传)

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

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

关键技术点

  • 文件分块 hash 计算(使用 SHA-1,避免 MD5 碰撞率过高)
  • 将 hash 传给后端,查询文件状态
  • 根据服务端返回的三种状态处理上传

1. 使用 Web Worker 计算文件 hash

为什么要用 Web Worker?

在主线程计算大文件 hash 时,SHA-1 运算会长时间占用 CPU,导致页面 UI 完全卡死、无法响应用户操作。Web Worker 运行在独立线程,hash 计算过程完全不阻塞主线程。

// worker 脚本(分块读取 + SHA-1 累加)
importScripts('https://cdn.example.com/crypto-js.min.js');

self.addEventListener('message', function (e) {
  const { file, chunkSize, uid } = e.data;
  let currentChunkIndex = 0;
  const maxChunkCount = Math.ceil(file.size / chunkSize);
  let sha1WordArray = CryptoJS.algo.SHA1.create();

  function loadNextChunk() {
    const start = currentChunkIndex * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const reader = new FileReader();
    reader.onload = function (e) {
      const wordArray = CryptoJS.lib.WordArray.create(e.target.result);
      sha1WordArray.update(wordArray);
      if (currentChunkIndex < maxChunkCount - 1) {
        currentChunkIndex++;
        loadNextChunk();
      } else {
        const fsha1 = sha1WordArray.finalize().toString(CryptoJS.enc.Hex);
        self.postMessage({ fsha1, file, uid });
      }
    };
    reader.onerror = () => self.postMessage({ error: 'read error', uid, file });
    reader.readAsArrayBuffer(file.slice(start, end));
  }
  loadNextChunk();
});

SDK 场景的额外挑战:作为 npm 包使用时,无法要求消费方在固定路径部署 worker 文件。

通常的做法 new Worker('/path/to/worker.js') 受同源策略限制,跨域 URL 会直接被浏览器拦截;而 new Worker(URL.createObjectURL(blob)) 创建的 Blob URL 继承当前页面的 origin,完全绕开同源限制,也不需要消费方部署任何文件。

⚠️ 唯一需要注意的是 CSP(Content-Security-Policy):若页面设置了 worker-src 'self' 且未包含 blob:,Blob URL Worker 会被拦截。消费方需确保 CSP 允许 worker-src blob:

解决方案是使用 Blob URL 内联 Worker,将 worker 脚本作为字符串内嵌,运行时动态创建:

// 不依赖任何外部路径,SDK 内部自包含
function workerCalculateSliceFileHash({ file, chunkSize = 20 * 1024 * 1024 }) {
  return new Promise((resolve) => {
    const blob = new Blob([WORKER_SCRIPT], { type: 'application/javascript' })
    const blobUrl = URL.createObjectURL(blob)
    const worker = new Worker(blobUrl)          // ← Blob URL,无需部署文件
    const uid = `${file.name}_${file.size}_${Date.now()}`

    const done = (fsha1) => {
      worker.terminate()
      URL.revokeObjectURL(blobUrl)              // ← 及时释放内存
      resolve({ fsha1 })
    }

    worker.addEventListener('message', (e) => {
      if (e.data.uid !== uid) return           // ← uid 隔离并发任务
      done(e.data.error ? '' : e.data.fsha1)
    })
    worker.addEventListener('error', () => done(''))  // ← 失败静默降级
    worker.postMessage({ file, chunkSize, uid })
  })
}

跨域说明:Blob URL Worker 继承当前页面的 origin,importScripts 跨域加载 CryptoJS CDN 脚本只需 CDN 配置 CORS,与 Worker 文件路径无关。

2. 移动端兼容处理

实践中发现,平板(iPad 等)在上传 1~2GB 视频时,即使使用 Worker,hash 计算也会导致上传过程卡死——移动端硬件性能有限,长时间的内存操作会触发系统资源回收。

解决方案:PC 端强制走秒传,移动端允许通过参数跳过

// isPC:UA 不含 iPhone/iPad/Android/ios 即视为 PC
function isPC() {
  return !/(iPhone|iPad|Android|ios)/i.test(window.navigator?.userAgent)
}

// 上传时的判断逻辑
if (file instanceof File && (isPC() || !skipCalcHash)) {
  const { fsha1 } = await workerCalculateSliceFileHash({ file })
}

调用方可以灵活控制:

// 讲师后台:移动端自动跳过秒传,PC 正常走
uploader.upload({ file, options, skipCalcHash: !isPC() })

// 确定是平板上传大视频:强制跳过
uploader.upload({ file, options, skipCalcHash: true })

3. 根据服务端状态处理上传

async uploadFile(params) {
  // 计算文件 hash(失败时 fsha1 为空字符串,不影响正常上传)
  const { fsha1 } = await workerCalculateSliceFileHash({ file })

  // 携带 fsha1 请求预签名接口,服务端判断文件状态
  const { data } = await http.post('/api/xxx', { ...params, ...(fsha1 && { fsha1 }) })
  const { key, bucket, region, state, url } = data

  // ① 秒传命中:文件已存在,直接返回
  if (state === 'hasHash') {
    onProgress({ percent: 1 })
    return Promise.resolve({ url })
  }

  // ② 相同文件上传中:轮询等待完成
  if (state === 'hashIng') {
    return pollUntilFinish(params)
  }

  // ③ 首次上传(state === 'notHash'):正常走 COS 分块上传
  return new Promise((resolve, reject) => {
    cos.sliceUploadFile(
      { Bucket: bucket, Region: region, Key: key, Body: file,
        SliceSize: 1024 * 1024 * 10, onProgress },
      (error, data) => error ? reject({ error }) : resolve({ url, data })
    )
  })
}

4. 轮询优化:指数退避

状态三(uploading)的轮询等待的是另一个客户端的上传完成,可能持续几十秒甚至几分钟。固定 1s 轮询在长时间等待时会产生大量无效请求。

指数退避策略:间隔从 1s 开始每次翻倍,上限封顶 8s:

轮询次数等待时间累计等待
第 1 次1s1s
第 2 次2s3s
第 3 次4s7s
第 4 次起8s(封顶)
超时3 分钟
async function pollUntilFinish(params, pollStartTime = Date.now(), interval = 1000) {
  if (Date.now() - pollStartTime >= 180_000) {
    return Promise.reject({ error: '文件上传轮询超时,请稍后再试' })
  }
  await sleep(interval)
  const { data } = await http.post('/api/xxx', params)
  if (data.state === 'hashIng') {
    // 每次翻倍,最大 8s
    return pollUntilFinish(params, pollStartTime, Math.min(interval * 2, 8000))
  }
  return data
}

3 分钟内请求次数从固定 1s 的 180 次降到约 22 次,对服务端更友好,同时小文件前几次仍是 12s 响应,体验不受影响。

批量上传应用秒传

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

注:建议使用遍历逐个上传,能有更好的用户体验。

存在的主要问题

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

image.png

如上所示,通常的处理办法包括: 通过 setTimeoutrequestAnimationFrame 分时段计算、切割合适的块、使用 Web Workers、使用 Stream Processing 优化、优化算法,内存管理、在服务端计算等。

最佳方案是使用 Web Worker 多线程计算 hash。同时需要注意:

  • 移动端兼容:平板等移动端硬件性能有限,上传大视频时应允许跳过 hash 计算
  • Worker 兜底:Worker 创建失败或 CryptoJS 加载失败时,静默返回空 hash、继续正常上传,不能因秒传失败而中断整个上传流程
  • 并发隔离:多文件同时上传时,需用唯一 uid 标记每个 Worker 的消息,防止串扰

总结

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

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