File API实战指南:读取、处理与上传文件的前端技巧

东旭 前端 阅读 969
赞 20 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月我接了个上传大文件的需求,用户要能上传几百 MB 的视频。一开始没当回事,直接用 FileReader 读整个文件,再转成 base64 丢给后端。结果本地测试时,100MB 的文件一上传,浏览器直接卡死——页面无响应,风扇狂转,连 DevTools 都打不开。用户反馈更狠:“点上传后电脑像被施了定身法”。

File API实战指南:读取、处理与上传文件的前端技巧

这哪行啊?我赶紧回滚代码,但需求又不能砍。只能硬着头皮优化。说实话,那几天做梦都是文件分片和内存溢出的报错。

找到瓶颈了!

先用 Performance 面板录了个操作过程。一看吓一跳:主线程被一个长达 4.8 秒的 FileReader.readAsDataURL 调用占满了,期间完全没空处理 UI 事件。再切到 Memory 面板,堆内存直接从 50MB 飙到 800MB+,全是那个 base64 字符串占的——100MB 的文件转成 base64 后膨胀到 133MB,还全在内存里堆着。

问题很明显:一次性读大文件 + base64 编码 = 主线程阻塞 + 内存爆炸。得拆!

核心思路:别让主线程干重活

试了几种方案:

  • 方案一:用 Web Worker 处理 FileReader。但 FileReader 本身不能直接扔进 Worker(它依赖 DOM),得靠 postMessage 传文件,大文件传过去又卡。
  • 方案二:分片读取 + 流式上传。这个靠谱,但实现起来有点绕。

最后选了方案二,核心就两点:1)用 slice 切文件;2)用 FormData 直接传二进制,别碰 base64。base64 纯属自找麻烦,体积大还耗 CPU。

关键优化:分片 + 直接传二进制

优化前的代码是这样的(别笑,真有人这么写):

// 优化前:灾难现场
function uploadFile(file) {
  const reader = new FileReader();
  reader.onload = (e) => {
    const base64 = e.target.result;
    // 发送 base64 字符串,体积巨大
    fetch('/upload', {
      method: 'POST',
      body: JSON.stringify({ data: base64 })
    });
  };
  reader.readAsDataURL(file); // 阻塞主线程!
}

改成这样:

// 优化后:分片 + 直接传二进制
const CHUNK_SIZE = 1024 * 1024; // 1MB 分片

async function uploadFile(file) {
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
  
  for (let i = 0; i < totalChunks; i++) {
    const start = i * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = file.slice(start, end); // 切片,不复制数据
    
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('index', i);
    formData.append('total', totalChunks);
    
    // 直接传二进制,不经过 base64
    await fetch('https://jztheme.com/api/upload', {
      method: 'POST',
      body: formData
    });
    
    // 这里可以加个进度更新
    updateProgress((i + 1) / totalChunks);
  }
}

这里注意我踩过好几次坑:1)file.slice() 是轻量级的,只是创建文件句柄,不会复制数据;2)千万别用 readAsArrayBuffer 再手动拼接,那又回到内存爆炸的老路;3)用 FormDataBlob,浏览器会自动处理 multipart 编码,后端也省事

再榨干一点性能:并发控制

分片后发现新问题:如果同时发 100 个请求(比如 100MB 文件分 100 片),浏览器会卡在“排队”状态,反而更慢。于是加了并发限制:

// 控制并发数,避免请求爆炸
async function uploadWithConcurrency(file, concurrency = 3) {
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
  const chunks = Array.from({ length: totalChunks }, (_, i) => i);
  
  async function uploadChunk(index) {
    const start = index * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = file.slice(start, end);
    
    const formData = new FormData();
    formData.append('chunk', chunk);
    formData.append('index', index);
    
    return fetch('https://jztheme.com/api/upload', {
      method: 'POST',
      body: formData
    });
  }
  
  // 并发执行,最多同时 3 个
  const results = [];
  for (let i = 0; i < chunks.length; i += concurrency) {
    const batch = chunks.slice(i, i + concurrency);
    const batchPromises = batch.map(uploadChunk);
    results.push(...await Promise.all(batchPromises));
    updateProgress(i / chunks.length);
  }
  return results;
}

实测并发数设为 3-5 最稳,再高网络反而抖动。这个值得根据实际带宽调,别盲目设 10。

性能数据对比

拿 150MB 的视频文件实测(Chrome 120,MacBook Pro M1):

  • 优化前:内存峰值 1.2GB,上传耗时 12.3s(含卡死等待),CPU 占用 95%+
  • 优化后(分片+并发=3):内存峰值稳定在 150MB,上传耗时 2.1s,CPU 占用 40% 左右

最直观的是体验:以前上传时页面完全冻住,现在进度条丝滑,还能随时取消。用户反馈从“卡死了”变成“挺快啊”。

还有个小尾巴

其实分片上传后端也得配合,要能合并分片。不过这是后话了。前端这边还剩个小问题:超大文件(比如 2GB)分片太多,进度条更新太频繁会轻微卡顿。后来我改成每 10 个分片更新一次进度,基本无感了。不是完美方案,但够用。

另外,如果项目支持,直接上 ReadableStream + fetch 流式上传会更优雅,但兼容性差点(Safari 16.4 才支持)。我这项目要兼容老 Chrome,所以还是用分片稳妥。

总结

File API 性能优化的核心就一句:别让主线程碰大文件,别用 base64。分片、二进制直传、控制并发,三招搞定。折腾完这波,我对“用户上传大文件”再也不慌了。

以上是我踩坑后的实战总结,有更优的实现方式(比如用 Web Worker + Transferable 对象传文件句柄)欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合压缩、校验,后续会继续分享这类博客。

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

暂无评论