面试管问我大文件上传

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

为什么?

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

分片上传

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

  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; // 其他浏览器保守值
}

参考文章

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

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

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

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

相关推荐
坊钰15 分钟前
【MySQL 数据库】增删查改操作CRUD(下)
java·前端·数据库·学习·mysql·html
excel18 分钟前
webpack 模块 第 六 节
前端
Watermelo61720 分钟前
Vue3+Vite前端项目部署后部分图片资源无法获取、动态路径图片资源报404错误的原因及解决方案
前端·vue.js·数据挖掘·前端框架·vue·运维开发·持续部署
好_快20 分钟前
Lodash源码阅读-flattenDepth
前端·javascript·源码阅读
好_快20 分钟前
Lodash源码阅读-baseWhile
前端·javascript·源码阅读
呆头呆脑~21 分钟前
阿里滑块 231 231纯算 水果滑块 拼图 1688滑块 某宝 大麦滑块 阿里231 验证码
javascript·爬虫·python·网络爬虫·wasm
好_快21 分钟前
Lodash源码阅读-flatten
前端·javascript·源码阅读
好_快22 分钟前
Lodash源码阅读-flattenDeep
前端·javascript·源码阅读
excel24 分钟前
webpack 模块图 第 一 节
前端
Allen Bright1 小时前
【XML基础-2】深入理解XML中的语义约束:DTD详解
xml·前端