全网最全面的"大文件上传" - 前端:Vue3+TS+Vite, 后端:node+express

32,358 阅读10分钟

背景

一般情况下,上传文件就是 new 一个 FormData,把文件 append 进去,然后发送 post 请求给后端就可以了. 但是,偶尔可能出现上传几G甚至上传上百个G文件的场景.文件越大,上传的时间越长就会导致几个问题
上传过程中网络异常,浏览器异常,或者一些突发的事件造成中途中断,下次需要重新开始 上传超时或者等待时间长,影响用户体验
所以,就需要为大文件场景设计一套通用的分片上传机制,确保大文件上传稳定,用户体验好

UI

我就是我,是颜色不一样的烟火 Snipaste_2023-02-18_09-05-13.png

前后端交互流程图(可右键看清细节)

976b1132e8e545b48b32ef41de9ec079tplv-k3u1fbpfcp-watermark.png

前端功能

  1. 可以上传单个文件和多个几百M或者1G多的文件给服务器
  2. 点击暂停再点继续是接着之前那个文件的上传
  3. 页面关闭再上传一样的文件继续上传
  4. 正在上传中文件,再上传同一个,直接提示继续上传中的文件
  5. 上传到一半暂停的文件,上传同一个文件,立即自动继续上传
  6. 全部取消,之前没上传完成的,再上传只能从头上传,可是上传成功的还是秒传
  7. 清空本地和服务器存储的文件,上传所有文件都要重新来过
  8. 上传过程中要显示进度条,一些特殊情况要给文字提示框

开发过程中遇到的问题和解决办法

1.onUploadProgress,在请求超时或者服务器异常的时候它一样会触发

上传文件的过程中要实时的显示文件的上传进度,这样就很直观的知道文件还差多少上传完成. 这时候我就看很多文章示范都喜欢用onUploadProgress 这个方法,其实这个方法返回的进度只是文件从前端上传到服务器这一过程的进度而已,上传到服务器了又不是就绝对上传成功了 123.png 后端也会报错造成上传失败. 文件完全上传到了服务器 !== 上传成功. 34.png 所以我最后是直接改变逻辑不使用这个api,使用已完成的分片个数显示总进度

  • 这个功能本来就是分片的,如果分了100片相当于一片占了100里的一份,这就是进度.响应返回上传分片成功,更新一次进度, 最新进度 = 旧进度 + 总片数/100
    if(result === 1){
      sliceProgress(needObj,taskArrItem,progressTotal)  // 更新进度条
      taskArrItem.errNumber > 0 ? taskArrItem.errNumber-- : ''
      taskArrItem.finishNumber++
      needObj.finish = true
      taskArrItem.whileRequests = taskArrItem.whileRequests.filter(item => item.index !== needObj.index)  // 上传成功了就删掉请求中数组中的那一片
      if(taskArrItem.finishNumber === sliceNumber){
        const resB = await mergeSlice(data).catch(()=>{})
        resB && resB.result === 1 ? isFinishTask(taskArrItem) : pauseUpdate(taskArrItem,false)
        taskArrItem.finishNumber = 0
      }else{
        slicesUpdate(taskArrItem)
      }
    }else if(result === -2){
      pauseUpdate(taskArrItem,false)
      message('服务器剩余容量不足! 请清空本地和服务器存储的文件')
    }
    
    // 更新进度条
    const sliceProgress = (needObj:AllDataItem,taskArrItem:taskArrItem,progressTotal:number) =>{
      const placeholder = progressTotal/needObj.sliceNumber  // 每一片占100的多少
      taskArrItem.percentage = taskArrItem.percentage + placeholder
    }

2.文件越大,使用md5计算整个文件的哈希值越慢

用户选择文件后,我们需要给每一个文件一个唯一标识,所以我们需要对文件进行哈希计算,得到的哈希值就是文件的唯一标识.如果你直接计算整个文件,小的文件很快,大的文件非常慢,毕竟计算量大了. 为了解决这个问题,下边是我逐步优化的过程,每一个阶段速度都有提升
使用 web worker 创建多一个子线程计算 => 子线程里,使用 File.slice 切片再追加分片计算哈希 => 子线程里,只追加计算第一片和最后一片

  • 以这个1.68G的文件为例子,这是最开始完全没优化时前的计算总时长20067ms 85a382ebddaa4a098f314579bbf2d7cetplv-k3u1fbpfcp-watermark.png
  • 使用web worker创建多一个子线程计算,计算完返回给主线程 - 总时长20577ms,可能是文件不够大,没有明显看出计算效率提高了 cead06d9a9044ece8a620021c7c2ea39tplv-k3u1fbpfcp-watermark.png
  • 子线程里,使用 File.slice 切片再追加分片计算哈希 - 总时长434ms,时长明显减少,大概是之前时长的1/46,这样1G多的是没什么大问题,可是你换一个5G的文件,瞬间又感觉慢了 2fab7255200d4087ba553cd27102f738tplv-k3u1fbpfcp-watermark.png
  • 子线程里,只追加计算第一片和最后一片 - 这样速度就非常的快,最后是选择使用这种方案 d422416d08d143258302a49b21e4167ftplv-k3u1fbpfcp-watermark.png 可能有人会有疑问,你就追加计算第一片和最后一片,那我一个大文件夹里的中间一个文档内容修改了一个字,岂不是得到了一样的哈希值,为此我还刻意做了一个测试.准备一个文件夹,复制一份,然后两的文件夹的文件正中间放一个文档,第一个写上999,第二个写上909,然后计算它们各自的哈希值.可以看到这两个哈希值是完全不同的.类似一张身份证,你中间的一个小点改了,你整个身份证就是另外一张新身份证了,然后你截取新身份证的前后两截,也是新身份证的前后两截 1235.png Snipaste_2023-02-15_07-35-19.png
  • 而且,要使用 Promise 封装 web Worker 计算结果返回,再使用 await 调用.否则会造成下个问题说的高并发请求
// hash.js - 执行复杂计算
self.importScripts("spark-md5.min.js")

self.onmessage = async (e) =>{
  const file = e.data.file
  //切片
  const chunkSize = 1024*1024*3  // 每个切片的大小定位3m
  const chunkNum = Math.ceil(file.size / chunkSize)  // 切片数量
  const sparkMD5 = new SparkMD5.ArrayBuffer()
  //利用文件首尾分片的md5合并作为整个文件md5
  const firstFile = file.slice(0 * chunkSize, (0 + 1) * chunkSize)
  try{
    if (chunkNum === 1) {
      await loadNext(firstFile)
    }else{
      const endFile = file.slice((chunkNum-1) * chunkSize, ((chunkNum-1) + 1) * chunkSize)
      await loadNext(firstFile)
      await loadNext(endFile)
    }
    const md = sparkMD5.end()
    self.postMessage({name:"succeed",data:md})
    self.close()
  }catch(err){
    self.postMessage({name:"error",data:err})
    self.close()
  }
  function loadNext(park){
    return new Promise((resolve,reject)=>{
      const reader = new FileReader()
      reader.readAsArrayBuffer(park)
      reader.onload = (e) => {
        sparkMD5.append(e.target.result)
        resolve()
      }
      reader.onerror = (err) => {
        reject(err)
      }
    })
  }
}
// App.vue - 调用
const fileMd5 = await useWorker(file) 

  // Promise封装web Worker计算结果返回
  const useWorker = (file:File) =>{
    return new Promise((resolve,reject)=>{
      const worker = new Worker('./js/hash.js')  //复杂的计算,使用web Worker提高性能
      worker.postMessage({file})
      worker.onmessage = (e) =>{
        const {name,data} = e.data
        name === 'succeed' ? resolve(data) : reject(data)
      }
    })
  }

3.浏览器同域名同一时间请求的最大并发数限制为6

文件分片处理好之后,就要先将所有分片发送请求给后端. 一开始是打算使用for循环或者Promise.all()并发请求几百个接口,感觉这样效率高一些. 后来发现文件小没事,大了请求直接不响应了,例如15个切片15个请求浏览器会感觉有点卡顿, 几百上千个切片请求瞬间浏览器卡死或者接口超时自动取消 screenshots.gif 因为浏览器同一域名同一时间是有最大请求并发数限制的,不是你想并发多少个接口就多少个接口的. 解决这个问题的核心就是永远不要让并发请求数超过6

  • for循环遍历的 Promise 携带着 await 会逐个执行,await 在方法里边或者直接 .then 就会多个并发执行.
    所以要合理的控制同步和异步.不然你上传多个文件,第一个没上传完让后边一直等着,这样不合理,几个文件同时计划哈希值和调查询文件接口,也是不合理
    我在输入框change事件里就计算哈希值和调查询文件接口两个Promise会同步执行,在之后执行的方法是异步执行.就是说上传多个文件,只要上一个文件执行完计算哈希值或者查询文件接口返回就可以轮到下一个文件执行了. 这样错开每个文件,就不会出现多个文件并发计算哈希值和调查询接口
    举下边两个例子强调一下
// for遍历和foreach遍历这样写都是会造成Promise并发执行的
for (let i = 0; i < arr.length; i++) {
    arr[i]().then(()=>{})
}
arr.forEach(async(item) => {
    await item()
})

// 这样写才会同步执行,上一个Promise执行完成之后才到下一个Promise
async function runP(){
    for (let i = 0; i < arr.length; i++) {
        await arr[i]()
    }
}
runP()
const p5 = function(){
    return new Promise((resolve,reject)=>{
        console.log('p5开始')
        setTimeout(() => {
            console.log('p5完成')
            resolve('p5')
        }, 1000)
    })
}

// 注意:这样写也是并发执行多个,6个Promise并发执行,即使insideP函数里边是包含着await
for (let j = 0; j < 6; j++) {
    insideP()
}
async function insideP(){
    await p5()
}

// 如果改成for遍历明await就是同步执行了
async function runP(){
  for (let j = 0; j < 6; j++) {
    await p5()
  }
}
runP()
  • 全局声明一个最大请求并发数,每次并发多个请求前都以 (6/文件个数) 动态设置它,每次的并发请求个数为全局的最大请求并发数
    每个文件上一次的并发多个上传切片请求全部都完成了,再并发多个上传切片请求
    slicesUpdate 方法执行完调 slicesUpdate 方法,发送多个请求
    slicesUpdate 方法执行完调 slicesUpdate 里边的 isUpdate 方法,发送多个请求
// 切片上传
const slicesUpdate = (taskArrItem:taskArrItem,progressTotal = 100) =>{
  // 一片都没有了,或者有正在请求中的接口,都直接不执行下边的逻辑,毕竟都有正在请求中的还上传,容易造成并发数高于浏览器限制
  if(taskArrItem.allData.length === 0 || taskArrItem.whileRequests.length > 0){return}
  const isTaskArrIng = toRaw(taskArr.value).filter(itemB => itemB.state === 1 || itemB.state === 2)
  maxNumb = Math.ceil(6/isTaskArrIng.length)  // 实时动态获取并发请求数,每次掉请求前都获取一次最大并发数
  const whileRequest = taskArrItem.allData.slice(-maxNumb)
  taskArrItem.allData.length > maxNumb ? taskArrItem.allData.length = taskArrItem.allData.length - maxNumb : taskArrItem.allData.length = 0
  taskArrItem.whileRequests.push(...whileRequest)
  for (const item of whileRequest) {
    isUpdate(item)
  }
  // 单个分片请求
  async function isUpdate(needObj:AllDataItem){
    const fd = new FormData()
    const { file,fileMd5,sliceFileSize,index,fileSize,fileName,sliceNumber } = needObj
    fd.append('file',file as File)
    fd.append('fileMd5',fileMd5)
    fd.append('sliceFileSize',String(sliceFileSize))
    fd.append('index',String(index))
    fd.append('fileSize',String(fileSize))
    fd.append('fileName',fileName)
    fd.append('sliceNumber',String(sliceNumber))
    const res = await update(fd,(cancel)=>{ needObj.cancel = cancel }).catch(()=>{})
    if(taskArrItem.state === 5 || taskArrItem.state === 3){return}  // 你的状态都已经变成暂停或者中断了,就什么都不要再做了,及时停止
    // 请求异常,或者请求成功服务端返回报错都按单片上传失败逻辑处理,.then.catch的.catch是只能捕捉请求异常的
    if(!res || res.result === -1){
      taskArrItem.errNumber++
      // 超过3次之后直接上传中断
      if(taskArrItem.errNumber > 3){
        console.log('超过三次了')
        pauseUpdate(taskArrItem,false)  // 上传中断
      }else{
        console.log('还没超过3次')
        isUpdate(needObj)  // 失败了一片,单个分片请求
      }
    }else{
      const { result,data } = res
      if(result === 1){
        sliceProgress(needObj,taskArrItem,progressTotal)  // 更新进度条
        taskArrItem.errNumber > 0 ? taskArrItem.errNumber-- : ''
        taskArrItem.finishNumber++
        needObj.finish = true
        taskArrItem.whileRequests = taskArrItem.whileRequests.filter(item => item.index !== needObj.index)  // 上传成功了就删掉请求中数组中的那一片
        console.log(taskArrItem.whileRequests.length,'请求成功了')
        if(taskArrItem.finishNumber === sliceNumber){
          const resB = await mergeSlice(data).catch(()=>{})
          resB && resB.result === 1 ? isFinishTask(taskArrItem) : pauseUpdate(taskArrItem,false)
          taskArrItem.finishNumber = 0
        }else{
          slicesUpdate(taskArrItem)
        }
      }else if(result === -2){
        pauseUpdate(taskArrItem,false)
        message('服务器剩余容量不足! 请清空本地和服务器存储的文件')
      }
    }
  }
}
  • 留意下图中控制台 requests 数量和接口的 Status 值,值为 pending 为待定,永远保持出现 pending(待定) 的接口总数<=6个,这就是我们需要的结果

6f4a0add18414b628e89b59d0cf74fd7tplv-k3u1fbpfcp-watermark.gif

5.浏览器客户端的安全设置是不允许自动访问本地资源和获取本地路径的

想用户体验好就要可以续传.那就要再次获取到 File 对象. 之前我是打算和迅雷那样一打开页面就看到所有可以继续上传的任务,想继续上传哪个就点哪个.可是浏览器的安全设置是无法实现这个功能的,除非像input标签那样用户点主动击上传,要用户参与.或者你是服务端和客户端应用.
遇到这种情况就只能改变功能了.参考网页版百度云,每次重新进来这个页面都是空的,可是拉取本地缓存里的数据放到一个变量里.一上传文件就通过哈希值判断这个文件在 indexDB 缓存里有没有,有就续传,没有就重头开始上传

6.要indexDB存的数据量越大,存储完成的速度越慢

续传功能,要每次新打开页面都获取一次本地缓存 indexDB ,所以在之前要实时存储任务对象数组到缓存.这次我使用的是一个 indexDB 库 localForage. 可是因为数据量比较大,indexDB存储的也会比较慢,这就造成了一些情况无法下次打开完全和之前关闭的进度绝对一致,例如上传中直接点击刷新或者直接关闭浏览器.我一开始是直接监听页面卸载前生命周期执行一次设置缓存,可是发现.数据量一大就设置失败,显示的还是之前的数据.
目前解决办法只能是我们去包容它这一个缺点.因为存储大容量缓存,目前就 indexDB 兼容性最好,Web SQL这种就只能兼容最新版的 Safari, Chrome 和 Opera 浏览器,ie和火狐均不支持
Snipaste_2023-02-14_07-14-34.png

  • 优化要存储的数据,不要放File数据类型.我是直接使用JSON.parse(JSON.stringify())将File对象变成了一个空对象
  • 改变逻辑,改成在监听事件里实时存储缓存,而且不要添加防抖,一添加防抖会出现明显的误差.特别是一些比较小的文件上传进度快起来是毫秒级别的
  • 因为是要实时存储到缓存的,所以执行 setItem 这个 api 前不要做一些复杂的逻辑,要做复杂的逻辑,下次获取的时候再处理
    // 监听任务改变
    watch(() => taskArr.value, (newVal,oldVal) => {
        setTaskArr()
    },{deep:true})
    
    // 存储任务到缓存
    const setTaskArr = async() =>{
      const needTaskArr = JSON.parse(JSON.stringify(taskArr.value))
      await localForage.setItem('taskArr',needTaskArr)
    }
    
    // 获取本地有没要继续上传的任务,状态为2都是可以继续上传的,1,4和5都没必要继续上传了
    // 暂停的,继续上传的,上传中断的自动继续上传
    const getTaskArr = async () =>{
      const arr = await localForage.getItem('taskArr').catch(()=>{})
        if(!arr || arr.length === 0){return}
        for (let i = 0; i < arr.length; i++) {
          const item = arr[i]
          if(item.state === 3 || item.state === 5 || item.state === 2){
            item.state = 2
            item.allData.push(...item.whileRequests)
            item.whileRequests.length = 0
          }
          if(item.state !== 2){
            arr.splice(i,1)
            i--
          }
        }
        uploadingArr = arr
    }

后端逻辑

  1. 查看文件接口 -
  • 先根据前端传过来的文件大小判断本地够不够空间,不够直接返回-2
  • 根据前端传过来的哈希值查看缓存文件夹里有没与之一致的文件夹名称,没有就返回1,有就返回-1
  1. 单个切片上传接口 -
  • 接收前端发送过来的上传分片请求,以哈希值为文件夹名称,以文件名后缀和分片的文件名称添加到指定的文件夹里
  • 文件创建完成就返回前端分片上传完成,其中一个环节报了异常就返回上传失败给前端
  1. 合并所有分片接口 -
  • 接收前端传过来的哈希值,根据这个哈希值找到服务器文件夹里与之对应的文件夹名称
  • 根据所有分片名称上包含的下标,有序的追加到可写流,最后关闭可写流,删除存放所有分片的那个文件夹
  1. 清空服务器里存储的所有文件接口 -
  • 获取缓存文件夹和完成文件夹里的所有文件,删除里边所有文件和文件夹,不包括.gitignore文件

遇到的问题和解决办法

1.node进程异常导致之后都无法再次访问服务器

开发过程中,要不断的测试数据传给后端,然后调试后端接口代码的逻辑.这时候就很容易出现api报错造成的主线程异常. 因为node服务器是单线程的,当主进程挂掉,整个服务器都是停止,如果不手动重启服务器,之后都无法再访问服务器.这样很不利于开发,总是以为前端出了bug.所以后端要捕捉好所有异常,完全杜绝服务器崩溃

  • try...catch... 每一个接口返回执行的逻辑有异常隐患的都用 try...catch... 做好逻辑处理
try{

}catch(err){
  console.log(err,'报错')
}
  • 回调函数的的返回值error,也做好逻辑处理
fs.readFile('/f1',(err,data)=>{
   if(!err){
   
   }else{
     console.log(err,'报错')
   }
})
  • process.on('uncaughtException')和process.on('unhandledRejection') 监听剩下没有捕捉到的进程异常,完全杜绝node服务器崩溃
process.on('uncaughtException', (err) => {
  console.log(err, 'js异常')
})
process.on('unhandledRejection', (err) => {
  console.log(err, 'Promise异常')
})

2.服务器的两个专门用来存储数据的空文件夹无法推送到远程git

服务器是要提前准备两个空文件夹的,一个是放所有分片,一个是放所有上传完成的整个文件.可是,git是无法识别空文件夹的.空文件夹都无法推送上去
解决办法是手动在这个文件夹里添加一个空的文本文档占位,反正就是不要空,我是两个空文件夹分别都添加一个名称为 .gitignore 的空文本文档 9c4375f315194c49a2bb66d06e504d25tplv-k3u1fbpfcp-watermark.png

3.os.freemem()获取到的值与文件所在的磁盘剩余空间不一致

每次接受前端传过来的分片或者要合并所有分片的时候都要看一下本地空间够不够,不够是无法正常存储到服务器上的.这时候就要判断本地空间够不够了.查了一下是使用os模块下的freemem,可是因为是本地开发的原因,看剩余的大小总是和本地磁盘的空间大小不一致,最后细细研究了一下才反应过来
os.freemem() 获取到的其实是空闲内存量,和本地磁盘的剩余空间不是一个东西,服务器显示的都是几G内存,所以 os.freemem() 这个api主要是计算服务器空闲内存的,不一致也是正常的,本地开发的时候用这个 os.freemem() 做逻辑判断就可以了 a34f3ce7ddc84faa8c7a444ec2ebc9cdtplv-k3u1fbpfcp-watermark.png

最终效果

0f2592844b2744b084c56920079d41e2_tplv-k3u1fbpfcp-watermark.gif 点击查看项目源码