面试管问我大文件上传

之前发现自己对大文件上传吃的并不是很透彻,所以写一篇文章总结一下。

为什么?

直接将大文件上传,耗时太久,传输超时,无法知道进度,无法暂停。

分片上传

思路:将大文件分成一个个小文件,也叫做切片,将切片进行上传,等到后端接收到所有的切片之后,在进行复原。

  1. 读取本地文件,得到文件对象,使用slice进行切割,
arduino 复制代码
  function createChunk(file,size=5*1024*1024){
            // 存储切片
            const chunkList=[];
            let cur=0;
            while(cur < file.size){
                chunkList.push({
                    file:file.slice(cur,cur+size), // 起始字节, 终止字节
                    chunkIndex:cur,
                    chunkSize:size,
                    fileName:file.name
                })
                cur+=size
            }
            return chunkList
        }
  1. 给每个切片设置一个唯一标识 hash,因为是并发请求,传输到服务端之后顺序可能发生变化。后端可以根据这个hash值判断这个切片的位置。
javascript 复制代码
 function handleUpload() {
+      if (!chunkList) return;
+      const fileChunkList = createChunk(chunkList);
+      const data = fileChunkList.map(({ file },index) => ({
+        chunk: file,
+        // 文件名 + 数组下标
+        hash: this.container.file.name + "-" + index
+      }));
+      return data
+    }
  1. 将表单数据并发发送给后端。
javascript 复制代码
function uploadChunks(chunks){
            //这个数组中的元素是对象,每个对象中有blob类型的文件对象
          const formChunks=chunks.map(({file,chunkName,fileName,index})=>{
                //对象需要转成二进制数据流传输
                const formData=new FormData()
                formData.append('file',file)
                formData.append('chunkName',chunkName)
                formData.append('fileName',fileName)
                return {formData,index}
            });
           // 调接口,发送请求。
          const results = await Promise.all(
                formChunks.map(item => {
                  //返回一个请求的promise对象
                  ...
                })
          );
}
  1. 后端什么时候合并切片,有两种方式,但是建议组合使用:
  • 前端给每个切片中携带切片最大数量的信息,当服务端接收到这格数量的切片自动合并。
  • 额外发送一个请求,通知后端进行切片合并。

至此大文件就上传完了。但是这其中还有很多细节需要注意。

大文件上传进度

首先需要明确上传进度有两种,一种是单个文件上传的进度,还有整个文件上传的进度。 整个文件的上传的进度是基于每个切片上传的进度计算而来。

先看单个切片如何看进度

  1. 如果使用XHR进行上传,直接使用onprogress 事件就可以
ini 复制代码
 xhr.upload.onprogress = function(e) {
    if (e.lengthComputable) {
      const percent = Math.round((e.loaded / e.total) * 100);
      //...
    }
  };
  1. 如果使用axios,使用onUploadProgress事件。也可以使用第三方库axios-progress-bar - 为axios添加进度条
ini 复制代码
axios.post('/upload', formData, {
  onUploadProgress: progressEvent => {
    if (progressEvent.lengthComputable) {
      // 更新当前切片的进度
      const percent = Math.round(
        (progressEvent.loaded / progressEvent.total) * 100
      );
    }
  }
})

总进度条就好办了,每个切片上传完后累加,再除以整个文件的大小。

断点续传

先断点再续传。

断点

断点可能是网络中断,也可能是有暂停上传的功能:

将之前存储每一个切片的请求对象的数组,如果使用xhr,调用abort方法

ini 复制代码
//点击暂停:
requestList.forEach(xhr => xhr?.abort());
requestList = [];

如果使用axios,可以使用 AbortController (推荐),也可以使用CancelToken(已弃用)

javascript 复制代码
let controllers = []; // 存储所有分片的controller

// 给每一个切片的请求中,创建取消控制器
const controller = new AbortController();
controllers.push(controller);


// 取消所有分片上传
function cancelAllChunks() {
  requestList.forEach((controller, index) => {
    controller.abort(`分片 ${index} 被取消`);
  });
  requestList = []
}

续传

  1. 服务端存储

服务端保存已上传的文件hash以及对应的切片,前端每次上传服务器之前,向服务端获取已经上传完的切片,只上传未完成的切片。

javascript 复制代码
// 1. 计算文件hash
const fileHash = await calculateFileHash(file);
  
  // 2. 查询服务器已上传的切片
  const { uploadedChunks } = await axios.get(`/check-upload?hash=${fileHash}`);


 // 3. 文件分片
  const chunkSize = 5 * 1024 * 1024; // 5MB一个切片
  const chunks = Math.ceil(file.size / chunkSize);

// 4.过滤掉已经上传的切片,再调接口。
  1. 本地存储
javascript 复制代码
// 上传前检查本地存储
function getLocalUploadProgress(hash) {
  const progress = localStorage.getItem(`upload-${hash}`);
  return progress ? JSON.parse(progress) : null;
}

// 更新本地存储的上传进度
function updateLocalProgress(hash, uploadedChunks) {
  localStorage.setItem(
    `upload-${hash}`,
    JSON.stringify({ time: Date.now(), chunks: uploadedChunks })
  );
}

// 上面存储了时间戳,这个主要是考虑到优化问题, 比如可以定期清理过期的数据,也可能遇到冲突的时候
// 选择时间较近的一个,也可以优化用户体验,显示上次上传的时间。

注意: 这种方式因为记录在了本地,如果用户换了浏览器,或者将存储删除,就不起作用了。

失败重试

1.单个切片上传失败后重试

为每一个请求,添加一个重试逻辑

javascript 复制代码
  // 最大重试次数
  const MAX_RETRY = 3;
  
  // 封装带重试的上传函数
  const uploadWithRetry = async ({formData, index, chunkName, retryCount}) => {
    try {
      // 请求逻辑 ...
    } catch (error) {
      if (retryCount < MAX_RETRY) {
        console.warn(`切片 ${chunkName} 上传失败,第 ${retryCount + 1} 次重试...`);
        return uploadWithRetry({
          formData, 
          index, 
          chunkName, 
          retryCount: retryCount + 1
        });
      } else {
        console.error(`切片 ${chunkName} 上传失败,已达最大重试次数`);
        return {success: false, index, error};
      }
    }
  };

// 执行所有上传(并行)
  const results = await Promise.all(
    formChunks.map(item => uploadWithRetry(item))
  );

2.整体失败后重新上传失败的切片

ini 复制代码
 // 检查失败的切片
  let failedIndices = results
    .filter(result => !result.value.success)
    .map(result => result.value.index);

//failedIndices.length > 0 重新调用请求接口。

优化

计算hash

  1. 计算hash的方式是通过文件名+下标作为切片的hash,这样的问题是如果文件被重命名,hash就没有用了,正确的做法应该是文件内容不变,hash就不变。这里使用 spark-md5 的库可以实现
  2. 上面计算hash是同步的方式,如果一个超大的文件,进行读取文件切片并计算hash耗费很长时间,会将页面卡死,可以使用web worker,另外开启一个线程计算hash,不阻塞主线程加护。 也可以使用requestIdleCallback 来利用浏览器每帧的空闲时间进行任务。

并发

上文中使用Promise.all()直接全部发送了请求,如果文件特别多的情况下,TCP建立链接可能会将浏览器卡死。所以需要并发控制限制同时上传的切片数量,我们可以实现一个队列:

kotlin 复制代码
class UploadQueue {
  constructor(maxConcurrent = 3) {
    this.maxConcurrent = maxConcurrent; // 并发数量 通常3-6个并发是比较安全的选择
    this.pending = []; // 等待上传的任务队列
    this.inProgress = 0; //正在上传的任务数
  }
  
  add(chunk) {
    return new Promise((resolve, reject) => {
      this.pending.push({ chunk, resolve, reject });
      this._next();
    });
  }
  
  _next() {
    // 如果已达最大并发数或没有等待任务,则返回
    if (this.inProgress >= this.maxConcurrent || !this.pending.length) return;
    
    this.inProgress++;// 增加进行中任务计数

    // 从队列中取出第一个任务
    const { chunk, resolve, reject } = this.pending.shift();
    
    uploadChunk(chunk)
      .then(resolve)
      .catch(reject)
      .finally(() => {
        this.inProgress--; // 无论成功失败,都减少计数
        this._next();      // 尝试启动下一个任务
      });
  }
}

// 使用方式
async function uploadAllChunks(chunks) {
  const queue = new UploadQueue(3); // 最大并发3
  return Promise.all(chunks.map(chunk => queue.add(chunk)));
}

并发数量也可以根据浏览器自适应:

javascript 复制代码
function getBrowserConcurrencyLimit() {
  // 根据不同浏览器特性设置不同的默认并发数
  const isChrome = /Chrome/.test(navigator.userAgent);
  const isFirefox = /Firefox/.test(navigator.userAgent);
  
  if (isChrome) return 6;  // Chrome通常允许6个并发
  if (isFirefox) return 8; // Firefox通常允许8个并发
  return 4; // 其他浏览器保守值
}

参考文章

字节跳动面试官:请你实现一个大文件上传和断点续传

字节跳动面试官,我也实现了大文件上传和断点续传

大文件分片上传实战:前端实现

大厂面试官:如何实现大文件上传

相关推荐
恋猫de小郭2 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅10 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment10 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端