Upload组件上传文件的那些坑与最佳实践

宇文娜娜 组件 阅读 2,491
赞 20 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线了个文件上传功能,用户反馈说一上传大文件页面就卡死,点了没反应,刷新都不敢刷。我自己试了下,传个 300MB 的视频,内存直接飙到 1.5GB,Chrome 弹出“页面无响应”警告,我人都傻了。

Upload组件上传文件的那些坑与最佳实践

这哪是上传组件,这是炸弹组件啊。不是 UI 不好看,是根本用不了。

最离谱的是,还没开始上传,只是调用了 file.slice() 做分片,页面就开始卡。我当时就意识到——问题不在网络,在本地处理逻辑上。

找到病颈了!

先开 DevTools 看 Performance 面板,录了一段操作:

  • 选择一个 300MB 的 MP4 文件
  • 触发 onChange 回调
  • 执行分片逻辑

结果一看 Call Tree,98% 的时间都在 FileReader.readAsArrayBuffer 上面,而且主线程被完全阻塞。更惨的是,我这个组件还同步计算了每个分片的 hash(MD5),用来做秒传校验……这一下双杀:大文件 + 同步读取 + 同步加密计算 = 卡成幻灯片。

我还用了 Chrome 的 Memory 面板看了下堆快照,发现有多个 ArrayBuffers 被同时持有,GC 根本来不及回收。典型的内存泄漏征兆。

结论很明确:所有文件处理不能放在主线程,必须拆出去。

方案一:Web Worker 搞起

第一反应就是 Web Worker。把分片和 hash 计算扔进 Worker,主线程只负责交互。

但 FileReader 在 Worker 里不能用。查了文档,Blob.slice 是可以的,但读内容还得靠主线程传 ArrayBuffer 进去。于是改成了这样:

  1. 主线程读取小块 chunk(比如 5MB)
  2. 转成 ArrayBuffer 发给 Worker
  3. Worker 计算这块的 hash 并返回
  4. 主线程拼接最终 hash 或直接上传

代码大概长这样:

// main.js
const chunkSize = 5 * 1024 * 1024;
function processFileInWorker(file) {
  const worker = new Worker('/upload-worker.js');
  let chunks = [];
  let index = 0;

  function readNextChunk() {
    if (index * chunkSize >= file.size) {
      worker.postMessage({ type: 'complete', chunks });
      return;
    }

    const start = index * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const blob = file.slice(start, end);

    const reader = new FileReader();
    reader.onload = (e) => {
      worker.postMessage({
        type: 'chunk',
        index,
        data: e.target.result
      }, [e.target.result]); // Transferable transfer

      index++;
      readNextChunk(); // 异步递归读取
    };
    reader.readAsArrayBuffer(blob);
  }

  worker.onmessage = (e) => {
    if (e.data.type === 'hash') {
      console.log('Final hash:', e.data.hash);
    }
  };

  readNextChunk();
}
// upload-worker.js
let chunks = [];
let currentHash = spark();

self.onmessage = function(e) {
  if (e.data.type === 'chunk') {
    const arrayBuffer = e.data.data;
    const uint8Array = new Uint8Array(arrayBuffer);
    currentHash.update(uint8Array);
    postMessage({ type: 'chunkProcessed', index: e.data.index });
  } else if (e.data.type === 'complete') {
    const finalHash = currentHash.digest();
    postMessage({ type: 'hash', hash: finalHash });
  }
};

这里注意我踩过好几次坑:

  • 忘了在 postMessage 后面加 [buffer] 做 transfer,导致内存复制而不是移交,内存还是涨
  • 递归调用 readNextChunk() 没控制并发,一次开了几十个 FileReader,浏览器直接崩
  • Spark-md5 不支持增量更新,后来换成了 spark-md5 的流式 API

方案二:Stream + ReadableStream 实验性搞法

后来想到,现在不是有 Response.body.pipeTo()ReadableStream 了吗?能不能边读边算?

试了下,确实可以,但兼容性差,Safari 支持不好,最后只作为降级失败后的 fallback 没上线。不过思路可以留着:

async function hashWithStream(file) {
  const stream = file.stream();
  const reader = stream.getReader();
  const hash = spark();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    hash.update(new Uint8Array(value));
  }

  return hash.digest();
}

这写法看着清爽,但在老版本 Chrome 里会卡,因为 getReader().read() 虽然是异步,但大文件下微任务队列积压严重。实测比 Worker 还卡。pass。

核心优化:节流 + 分帧处理

即使用了 Worker,上传进度条更新太频繁也会卡。比如每 10ms 更新一次 DOM,300次更新/sec,React 都扛不住。

我的解法是:用 requestAnimationFrame 包一层,控制 UI 更新频率。

let lastUpdateTime = 0;
function updateProgress(loaded, total) {
  const now = performance.now();
  if (now - lastUpdateTime < 16) return; // 最多 60fps
  lastUpdateTime = now;

  // 更新进度条
  document.getElementById('progress').style.width = ${(loaded / total) * 100}%;
}

另外上传请求做了并发控制,最多同时传 4 个分片,避免 TCP 打满或浏览器限制。

const MAX_CONCURRENT = 4;
let activeUploads = 0;
let pendingUploads = [];

function uploadChunk(chunk) {
  if (activeUploads >= MAX_CONCURRENT) {
    pendingUploads.push(() => doUpload(chunk));
    return;
  }

  doUpload(chunk);
}

function doUpload(chunk) {
  activeUploads++;
  fetch('https://jztheme.com/api/upload', {
    method: 'POST',
    body: chunk.data
  }).finally(() => {
    activeUploads--;
    if (pendingUploads.length > 0) {
      const next = pendingUploads.shift();
      next();
    }
  });
}

还有个小细节:预览图生成也得懒

我们有个需求是图片上传后要显示缩略图。之前是直接 URL.createObjectURL(file) 就完事,但对于大图(比如 20MB 的 PNG),createObjectURL 后立马塞进 img.src,解码就会卡主线程。

解决方案是:用空 src 先占位,等 idle 时间再加载:

if (file.type.startsWith('image/')) {
  const img = document.createElement('img');
  img.src = ''; // 先不赋值
  container.appendChild(img);

  // 利用 requestIdleCallback 懒加载
  requestIdleCallback(() => {
    img.src = URL.createObjectURL(file);
  });
}

虽然小功能,但用户体验提升明显,尤其在低端机上。

优化后:流畅多了

改完之后再测 300MB 视频上传:

  • 主线程 CPU 占用从 90%+ 降到 20% 以下
  • 内存峰值从 1.5GB 降到 300MB 左右
  • 页面可交互时间从“一直不可交互”变成能随时点击取消
  • 分片 hash 计算时间从 12s 缩短到 3.5s(并行切割)

最关键的是,用户终于不会以为系统崩溃了。

性能数据对比

以下是三轮测试的平均数据(300MB MP4,MacBook Pro M1,Chrome 128):

指标 优化前 优化后
主线程阻塞时间 ~12s ~800ms(分散在多个帧)
内存峰值 1.5GB 310MB
hash 计算耗时 12s 3.4s
页面可响应性 完全卡死 可滚动、可点击取消

虽然改完后还有点小瑕疵:Worker 通信偶尔延迟,hash 返回顺序乱,但这不影响主流程。整体已经可用。

以上是我的优化经验,有更优的实现方式欢迎评论区交流

这个组件折腾了我整整三天,中间一度想放弃用 Dropzone 换现成库。但最后自己动手调一遍,对文件 API 和性能瓶颈的理解深了很多。

目前方案不是最优,比如还没有上 Wasm 做 MD5 加速,但够用、稳定、不卡,就够了。

如果你也在搞大文件上传,建议早点上 Worker,别等用户投诉。真的一卡起来,谁都救不了你。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论